diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..fcfdae9f4 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,36 @@ +# Add 'pluto-core' label to any changes within 'pluto' or 'plugin' module +core: +- pluto/** +- pluto-plugins/base/** +- pluto-plugins/bundle/** +- .github/** + +# Add 'script' label to any changes within 'scripts' folder +script: +- script/** + +# Add 'sample' label to any changes within 'sample' module +sample: +- sample/** + +# labels for plugin changes +datastore: +- pluto-plugins/plugins/datastore/** + +exceptions: +- pluto-plugins/plugins/exceptions/** + +layout-inspector: +- pluto-plugins/plugins/layout-inspector/** + +logger: +- pluto-plugins/plugins/logger/** + +network: +- pluto-plugins/plugins/network/** + +rooms-database: +- pluto-plugins/plugins/rooms-database/** + +shared-preferences: +- pluto-plugins/plugins/shared-preferences/** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef3af2b29..9be5c848d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,12 +14,19 @@ jobs: - name: Build run: ./gradlew --no-daemon --max-workers 8 --build-cache assembleDebug + build-no-op: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build No Op + run: ./gradlew --no-daemon --max-workers 8 --build-cache assembleDebugNoOp + ktlint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Validating Ktlint - run: ./gradlew --no-daemon --max-workers 8 --build-cache ktlintDebugCheck + run: ./gradlew --no-daemon --max-workers 8 --build-cache ktlintCheck detekt: runs-on: ubuntu-latest diff --git a/.github/workflows/daily_builds.yml b/.github/workflows/daily_builds.yml index c76fedd5d..47365034c 100644 --- a/.github/workflows/daily_builds.yml +++ b/.github/workflows/daily_builds.yml @@ -2,7 +2,8 @@ name: Daily Builds on: schedule: - - cron: '0 20 * * *' + - cron: '0 20 * * *' # Runs daily at 20:00 UTC + workflow_dispatch: # Enables manual trigger jobs: build: diff --git a/.github/workflows/pr_label.yml b/.github/workflows/pr_label.yml new file mode 100644 index 000000000..461356907 --- /dev/null +++ b/.github/workflows/pr_label.yml @@ -0,0 +1,22 @@ +# This workflow will triage pull requests and apply a label based on the +# paths that are modified in the pull request. +# +# To use this workflow, you will need to set up a .github/labeler.yml +# file with configuration. For more information, see: +# https://github.com/actions/labeler + +name: Labeler +on: [pull_request_target] + +jobs: + label: + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/labeler@v4 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/process_release.yml b/.github/workflows/process_release.yml new file mode 100644 index 000000000..009109261 --- /dev/null +++ b/.github/workflows/process_release.yml @@ -0,0 +1,80 @@ +name: Release Workflow +on: + release: + types: + - created + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Run on New Release + run: | + echo "A new release was created." + echo "Release name: ${{ github.event.release.name }}" + echo "Release tag: ${{ github.event.release.tag_name }}" + + - name: Validating Release + run: | + pattern="^v[0-9]+(\.[0-9]+)+$" + if [[ ${{ github.event.release.name }} =~ $pattern ]]; then + echo "${{ github.event.release.name }} is valid" + else + echo "${{ github.event.release.name }} is not valid" + exit 1 + fi + + - name: Extract Version details + run: | + releaseName="${{ github.event.release.name }}" + echo "release name: $releaseName" + versionName=${releaseName:1} + echo "version name: $versionName" + IFS='.' read -ra versionStubs <<< "$versionName" + echo "versionMajor : ${versionStubs[0]}" + echo "versionMinor : ${versionStubs[1]}" + echo "versionPatch : ${versionStubs[2]}" + + echo "versionMajor=${versionStubs[0]}" >> $GITHUB_ENV + echo "versionMinor=${versionStubs[1]}" >> $GITHUB_ENV + echo "versionPatch=${versionStubs[2]}" >> $GITHUB_ENV + + - name: Commit version.properties changes + run: | + git fetch --all + git checkout develop + echo "major=${{ env.versionMajor }}" > version.properties + echo "minor=${{ env.versionMinor }}" >> version.properties + echo "patch=${{ env.versionPatch }}" >> version.properties + echo "channel=release" >> version.properties + echo "build=0" >> version.properties + + git config --local user.email "${GITHUB_ACTOR}@users.noreply.github.com" + git config --local user.name "${GITHUB_ACTOR}" + git add version.properties + + commitTitle="ci(release): version bump ${{ env.versionMajor }}.${{ env.versionMinor }}.${{ env.versionPatch }}" + commitBody="see release details https://github.com/androidPluto/pluto/releases/tag/${{ github.event.release.tag_name }}" + git commit -m "$commitTitle" -m "$commitBody" + + - name: Publish artifacts to MavenCentral +# run: ./gradlew publish + run: ./gradlew publishReleasePublicationToSonatypeRepository --max-workers 1 closeAndReleaseSonatypeStagingRepository + env: + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + SIGNING_SECRET_KEY: ${{ secrets.SIGNING_SECRET_KEY }} + SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }} + + - name: Pushing changes to version.properties post deployment + run: | + git fetch --all + git checkout develop + git push + + diff --git a/.gitignore b/.gitignore index 8f8e39112..4b1eec440 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,14 @@ *.iml .gradle -/local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml +*local.properties .DS_Store /build /captures .externalNativeBuild .cxx -/.idea/.name -/.idea/codeStyles/codeStyleConfig.xml -/.idea/gradle.xml -/.idea/misc.xml -/.idea/codeStyles/Project.xml -/.idea/runConfigurations.xml -/.idea/vcs.xml -/.idea/jarRepositories.xml -/.idea/.gitignore -/.idea/compiler.xml +/.idea/* +/scripts/publish/_credentials.properties +/scripts/publish/_newCreds.properties +*.gpg +/buildSrc/build +mavenCredentials.properties diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4a65bb61..0d37cc849 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -Contributing + + + + +**We're looking for contributors, help us improve Pluto.** 😁 + + + +Hers's how you can help + - Look for issues marked as [`help wanted`](https://github.com/androidPluto/pluto/labels/help%20wanted). + - Submit a new Plugin, see the [guidelines](/SUBMIT_GUIDELINES.md). + - While submitting a new PR, + - make sure tests are all successful + - if you think we need any new test, feel free to add new tests + - please attach a reference video or image for the change + +### Prerequisite + +In order to start contributing to Pluto, you need to fork the project and open it in Android Studio/IntelliJ IDEA. + +Before committing we suggest you install the pre-commit hooks with the following command: +``` +./gradlew installGitHook +``` + +This will make sure your code is validated against `ktLint` and `detekt` before every commit. +The command will run automatically before the `clean` task, so you should have the pre-commit hook installed by then. + +Before submitting a PR please run: +``` +./gradlew build +``` +This will build the library and make sure your CI checks will pass. + +> +> Before your code can be accepted into the project you must also sign the [Individual Contributor License Agreement (CLA)][1]. You will be prompted to sign the agreement once you raise the pull request. +> + +[1]: https://cla-assistant.io/androidPluto/pluto + diff --git a/README.md b/README.md index 271d5a2ce..b943aa90a 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,131 @@ -# Pluto -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.mocklets/pluto/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.mocklets/pluto) -[![CLA assistant](https://cla-assistant.io/readme/badge/mocklets/pluto)](https://cla-assistant.io/mocklets/pluto) -[![Daily Builds](https://github.com/mocklets/pluto/actions/workflows/daily_builds.yml/badge.svg)](https://github.com/mocklets/pluto/actions/workflows/daily_builds.yml) +# Android Pluto -Pluto is a on-device debugger for Android applications, which helps in inspection of HTTP requests/responses, capture Crashes and ANRs and manipulating application data on-the-go. +![Maven Central Version](https://img.shields.io/maven-central/v/com.androidpluto/pluto) +[![CLA assistant](https://cla-assistant.io/readme/badge/androidPluto/pluto)](https://cla-assistant.io/androidPluto/pluto) +[![Daily Builds](https://github.com/androidPluto/pluto/actions/workflows/daily_builds.yml/badge.svg)](https://github.com/androidPluto/pluto/actions/workflows/daily_builds.yml) -It comes with a UI to monitor and share the information, as well as APIs to access and use that information in your application. - - -

- pluto demo -

- ------ - -### Pluto Dependency graph - -

- -

+Pluto is an on-device debugging framework for Android applications, which helps in the inspection of HTTP requests/responses, captures Crashes, and ANRs, and manipulates application data on the go. -*** +It comes with a UI to monitor and share the information, as well as APIs to access and use that information in your application. +
-## Integrate Pluto in your application +## πŸ–‡  Integrate Pluto in your application ### Add Gradle Dependencies -Pluto is distributed through [***mavenCentral***](https://search.maven.org/artifact/com.mocklets/pluto). To use it, you need to add the following Gradle dependency to your build.gradle file of you android app module. - - +Pluto is distributed through [***mavenCentral***](https://central.sonatype.com/artifact/com.androidpluto/pluto). To use it, you need to add the following Gradle dependency to your build.gradle file of your app module. -> Note: add both the pluto and the pluto-no-op variant to isolate Pluto from release builds. +> Note: add both the `pluto` and the `pluto-no-op` variant to isolate Pluto from release builds. ```groovy +def plutoVersion = "3.0.0" + dependencies { - debugImplementation 'com.mocklets:pluto:LATEST_VERSION' - releaseImplementation'com.mocklets:pluto-no-op:LATEST_VERSION' + .... + debugImplementation "com.androidpluto:pluto:$plutoVersion" + releaseImplementation "com.androidpluto:pluto-no-op:$plutoVersion" + .... } ``` -### Intialize Pluto +### Initialize Pluto -Now to start using Pluto, intialize Pluto SDK from you application class by passing context to it. +Now to start using Pluto, initialize Pluto SDK from you application class by passing context to it. ```kotlin -Pluto.initialize(context) +Pluto.Installer(this) + .addPlugin(....) + .install() ``` +### Install plugins -### Add Pluto interceptor - -To debug HTTP requests/responses, plug the PlutoInterceptor in your OkHttp Client Builder -```kotlin -val client = OkHttpClient.Builder() - .addInterceptor(PlutoInterceptor()) - .build() -``` - -### Set Global Exception Handler +Unlike [version 1.x.x](https://github.com/androidPluto/pluto/wiki/Integrating-Pluto-1.x.x), Pluto now allows developers to add debuggers as plugin bundle or individual plugins based on their need. -To intercept uncaught exceptions in your app, attach `UncaughtExceptionHandler` to Pluto -```kotlin -Pluto.setExceptionHandler { thread, throwable -> - Log.d("Exception", "uncaught exception handled on thread: " + thread.name, throwable) +Plugin bundle comes with all the basic plugins bundled together as single dependency. +```groovy +dependencies { + .... + debugImplementation "com.androidpluto.plugins:bundle-core:$plutoVersion" + releaseImplementation "com.androidpluto.plugins:bundle-core-no-op:$plutoVersion" + .... } ``` +But, if you want to use individual plugins, here is the list of some plugins provided by us -### Listen to ANRs +- **[Network Plugin](pluto-plugins/plugins/network)** +- **[Exceptions & Crashes Plugin](pluto-plugins/plugins/exceptions)** +- **[Logger Plugin](pluto-plugins/plugins/logger)** +- **[Shared Preferences Plugin](pluto-plugins/plugins/shared-preferences)** +- **[Rooms Database Plugin](pluto-plugins/plugins/rooms-database)** +- **[Datastore Preferences Plugin](pluto-plugins/plugins/datastore)** +- **[Layout Inspector Plugin](pluto-plugins/plugins/layout-inspector)** -Pluto can capture and store potential ANRs occurring in the app. You can also listen to these ANRs and report these to any Crash reporting tools like Firebase Crashlytics, Bugsnag, etc. -```kotlin -Pluto.setANRListener(object: ANRListener { - override fun onAppNotResponding(exception: ANRException) { - exception.printStackTrace() - PlutoLog.e("ANR", exception.threadStateMap) - } -}) -``` +We will be adding more to the [list](https://central.sonatype.com/search?q=com.androidpluto.plugins). So please stay tuned.
+Please refer to their respective README for integration steps. +

+> You can also help us expand the Pluto ecosystem now.
Pluto now allows to develop custom debuggers as plugin. Read [Develop Custom Plugins](https://github.com/androidPluto/pluto/wiki/Develop-Custom-Pluto-Plugins-(Beta)). +
+πŸŽ‰  You are all set! -### Add Pluto Logs +Now re-build and run your app, you will receive a notification from Pluto, use it to access Pluto UI. -Pluto allows you to log and persist the user journey through the app, and help debug them without any need to connect to Logcat. -```kotlin -PlutoLog.event("analytics", eventName, HashMap(attributes)) -PlutoLog.d("debug_log", "button clicked") -PlutoLog.e("error_log", "api call falied with http_status 400") -PlutoLog.w("warning_log", "warning log") -PlutoLog.i("info_log", "api call completed") -``` +
-But if you are connected to Logcat, PlutoLogs behave similar to Log class, with an improvement to tag the method and file name also. In Logcat, PlutoLogs will look like the following. -``` -D/onClick(MainActivity.kt:40) | debug_log: button clicked -E/onFailure(NetworkManager.kt:17) | error_log: api call falied with http_status 400 -``` +## Grouping Plugins *(Optional)* +Pluto now allows to group similar plugins together to have better readability & categorization. +
+To create a group, we need to override PluginGroup & attach Plugins to it. *(We have taken the example of grouping datasource plugins together)* +```kotlin +class DataSourcePluginGroup : PluginGroup("datasource-group") { -### Set App Properties + override fun getConfig() = PluginGroupConfiguration( + name = "DataSource Group" + ) -Pluto allows storing information like App status(like app configurations), User properties(like email, profile) and Device fingerprint(like IMEI). + override fun getPlugins() = listOf( + PlutoSharePreferencesPlugin(), + PlutoDatastorePreferencesPlugin(), + PlutoRoomsDatabasePlugin() + ) +} +``` -This data can later be accessed via Pluto debug UI. This method can be called multiple times and it will keep on appending the data. +Then add the group to Plugin installer. ```kotlin -Pluto.setAppProperties(hashMapOf( - "User id" to "2whdue-dn4f-3hr-dfhrhs", - "User email" to "john.smith@gmail.com" -)) +Pluto.Installer(this) + .addPluginGroup(DataSourcePluginGroup()) + .install() ``` -πŸŽ‰ You are all done! +
-Now re-build and run your app, you will receive a notification from Pluto, use it to access Pluto UI. - -*** - - -## Contribution +## πŸ“  Contribution **We're looking for contributors, help us improve Pluto.** 😁 -Hers's how you can help - - Look for issues marked as [`help wanted`](https://github.com/mocklets/pluto/labels/help%20wanted) - - While submitting a new PR, make sure tests are all successful. If you think we need any new test, feel free to add new tests. - -### Prerequisite - -In order to start working on Pluto, you need to fork the project and open it in Android Studio/IntelliJ IDEA. - -Before committing we suggest you install the pre-commit hooks with the following command: -``` -./gradlew installGitHook -``` - -This will make sure your code is validated against `ktLint` and `detekt` before every commit. -The command will run automatically before the `clean` task, so you should have the pre-commit hook installed by then. - -Before submitting a PR please run: -``` -./gradlew build -``` -This will build the library and make sure your CI checks will pass. - -*** - - -## Acknowledgments +Please refer to your [`Contribution guidelines`](/CONTRIBUTING.md) to get started. -Big thanks πŸ™ to [ChuckerTeam/chucker](https://github.com/ChuckerTeam/chucker) for inspiration behind network interceptor code. +
+Have an idea to improve Pluto? Let's connect on +- [Twitter](https://twitter.com/srtv_prateek) +- [Github](https://github.com/srtvprateek) -*** +
-## License +## πŸ“ƒ  License ``` -Copyright 2021 Graylattice Communications Private Limited. +Copyright 2021 Android Pluto. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/SUBMIT_GUIDELINES.md b/SUBMIT_GUIDELINES.md new file mode 100644 index 000000000..d075a7cae --- /dev/null +++ b/SUBMIT_GUIDELINES.md @@ -0,0 +1,36 @@ +## Submit a plugin to Pluto + +We are looking for contributors to add new & exciting Plugins for the Android developers out there. But at the same time we need to made the uniformity and standard through out the plugins. + +To start with a new plugins, start a discussion for [Plugin Suggestions](https://github.com/androidPluto/pluto/discussions/categories/plugin-suggestions). This will help you gather valuable feedback and insights even before putting any dev effort. + +Once you have enough information, lets jump into development. + +
+ +### πŸ›   Develop + +- We have a uniform package name for all our plugins `com.pluto.plugins.`. Please make sure you follow this. +- All the plugins need to accept `identifier` in `Plugin.kt` constructor. This helps developers to open the Plugin directly using the `identifier`. +- In your plugin's *build.gradle*, mention `resourcePrefix 'pluto_PLUGIN-NAME___'`. This will make sure your resources are not being overridden by the parent apps. +- Make sure you add both `lib` & `lib-no-op` variants. +
+ +### πŸš€  Publish + +- Make sure you use the same version logic as the whole project. +- Setup your Maven configuration, like given below +``` groovy +ext { + PUBLISH_GROUP_ID = "com.androidpluto.plugins" // do not change this + PUBLISH_VERSION = verPublish // do not change this + PUBLISH_ARTIFACT_ID = 'PLUGIN_NAME' +} +``` +
+ +### πŸŽ–  Get your plugin merged + +Once the development is done, its time to get your code merged. Open a Pull Request with label `submit plugin`. + +We will review it & if everything is good, it ll be merged to **develop** 🎊. diff --git a/build-utils.gradle b/build-utils.gradle deleted file mode 100644 index 2526e743c..000000000 --- a/build-utils.gradle +++ /dev/null @@ -1,24 +0,0 @@ -ext.genVersion = { - Properties versionProps = new Properties() - versionProps.load(new FileInputStream(file("$project.rootDir/pluto-version.properties"))) - - def versionMajor = versionProps['major'].toInteger() - def versionMinor = versionProps['minor'].toInteger() - def versionPatch = versionProps['patch'].toInteger() - def versionBuild = versionProps['build'].toInteger() - def versionChannel = versionProps['channel'] - - def verCode = (versionMajor * 1000000) + (versionMinor * 10000) + (versionPatch * 100) + versionBuild - def verName = "${versionMajor}.${versionMinor}.${versionPatch}.${versionBuild}" - def verNameShort = "${versionMajor}.${versionMinor}.${versionPatch}" - def verPublish = "${versionMajor}.${versionMinor}.${versionPatch}" - if (versionChannel != "release") { - verPublish = "${verPublish}-${versionChannel}" - } - - return [verCode, verName, versionBuild, verNameShort, verPublish] -} - -ext.gitSha ={ - return 'git rev-parse --short=10 HEAD'.execute().text.trim() -} diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 04d5de07e..000000000 --- a/build.gradle +++ /dev/null @@ -1,76 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -apply plugin: 'org.jetbrains.dokka' -apply plugin: 'io.github.gradle-nexus.publish-plugin' - -buildscript { - ext.kotlin_version = '1.5.20' - ext.detekt_version = '1.13.1' - repositories { - maven { url "https://plugins.gradle.org/m2/" } - google() - jcenter() - mavenCentral() - } - dependencies { - classpath 'com.android.tools.build:gradle:4.2.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "com.github.dcendents:android-maven-gradle-plugin:2.1" - - // maven central dependencies - classpath 'io.github.gradle-nexus:publish-plugin:1.1.0' - classpath 'org.jetbrains.dokka:dokka-gradle-plugin:1.4.30' - - // ktlint & detekt dependencies - classpath "org.jlleitschuh.gradle:ktlint-gradle:9.4.0" - classpath "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:$detekt_version" - } -} - -allprojects { - repositories { - google() - jcenter() - } -} - -subprojects { - if (project.name != "pluto-no-op") { - pluginManager.withPlugin('kotlin-android') { - apply from: "$rootDir/static-analysis/code-analysis.gradle" - } - } -} - -ext { - minSdkVersion = 19 - targetSdkVersion = 29 - compileSdkVersion = 29 - buildToolsVersion = '29.0.3' - - androidXCoreVersion = '1.5.0' - okhttpVersion = '3.14.4' - roomsVersion = '2.3.0' -} - -task installGitHook(type: Copy) { - from new File(rootProject.rootDir, 'pre-commit') - into { new File(rootProject.rootDir, '.git/hooks') } - fileMode 0777 -} - -task clean(type: Delete) { - dependsOn(installGitHook) - delete rootProject.buildDir -} - -tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { - exclude(".*/resources/.*,.*/build/.*") -} - -task prCheck { - dependsOn ':sample:assembleDebug' - dependsOn ':pluto:validateChanges' -} - -apply from: "${rootDir}/scripts/publish-root.gradle" -apply from: "${rootDir}/scripts/project-dependancy-graph.gradle" \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..65c024c88 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,56 @@ +import org.gradle.api.tasks.Copy +import org.gradle.api.tasks.Delete +import io.gitlab.arturbosch.detekt.Detekt + +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.dokka) + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.ktlint) apply false +} + +allprojects { + repositories { + google() + mavenCentral() + // for testing Pluto staged repository + maven("https://s01.oss.sonatype.org/content/groups/staging/") + } +} + +subprojects { +// if (project.name != "pluto-no-op") { + pluginManager.withPlugin("kotlin-android") { + apply (from = "$rootDir/scripts/static-analysis/code-analysis.gradle") + } +// } +} + +val installGitHook by tasks.registering(Copy::class) { + from(File(rootProject.rootDir, "pre-commit")) + into(File(rootProject.rootDir, ".git/hooks")) + fileMode = "0777".toInt(8) +} + +val clean by tasks.registering(Delete::class) { + dependsOn(installGitHook) + delete(rootProject.buildDir) +} + +tasks.withType().configureEach { + exclude(".*/resources/.*,.*/build/.*") +} + +val prCheck by tasks.registering { + dependsOn(":sample:assembleDebug") + dependsOn(":pluto:validateChanges") +} + +apply (from = "$rootDir/scripts/project-dependancy-graph.gradle") +apply (from = "$rootDir/maven-versions.gradle.kts") +apply(from = "$rootDir/publishTasks.gradle.kts") \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 000000000..b22ed732f --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Versioning.kt b/buildSrc/src/main/kotlin/Versioning.kt new file mode 100644 index 000000000..b62113ba3 --- /dev/null +++ b/buildSrc/src/main/kotlin/Versioning.kt @@ -0,0 +1,40 @@ +import java.io.File +import java.util.Properties + +object Versioning { + fun loadVersioningData(): Map { + val version = mutableMapOf() + + val versionProps = Properties().apply { + load(File("${System.getProperty("user.dir")}/version.properties").inputStream()) + } + + val versionMajor = versionProps["major"]?.toString()?.toInt() ?: 0 + val versionMinor = versionProps["minor"]?.toString()?.toInt() ?: 0 + val versionPatch = versionProps["patch"]?.toString()?.toInt() ?: 0 + val versionBuild = versionProps["build"]?.toString()?.toInt() ?: 0 + val versionChannel = versionProps["channel"]?.toString() ?: "release" + + version["code"] = (versionMajor * 1_000_000) + (versionMinor * 10_000) + (versionPatch * 100) + versionBuild + version["name"] = "$versionMajor.$versionMinor.$versionPatch-rc$versionBuild" + + var publishVersion = "$versionMajor.$versionMinor.$versionPatch" + if (versionChannel != "release") { + publishVersion = "$publishVersion-$versionChannel$versionBuild" + } + version["publish"] = publishVersion + + val gitSha = "git rev-parse --short=10 HEAD".runCommand()?.trim() ?: "" + version["gitSha"] = gitSha + + return version + } + + private fun String.runCommand(): String? { + return try { + ProcessBuilder("/bin/sh", "-c", this).start().inputStream.bufferedReader().readText() + } catch (e: Exception) { + null + } + } +} diff --git a/gradle.properties b/gradle.properties index 83df5e3a0..9b9c7086d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,19 +1,36 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit +## For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html +# # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx1536m +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn +#Thu Sep 14 17:27:07 IST 2023 android.useAndroidX=true -# Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official +org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" + +# Maven Central publish credentials +mavenCentralUsername=XXXX +mavenCentralPassword=XXXX +signing.keyId=XXXX +signing.password=XXXX +signing.secretKeyRingFile=XXXX + +# Maven Central publish pom details +pom.inceptionYear=2024 +pom.url=https://androidpluto.com +pom.license.name=The Apache Software License, Version 2.0 +pom.license.url=http://www.apache.org/licenses/LICENSE-2.0.txt +pom.developer.id=srtvprateek +pom.developer.name=Prateek Srivastava +pom.developer.email=srtv.prateek@gmail.com +pom.scm.connection=https://github.com/androidPluto/pluto +pom.scm.developerConnection=https://github.com/srtvprateek +pom.scm.url=https://github.com/androidPluto/pluto + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..0f6a311e6 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,94 @@ +[versions] + +testCore = "1.6.1" +coreTesting = "2.2.0" +java = "1.8" + +minSdk = "21" +mockitoCore = "5.10.0" +robolectric = "4.11.1" +runner = "1.6.2" +targetSdk = "32" +compileSdk = "34" +buildTools = "34.0.0" + +agp = "8.6.0" +androidXCore = "1.6.0" +androidXLifecycle = "2.8.7" +detekt = "1.19.0" +kotlin = "1.9.22" +ktlint-plugin = "11.1.0" +ktor = "2.3.2" +ksp = "1.9.22-1.0.16" +moshi = "1.15.1" +navigation = "2.8.6" +okhttp = "4.12.0" +retrofit = "2.9.0" +room = "2.5.1" +mavenPublish = "0.28.0" + +[plugins] + +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +dokka = { id = "org.jetbrains.dokka", version = "1.5.0" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" } +maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish"} + +[libraries] + +androidx-annotation = { module = "androidx.annotation:annotation", version = "1.4.0" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.4.0" } +androidx-browser = { module = "androidx.browser:browser", version = "1.4.0" } +androidx-cardview = { module = "androidx.cardview:cardview", version = "1.0.0" } +androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.1.2" } +androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidXCore" } +androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-test-core = { module = "androidx.test:core", version.ref = "testCore" } +androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidXLifecycle" } +androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidXLifecycle" } +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidXLifecycle" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidXLifecycle" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" } +androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "navigation" } +androidx-preference = { module = "androidx.preference:preference-ktx", version = "1.2.0" } +androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } +androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } +androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version = "1.1.0" } + +datastore-preferences = { module = "androidx.datastore:datastore-preferences", version = "1.0.0" } + +google-material = { module = "com.google.android.material:material", version = "1.6.1" } + +junit = { module = "junit:junit", version = "4.13.2" } + +kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.6.0" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.5.1" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-core-jvm = { module = "io.ktor:ktor-client-core-jvm", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } + +leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.7" } + +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" } +moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } +moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } + +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +okio = { module = "com.squareup.okio:okio", version = "2.10.0" } + +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +room = { module = "androidx.room:room-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } + +timber = { module = "com.jakewharton.timber:timber", version = "5.0.1" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c99d73470..2ec4e51cd 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Dec 25 12:26:30 IST 2020 +#Tue Feb 08 21:06:21 IST 2022 distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip +zipStoreBase=GRADLE_USER_HOME diff --git a/maven-versions.gradle.kts b/maven-versions.gradle.kts new file mode 100644 index 000000000..e4317b9b7 --- /dev/null +++ b/maven-versions.gradle.kts @@ -0,0 +1 @@ +extra["plutoVersion"] = "2.0.5" \ No newline at end of file diff --git a/pluto-no-op/build.gradle b/pluto-no-op/build.gradle deleted file mode 100644 index 4682a1c0b..000000000 --- a/pluto-no-op/build.gradle +++ /dev/null @@ -1,51 +0,0 @@ -plugins { - id 'com.android.library' - id 'kotlin-android' -} - -apply from: "$project.rootDir/build-utils.gradle" -apply from: "${rootDir}/scripts/publish-module.gradle" - -def verCode, verName, verBuild, verNameShort, verPublish -(verCode, verName, verBuild, verNameShort, verPublish) = genVersion() - -ext { - PUBLISH_GROUP_ID = "com.mocklets" - PUBLISH_VERSION = verPublish - PUBLISH_ARTIFACT_ID = 'pluto-no-op' -} - -android { - compileSdkVersion rootProject.compileSdkVersion - buildToolsVersion rootProject.buildToolsVersion - - defaultConfig { - minSdkVersion rootProject.minSdkVersion - targetSdkVersion rootProject.targetSdkVersion - versionCode verCode - versionName verName - } - - buildTypes { - release { - minifyEnabled false - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.6' - } -} - -dependencies { - implementation "androidx.core:core-ktx:$androidXCoreVersion" - implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" -} - -task buildAndUpload { - dependsOn 'assemble' - dependsOn 'bintrayUpload' -} \ No newline at end of file diff --git a/pluto-no-op/src/main/AndroidManifest.xml b/pluto-no-op/src/main/AndroidManifest.xml deleted file mode 100644 index 324c9d04c..000000000 --- a/pluto-no-op/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/pluto-no-op/src/main/java/com/mocklets/pluto/Pluto.kt b/pluto-no-op/src/main/java/com/mocklets/pluto/Pluto.kt deleted file mode 100644 index 2649f165c..000000000 --- a/pluto-no-op/src/main/java/com/mocklets/pluto/Pluto.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.mocklets.pluto - -import android.content.Context -import androidx.annotation.Keep -import com.mocklets.pluto.modules.exceptions.ANRListener - -@Keep -object Pluto { - - @JvmOverloads - fun initialize(context: Context, shouldShowIntroToast: Boolean = true) {} - - fun setAppProperties(properties: HashMap) {} - - fun setExceptionHandler(uncaughtExceptionHandler: Thread.UncaughtExceptionHandler) {} - - fun setANRListener(listener: ANRListener) {} - - fun showUi() {} -} diff --git a/pluto-no-op/src/main/java/com/mocklets/pluto/PlutoInterceptor.kt b/pluto-no-op/src/main/java/com/mocklets/pluto/PlutoInterceptor.kt deleted file mode 100644 index e136065bc..000000000 --- a/pluto-no-op/src/main/java/com/mocklets/pluto/PlutoInterceptor.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mocklets.pluto - -import androidx.annotation.Keep -import okhttp3.Interceptor -import okhttp3.Response - -@Keep -class PlutoInterceptor : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - return chain.proceed(request) - } -} diff --git a/pluto-no-op/src/main/java/com/mocklets/pluto/PlutoLog.kt b/pluto-no-op/src/main/java/com/mocklets/pluto/PlutoLog.kt deleted file mode 100644 index 3caf7d037..000000000 --- a/pluto-no-op/src/main/java/com/mocklets/pluto/PlutoLog.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.mocklets.pluto - -import androidx.annotation.Keep - -@Keep -class PlutoLog private constructor() { - - companion object { - @JvmStatic - fun v(tag: String, message: String?, tr: Throwable? = null) {} - - @JvmStatic - fun d(tag: String, message: String?, tr: Throwable? = null) {} - - @JvmStatic - fun i(tag: String, message: String?, tr: Throwable? = null) {} - - @JvmStatic - fun w(tag: String, message: String?, tr: Throwable? = null) {} - - @JvmStatic - fun e(tag: String, message: String?, tr: Throwable? = null) {} - - @JvmStatic - fun event(tag: String, event: String, attributes: HashMap?) {} - } -} diff --git a/pluto-no-op/src/main/java/com/mocklets/pluto/modules/exceptions/ANRException.kt b/pluto-no-op/src/main/java/com/mocklets/pluto/modules/exceptions/ANRException.kt deleted file mode 100644 index ec6f92279..000000000 --- a/pluto-no-op/src/main/java/com/mocklets/pluto/modules/exceptions/ANRException.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mocklets.pluto.modules.exceptions - -import androidx.annotation.Keep - -@Keep -class ANRException(thread: Thread) : Exception("ANR detected in Pluto-No-Op") { - - val threadStateMap: String - - init { - stackTrace = thread.stackTrace - threadStateMap = "" - } -} diff --git a/pluto-no-op/src/main/java/com/mocklets/pluto/modules/exceptions/ANRListener.kt b/pluto-no-op/src/main/java/com/mocklets/pluto/modules/exceptions/ANRListener.kt deleted file mode 100644 index 30c4d19dd..000000000 --- a/pluto-no-op/src/main/java/com/mocklets/pluto/modules/exceptions/ANRListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.mocklets.pluto.modules.exceptions - -import androidx.annotation.Keep - -@Keep -interface ANRListener { - fun onAppNotResponding(exception: ANRException) -} diff --git a/pluto-no-op/.gitignore b/pluto-plugins/base/lib/.gitignore similarity index 100% rename from pluto-no-op/.gitignore rename to pluto-plugins/base/lib/.gitignore diff --git a/pluto-plugins/base/lib/build.gradle.kts b/pluto-plugins/base/lib/build.gradle.kts new file mode 100644 index 000000000..f60cd8506 --- /dev/null +++ b/pluto-plugins/base/lib/build.gradle.kts @@ -0,0 +1,109 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugin" + resourcePrefix = "pluto___" + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"$verPublish\"") + buildConfigField("long", "VERSION_CODE", "$verCode") + buildConfigField("String", "GIT_SHA", "\"$verGitSHA\"") + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto" +extra["PUBLISH_ARTIFACT_ID"] = "plugin" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Base Plugin Module" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Open Sourced, on-device debugger for Android apps" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + + api(libs.androidx.constraintlayout) + api(libs.androidx.navigation.fragment) + api(libs.androidx.recyclerview) + + api(libs.google.material) + + api(libs.androidx.cardview) + + api(libs.androidx.lifecycle.common) + api(libs.androidx.lifecycle.runtime) +} diff --git a/pluto-plugins/base/lib/src/main/AndroidManifest.xml b/pluto-plugins/base/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/DataModel.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/DataModel.kt new file mode 100644 index 000000000..454625f16 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/DataModel.kt @@ -0,0 +1,49 @@ +package com.pluto.plugin + +import androidx.annotation.DrawableRes + +/** + * Data class containing details about a plugin's developer. + * + * This class is used to provide information about the developer of a plugin, + * which can be displayed in the plugin's details screen. + * + * @property vcsLink Optional link to the version control system repository + * @property website Optional link to the developer's website + * @property twitter Optional link to the developer's Twitter profile + */ +data class DeveloperDetails( + val vcsLink: String? = null, + val website: String? = null, + val twitter: String? = null +) + +/** + * Data class containing configuration for a plugin. + * + * This class defines the visual and metadata properties of a plugin, + * such as its name, icon, and version. + * + * @property name The display name of the plugin + * @property icon The resource ID of the plugin's icon + * @property version The version string of the plugin + */ +data class PluginConfiguration( + val name: String, + @DrawableRes val icon: Int = R.drawable.pluto___ic_plugin_placeholder_icon, + val version: String +) + +/** + * Data class containing configuration for a plugin group. + * + * This class defines the visual properties of a plugin group, + * such as its name and icon. + * + * @property name The display name of the plugin group + * @property icon The resource ID of the plugin group's icon + */ +data class PluginGroupConfiguration( + val name: String, + @DrawableRes val icon: Int = R.drawable.pluto___ic_plugin_group_placeholder_icon, +) diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/Plugin.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/Plugin.kt new file mode 100644 index 000000000..cd3e6d801 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/Plugin.kt @@ -0,0 +1,170 @@ +package com.pluto.plugin + +import android.app.Application +import android.content.Context +import android.os.Bundle +import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT +import androidx.annotation.Keep +import androidx.fragment.app.Fragment + +/** + * Base class for all Pluto plugins. + * + * This abstract class provides the foundation for creating Pluto debugging plugins. + * It handles plugin lifecycle, configuration, and UI presentation. + * + * To create a new plugin, extend this class and implement the required abstract methods. + * + * Example: + * ``` + * class NetworkPlugin : Plugin("network") { + * override fun getConfig() = PluginConfiguration( + * name = "Network", + * icon = R.drawable.ic_network, + * version = "1.0.0" + * ) + * + * override fun getView() = NetworkFragment() + * + * override fun onPluginInstalled() { + * // Initialize plugin resources + * } + * + * override fun onPluginDataCleared() { + * // Clear plugin data + * } + * } + * ``` + * + * @property identifier A unique string identifier for the plugin + */ +@Keep +abstract class Plugin(val identifier: String) : PluginEntity(identifier) { + + /** + * The application context. + * + * This property provides access to the application context for the plugin. + * It throws an IllegalStateException if accessed before the plugin is installed. + */ + val context: Context + get() = returnContext() + + /** + * The application instance. + * + * This property provides access to the application instance for the plugin. + * It throws an IllegalStateException if accessed before the plugin is installed. + */ + val application: Application + get() = returnApplication() + + /** The internal application instance, set during installation */ + private var _application: Application? = null + + /** + * Returns the application context. + * + * @throws IllegalStateException if the plugin is not installed + * @return The application context + */ + private fun returnContext(): Context { + _application?.let { + return it.applicationContext + } + throw IllegalStateException("${this.javaClass.name} plugin is not installed yet.") + } + + /** + * Returns the application instance. + * + * @throws IllegalStateException if the plugin is not installed + * @return The application instance + */ + private fun returnApplication(): Application { + _application?.let { + return it + } + throw IllegalStateException("${this.javaClass.name} plugin is not installed yet.") + } + + /** + * Bundle for saving instance state. + * + * This bundle can be used to save and restore the plugin's state. + */ + var savedInstance: Bundle = Bundle() + private set + + /** + * Installs the plugin with the provided application instance. + * + * This method is final and cannot be overridden. It sets the application + * instance and calls onPluginInstalled(). + * + * @param application The application instance to use for installation + */ + final override fun install(application: Application) { + this._application = application + onPluginInstalled() + } + + /** + * Returns the plugin configuration. + * + * This method should provide a PluginConfiguration object that defines + * the plugin's name, icon, and version. + * + * @return The plugin configuration + */ + abstract fun getConfig(): PluginConfiguration + + /** + * Returns the plugin's UI view. + * + * This method should provide a Fragment that implements the plugin's UI. + * + * @return The plugin's UI fragment + */ + abstract fun getView(): Fragment + + /** + * Returns the plugin developer's details. + * + * This method can be overridden to provide information about the plugin's + * developer, such as VCS link, website, and Twitter handle. + * + * @return The developer details, or null if not provided + */ + open fun getDeveloperDetails(): DeveloperDetails? = null + + /** + * Called when the plugin is installed. + * + * This method is called during the plugin installation process. + * It should be used to initialize any resources needed by the plugin. + */ + abstract fun onPluginInstalled() + + /** + * Called when the plugin's data should be cleared. + * + * This method is called when the user requests to clear the plugin's data. + * It should be used to clear any cached data or logs. + */ + abstract fun onPluginDataCleared() + + /** + * Called when the plugin's view is created. + * + * This method is called when the plugin's UI is created. + * It shows a toast message indicating that the view has switched to this plugin. + * + * @param savedInstanceState The saved instance state bundle + */ + @SuppressWarnings("UnusedPrivateMember") + fun onPluginViewCreated(savedInstanceState: Bundle?) { + Toast.makeText(context, "View switched to ${getConfig().name}", LENGTH_SHORT).show() + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/PluginEntity.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/PluginEntity.kt new file mode 100644 index 000000000..5f517cead --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/PluginEntity.kt @@ -0,0 +1,42 @@ +package com.pluto.plugin + +import android.app.Application + +/** + * Base class for all Pluto plugin entities. + * + * This abstract class serves as the foundation for both individual plugins and plugin groups. + * It provides a common interface for installation and identity management. + * + * @property identifier A unique string identifier for the plugin entity + */ +abstract class PluginEntity(private val identifier: String) { + + /** + * Installs the plugin entity with the provided application instance. + * + * This method is called during Pluto initialization to set up the plugin. + * + * @param application The application instance to use for installation + */ + abstract fun install(application: Application) + + /** + * Compares this plugin entity with another object for equality. + * + * Plugin entities are considered equal if they have the same identifier. + * + * @param other The object to compare with + * @return True if the objects are equal, false otherwise + */ + override fun equals(other: Any?): Boolean = other is PluginEntity && identifier == other.identifier + + /** + * Returns a hash code value for this plugin entity. + * + * The hash code is based on the identifier to ensure consistency with equals. + * + * @return The hash code value + */ + override fun hashCode(): Int = identifier.hashCode() +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/PluginGroup.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/PluginGroup.kt new file mode 100644 index 000000000..b721cb289 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/PluginGroup.kt @@ -0,0 +1,82 @@ +package com.pluto.plugin + +import android.app.Application + +/** + * Base class for grouping related Pluto plugins. + * + * This abstract class allows multiple plugins to be grouped together under a + * common identifier. Plugin groups are useful for organizing related plugins, + * such as database inspection tools or network monitoring utilities. + * + * To create a new plugin group, extend this class and implement the required + * abstract methods. + * + * Example: + * ``` + * class DatabasePluginGroup : PluginGroup("database") { + * override fun getConfig() = PluginGroupConfiguration( + * name = "Database Tools" + * ) + * + * override fun getPlugins() = listOf( + * RoomDatabasePlugin(), + * SharedPreferencesPlugin() + * ) + * } + * ``` + * + * @param identifier A unique string identifier for the plugin group + */ +abstract class PluginGroup(identifier: String) : PluginEntity(identifier) { + + /** Set of installed plugins in this group */ + private var plugins: LinkedHashSet = linkedSetOf() + + /** + * List of all installed plugins in this group. + * + * This property returns a copy of the internal plugins set as a list, + * ensuring that the original set cannot be modified externally. + */ + val installedPlugins: List + get() { + val list = arrayListOf() + list.addAll(plugins) + return list + } + + /** + * Returns the plugin group configuration. + * + * This method should provide a PluginGroupConfiguration object that defines + * the group's name and icon. + * + * @return The plugin group configuration + */ + abstract fun getConfig(): PluginGroupConfiguration + + /** + * Returns the list of plugins in this group. + * + * This method should provide a list of all plugins that belong to this group. + * + * @return The list of plugins in this group + */ + protected abstract fun getPlugins(): List + + /** + * Installs all plugins in this group with the provided application instance. + * + * This method is final and cannot be overridden. It installs each plugin + * in the group and adds it to the internal registry of plugins. + * + * @param application The application instance to use for installation + */ + final override fun install(application: Application) { + getPlugins().forEach { + it.install(application) + plugins.add(it) + } + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/FilesInterface.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/FilesInterface.kt new file mode 100644 index 000000000..c13e2e180 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/FilesInterface.kt @@ -0,0 +1,26 @@ +package com.pluto.plugin.libinterface + +import android.app.Application +import java.io.File +import java.io.IOException +import java.util.concurrent.atomic.AtomicLong + +class FilesInterface(application: Application) { + private val uniqueIdGenerator = AtomicLong() + private val directory: File = application.applicationContext.filesDir + + fun createFile(filename: String = "pluto-${uniqueIdGenerator.getAndIncrement()}"): File? = try { + File(directory, filename).apply { + if (exists() && !delete()) { + throw IOException("Failed to delete file $this") + } + parentFile?.mkdirs() + if (!createNewFile()) { + throw IOException("File $this already exists") + } + } + } catch (e: IOException) { + IOException("An error occurred while creating a Pluto file", e).printStackTrace() + null + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/LibraryInfoInterface.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/LibraryInfoInterface.kt new file mode 100644 index 000000000..f8e29dce8 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/LibraryInfoInterface.kt @@ -0,0 +1,5 @@ +package com.pluto.plugin.libinterface + +import androidx.fragment.app.FragmentActivity + +data class LibraryInfoInterface(val pluginActivityClass: Class, val selectorActivityClass: Class) diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/NotificationInterface.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/NotificationInterface.kt new file mode 100644 index 000000000..3bfc0b7fe --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/NotificationInterface.kt @@ -0,0 +1,28 @@ +package com.pluto.plugin.libinterface + +import android.app.Application +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.fragment.app.FragmentActivity + +class NotificationInterface(private val application: Application, private val pluginActivityClass: Class) { + + fun getPendingIntent(identifier: String, bundle: Bundle? = null): PendingIntent { + val notificationIntent = Intent(application.applicationContext, pluginActivityClass) + .putExtra(ID_LABEL, identifier) + bundle?.let { notificationIntent.putExtra(BUNDLE_LABEL, it) } + notificationIntent.action = System.currentTimeMillis().toString() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity(application.applicationContext, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + } else { + PendingIntent.getActivity(application.applicationContext, 0, notificationIntent, 0) + } + } + + companion object { + const val ID_LABEL = "pluginIdentifier" + const val BUNDLE_LABEL = "pluginBundle" + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/PlutoInterface.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/PlutoInterface.kt new file mode 100644 index 000000000..467cd3f05 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/libinterface/PlutoInterface.kt @@ -0,0 +1,47 @@ +package com.pluto.plugin.libinterface + +import android.app.Application +import androidx.fragment.app.FragmentActivity +import java.lang.IllegalStateException + +class PlutoInterface private constructor( + val application: Application, + val pluginActivityClass: Class, + val selectorActivityClass: Class +) { + + companion object { + private var instance: PlutoInterface? = null + + private val get: PlutoInterface + get() { + if (instance != null) { + return instance!! + } + throw IllegalStateException("PluginUiBridge not initialised yet") + } + + val notification: NotificationInterface + get() = NotificationInterface(get.application, get.pluginActivityClass) + + val libInfo: LibraryInfoInterface + get() = LibraryInfoInterface(get.pluginActivityClass, get.selectorActivityClass) + + val files: FilesInterface by lazy { FilesInterface(get.application) } // singleton + + fun create( + application: Application, + pluginActivityClass: Class, + selectorActivityClass: Class + ): PlutoInterface? { + if (instance == null) { + synchronized(PlutoInterface::class.java) { + if (instance == null) { + instance = PlutoInterface(application, pluginActivityClass, selectorActivityClass) + } + } + } + return instance + } + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/settings/SettingsPreferences.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/settings/SettingsPreferences.kt new file mode 100644 index 000000000..0e34b0da4 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/settings/SettingsPreferences.kt @@ -0,0 +1,37 @@ +package com.pluto.plugin.settings + +import android.content.Context +import android.content.SharedPreferences + +object SettingsPreferences { + + private val settingsPrefs: SharedPreferences + get() = returnSettings() + private var _settingsPrefs: SharedPreferences? = null + private fun returnSettings(): SharedPreferences { + _settingsPrefs?.let { return it } + throw IllegalStateException("Settings preferences is not initialised yet.") + } + + fun init(context: Context) { + _settingsPrefs = context.preferences("_pluto_pref_lib_settings") + } + + var isDarkThemeEnabled: Boolean + get() = settingsPrefs.getBoolean(IS_DARK_THEME_ENABLED, true) + set(value) = settingsPrefs.edit().putBoolean(IS_DARK_THEME_ENABLED, value).apply() + + var isRightHandedAccessPopup: Boolean + get() = settingsPrefs.getBoolean(IS_RIGHT_HANDED_ACCESS_POPUP, true) + set(value) = settingsPrefs.edit().putBoolean(IS_RIGHT_HANDED_ACCESS_POPUP, value).apply() + + var gridSize: Int + get() = settingsPrefs.getInt(GRID_SIZE, 5) + set(value) = settingsPrefs.edit().putInt(GRID_SIZE, value).apply() + + private const val IS_DARK_THEME_ENABLED = "is_dark_theme_enabled" + private const val IS_RIGHT_HANDED_ACCESS_POPUP = "is_right_handed_access_popup" + private const val GRID_SIZE = "grid_size" +} + +private fun Context.preferences(name: String, mode: Int = Context.MODE_PRIVATE) = getSharedPreferences(name, mode) diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ContentShare.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ContentShare.kt new file mode 100644 index 000000000..6e1f67a3f --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ContentShare.kt @@ -0,0 +1,27 @@ +package com.pluto.plugin.share + +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels + +fun Fragment.lazyContentSharer(): Lazy = activityViewModels() + +fun ComponentActivity.lazyContentSharer(): Lazy = viewModels() + +data class Shareable( + val title: String, + val content: String, + val fileName: String = "File shared from Pluto" +) { + val processedContent: String = StringBuilder().apply { + append(content) + append("\n\n-------------------------------------\nreport generated by Pluto (https://androidpluto.com)") + }.toString() +} + +sealed class ShareAction { + data class ShareAsText(val shareable: Shareable) : ShareAction() + data class ShareAsFile(val shareable: Shareable, val type: String = "text/*") : ShareAction() + data class ShareAsCopy(val shareable: Shareable) : ShareAction() +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ContentShareViewModel.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ContentShareViewModel.kt new file mode 100644 index 000000000..367c559bf --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ContentShareViewModel.kt @@ -0,0 +1,24 @@ +package com.pluto.plugin.share + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import com.pluto.utilities.SingleLiveEvent + +class ContentShareViewModel : ViewModel() { + + val data: LiveData + get() = _data + private val _data = SingleLiveEvent() + + val action: LiveData + get() = _action + private val _action = SingleLiveEvent() + + fun performAction(action: ShareAction) { + _action.postValue(action) + } + + fun share(shareable: Shareable) { + _data.postValue(shareable) + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ShareFragment.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ShareFragment.kt new file mode 100644 index 000000000..b256e71d5 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ShareFragment.kt @@ -0,0 +1,70 @@ +package com.pluto.plugin.share + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugin.R +import com.pluto.plugin.databinding.PlutoFragmentShareBinding +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +class ShareFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoFragmentShareBinding::bind) + private val shareViewModel: ContentShareViewModel by activityViewModels() + private var shareContent: Shareable? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto___fragment_share, container, false) + + override fun getTheme(): Int = R.style.PlutoBottomSheetDialogTheme + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + shareContent = arguments?.toShareable() + + binding.title.text = shareContent?.title ?: getString(R.string.pluto___share_as) + + binding.shareCopy.setOnDebounceClickListener { + shareContent?.let { + shareViewModel.performAction(ShareAction.ShareAsCopy(it)) + } + dismiss() + } + + binding.shareText.setOnDebounceClickListener { + shareContent?.let { + shareViewModel.performAction(ShareAction.ShareAsText(it)) + } + dismiss() + } + + binding.shareFile.setOnDebounceClickListener { + shareContent?.let { + shareViewModel.performAction(ShareAction.ShareAsFile(it)) + } + dismiss() + } + } +} + +private fun Bundle?.toShareable(): Shareable? { + this?.let { + return if (it.getString("title") != null && + it.getString("content") != null && + it.getString("fileName") != null + ) { + Shareable( + title = it.getString("title") ?: "", + content = it.getString("content") ?: "", + fileName = it.getString("fileName") ?: "", + ) + } else { + null + } + } + return null +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ShareKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ShareKtx.kt new file mode 100644 index 000000000..59a2eb61d --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/ShareKtx.kt @@ -0,0 +1,75 @@ +package com.pluto.plugin.share + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Environment +import androidx.core.content.FileProvider +import com.pluto.utilities.DebugLog +import java.io.File +import java.io.FileOutputStream + +fun Context.share(message: String, title: String? = null, subject: String? = null) { + val intent = Intent().apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, subject ?: "") + putExtra(Intent.EXTRA_TEXT, message) + action = Intent.ACTION_SEND + } + startActivity(Intent.createChooser(intent, title ?: "Share via...")) +} + +fun Context.shareFile(message: String, title: String? = null, fileName: String, contentType: String) { + val dir = getDirectoryName() + val file = generateFile(message, dir, contentType) + val uri = FileProvider.getUriForFile(applicationContext, "pluto___${applicationContext.packageName}.provider", file) + val intent = Intent().apply { + type = contentType + + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_TEXT, fileName) + putExtra(Intent.EXTRA_STREAM, uri) + action = Intent.ACTION_SEND + } + startActivity(Intent.createChooser(intent, title ?: "Share via...")) +} + +fun Context.copyToClipboard(data: String, label: String) { + val clipboard: ClipboardManager? = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager? + val clip = ClipData.newPlainText(label, data) + clipboard?.setPrimaryClip(clip) +} + +private fun Context.getDirectoryName(): File { + val dir = File(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "PlutoShares") + if (!dir.exists()) { + if (dir.mkdir()) { + DebugLog.d("Create Directory", "Directory Created : $dir") + } + } + return dir +} + +@Suppress("TooGenericExceptionCaught") +private fun generateFile(content: String, saveFilePath: File, contentType: String): File { + val dir = File(saveFilePath.absolutePath) + if (!dir.exists()) { + dir.mkdirs() + } + val file = File(saveFilePath.absolutePath, "pluto_share.${getFileExtension(contentType)}") + try { + val fOut = FileOutputStream(file) + fOut.write(content.toByteArray()) + fOut.flush() + fOut.close() + } catch (e: Exception) { + DebugLog.e("share", "error while generating file", e) + } + return file +} + +private fun getFileExtension(contentType: String): String = when (contentType) { + "text/csv" -> "csv" + else -> "txt" +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/csv/CSVFormatter.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/csv/CSVFormatter.kt new file mode 100644 index 000000000..0a9b32758 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/plugin/share/csv/CSVFormatter.kt @@ -0,0 +1,31 @@ +package com.pluto.plugin.share.csv + +class CSVFormatter private constructor() { + + companion object { + fun write(nextLine: Array): String { + val sb = StringBuffer() + for (i in nextLine.indices) { + if (i != 0) { + sb.append(SEPARATOR) + } + val nextElement = nextLine[i] ?: continue + sb.append(QUOTE_CHARACTER) + for (element in nextElement) { + when (element) { + ESCAPE_OR_QUOTE_CHARACTER -> sb.append(ESCAPE_OR_QUOTE_CHARACTER).append(element) + else -> sb.append(element) + } + } + sb.append(QUOTE_CHARACTER) + } + sb.append(NEW_LINE) + return sb.toString() + } + + private const val ESCAPE_OR_QUOTE_CHARACTER = '"' + private const val SEPARATOR = ',' + private const val QUOTE_CHARACTER = '"' + private const val NEW_LINE = "\n" + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/AutoClearedValue.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/AutoClearedValue.kt new file mode 100644 index 000000000..5334c88f3 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/AutoClearedValue.kt @@ -0,0 +1,92 @@ +/** + * Source: https://gist.github.com/Zhuinden/ea3189198938bd16c03db628e084a4fa#file-fragmentviewbindingdelegate-kt + */ + +// https://github.com/Zhuinden/fragmentviewbindingdelegate-kt +package com.pluto.utilities + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer +import androidx.viewbinding.ViewBinding +import kotlin.properties.ReadOnlyProperty +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +private open class AutoClearedValue(protected val fragment: Fragment) : + ReadWriteProperty { + protected var value: T? = null + + init { + fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { + val viewLifecycleOwnerLiveDataObserver = Observer { + it?.lifecycle?.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + value = null + } + }) + } + + override fun onCreate(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.observeForever( + viewLifecycleOwnerLiveDataObserver + ) + } + + override fun onDestroy(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.removeObserver( + viewLifecycleOwnerLiveDataObserver + ) + } + }) + } + + override fun getValue(thisRef: Fragment, property: KProperty<*>): T { + return value + ?: throw IllegalStateException("should never call auto-cleared-value get when it might not be available") + } + + override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) { + this.value = value + } +} + +private abstract class BaseAutoClearedInitializedValue(fragment: Fragment) : + AutoClearedValue(fragment) { + override fun getValue(thisRef: Fragment, property: KProperty<*>): T { + val lifecycle = fragment.viewLifecycleOwner.lifecycle + check(lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { + "Should not attempt to get bindings when Fragment views are destroyed." + } + return value ?: initialize().also { this.value = it } + } + + protected abstract fun initialize(): T +} + +private class FragmentViewBindingDelegate( + fragment: Fragment, + private val viewBindingInitializerFactory: (View) -> T +) : BaseAutoClearedInitializedValue(fragment) { + override fun initialize(): T { + return viewBindingInitializerFactory(fragment.requireView()) + } +} + +private class AutoClearedInitializedValue( + fragment: Fragment, + private val initializerFactory: () -> T +) : BaseAutoClearedInitializedValue(fragment) { + override fun initialize(): T { + return initializerFactory.invoke() + } +} + +fun Fragment.autoClearInitializer(initializerFactory: () -> T) = + AutoClearedInitializedValue(this, initializerFactory) as ReadOnlyProperty + +fun Fragment.viewBinding(viewBindingFactory: (View) -> T) = + FragmentViewBindingDelegate(this, viewBindingFactory) as ReadOnlyProperty diff --git a/pluto/src/main/java/com/mocklets/pluto/core/ui/DeBounceClickListener.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/DeBounceClickListener.kt similarity index 75% rename from pluto/src/main/java/com/mocklets/pluto/core/ui/DeBounceClickListener.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/DeBounceClickListener.kt index aa2d569a5..b8f6f504a 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/ui/DeBounceClickListener.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/DeBounceClickListener.kt @@ -1,22 +1,22 @@ -package com.mocklets.pluto.core.ui +package com.pluto.utilities import android.view.HapticFeedbackConstants import android.view.SoundEffectConstants import android.view.View -import com.mocklets.pluto.core.ui.ClickUtils.Companion.DEBOUNCE_DELAY -import com.mocklets.pluto.core.ui.ClickUtils.Companion.enabled +import com.pluto.utilities.ClickUtils.Companion.DEBOUNCE_DELAY +import com.pluto.utilities.ClickUtils.Companion.enabled -internal fun View.setDebounceClickListener( +fun View.setOnDebounceClickListener( delay: Long = DEBOUNCE_DELAY, haptic: Boolean = false, action: ((View) -> Unit)? ) { - action?.let { action -> + action?.let { setOnClickListener { view -> view.onDebounce(delay) { view.hapticFeedback(haptic) view.soundFeedback() - action.invoke(view) + it.invoke(view) } } return @@ -32,7 +32,7 @@ private inline fun View.onDebounce(delay: Long, next: () -> Unit?) { } } -internal fun View.hapticFeedback(isGlobal: Boolean) { +fun View.hapticFeedback(isGlobal: Boolean) { isHapticFeedbackEnabled = true performHapticFeedback( HapticFeedbackConstants.VIRTUAL_KEY, @@ -40,7 +40,7 @@ internal fun View.hapticFeedback(isGlobal: Boolean) { ) } -internal fun View.soundFeedback() { +fun View.soundFeedback() { isSoundEffectsEnabled = true playSoundEffect(SoundEffectConstants.CLICK) } diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/DebugLog.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/DebugLog.kt new file mode 100644 index 000000000..0c6214939 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/DebugLog.kt @@ -0,0 +1,40 @@ +package com.pluto.utilities + +import android.util.Log + +class DebugLog private constructor() { + + companion object { + var enabled: Boolean = true + + fun v(tag: String, message: String?, tr: Throwable? = null) { + if (enabled) { + Log.v(tag, message.toString(), tr) + } + } + + fun d(tag: String, message: String?, tr: Throwable? = null) { + if (enabled) { + Log.d(tag, message.toString(), tr) + } + } + + fun i(tag: String, message: String?, tr: Throwable? = null) { + if (enabled) { + Log.i(tag, message.toString(), tr) + } + } + + fun w(tag: String, message: String?, tr: Throwable? = null) { + if (enabled) { + Log.w(tag, message.toString(), tr) + } + } + + fun e(tag: String, message: String?, tr: Throwable? = null) { + if (enabled) { + Log.e(tag, message, tr) + } + } + } +} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/SingleLiveEvent.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/SingleLiveEvent.kt similarity index 89% rename from pluto/src/main/java/com/mocklets/pluto/core/SingleLiveEvent.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/SingleLiveEvent.kt index b9bdf061a..4b908a633 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/SingleLiveEvent.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/SingleLiveEvent.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core +package com.pluto.utilities /* * Copyright 2017 Google Inc. @@ -44,14 +44,11 @@ class SingleLiveEvent : MutableLiveData() { Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") } // Observe the internal MutableLiveData - super.observe( - owner, - Observer { t -> - if (pending.compareAndSet(true, false)) { - observer.onChanged(t) - } + super.observe(owner) { + if (pending.compareAndSet(true, false)) { + observer.onChanged(it) } - ) + } } @MainThread diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/device/Device.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/device/Device.kt new file mode 100644 index 000000000..308ca8efd --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/device/Device.kt @@ -0,0 +1,115 @@ +package com.pluto.utilities.device + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings +import androidx.annotation.Keep +import kotlin.math.ceil +import kotlin.math.pow +import kotlin.math.sqrt + +data class Device(val context: Context) { + val build: BuildData = BuildData() + val screen: ScreenData = context.getScreen() + val software: SoftwareData = context.getSoftwareData() + val app: AppData = context.getAppData() +} + +private fun Context.getSoftwareData(): SoftwareData { + return SoftwareData( + androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID), + androidAPILevel = Build.VERSION.SDK_INT.toString(), + androidOs = Build.VERSION.RELEASE, + isRooted = RootUtil.isDeviceRooted + ) +} + +private const val DENSITY_MULTIPLIER = 160f +private fun Context.getScreen(): ScreenData { + val dm = resources.displayMetrics + val height = dm.heightPixels + val width = dm.widthPixels + val x = (width / dm.xdpi.toDouble()).pow(2.0) + val y = (height / dm.ydpi.toDouble()).pow(2.0) + val screenInches = sqrt(x + y) + val rounded = ceil(screenInches) + val densityDpi = (dm.density * DENSITY_MULTIPLIER).toInt() + + return ScreenData( + widthPx = width, + heightPx = height, + sizeInches = rounded, + density = densityDpi, + orientation = getOrientation() + ) +} + +private fun Context.getOrientation(): String { + return when (resources.configuration.orientation) { + 1 -> "portrait" + 2 -> "landscape" + else -> "undefined" + } +} + +private fun Context.getAppData(): AppData { + packageManager.getApplicationLabel( + packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + ) as String + val info: PackageInfo = packageManager.getPackageInfo(packageName, 0) + return AppData( + name = packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)) as String, + packageName = info.packageName, + version = VersionData( + name = info.versionName, + code = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong() + ) + ) +} + +@Keep +data class VersionData( + val name: String, + val code: Long +) + +@Keep +data class AppData( + val name: String, + val packageName: String, + val version: VersionData +) + +@Keep +data class ScreenData( + val widthPx: Int, + val heightPx: Int, + val sizeInches: Double, + val density: Int, + val orientation: String +) + +@Keep +data class SoftwareData( + val androidId: String?, + val androidAPILevel: String?, + val androidOs: String?, + val isRooted: Boolean +) + +@Keep +data class BuildData( + val user: String? = Build.USER, + val host: String? = Build.HOST, + val id: String? = Build.ID, + val fingerprint: String? = Build.FINGERPRINT, + val manufacturer: String? = Build.MANUFACTURER, + val hardware: String? = Build.HARDWARE, + val board: String = Build.BOARD, + val brand: String? = Build.BRAND, + val bootloader: String? = Build.BOOTLOADER, + val model: String? = Build.MODEL, + val gaId: String? = null +) diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/device/RootUtil.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/device/RootUtil.kt new file mode 100644 index 000000000..efcc937e3 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/device/RootUtil.kt @@ -0,0 +1,56 @@ +package com.pluto.utilities.device + +import android.os.Build +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader + +/** Credits: https://stackoverflow.com/questions/1101380/determine-if-running-on-a-rooted-device/8097801 + * + * @author Kevin Kowalewski + */ +internal class RootUtil private constructor() { + companion object { + val isDeviceRooted: Boolean + get() = checkRootMethod1() || checkRootMethod2() || checkRootMethod3() + + private fun checkRootMethod1(): Boolean { + val buildTags = Build.TAGS + return buildTags != null && buildTags.contains("test-keys") + } + + private fun checkRootMethod2(): Boolean { + val paths = arrayOf( + "/system/app/Superuser.apk", + "/sbin/su", + "/system/bin/su", + "/system/xbin/su", + "/data/local/xbin/su", + "/data/local/bin/su", + "/system/sd/xbin/su", + "/system/bin/failsafe/su", + "/data/local/su", + "/su/bin/su" + ) + for (path in paths) { + if (File(path).exists()) return true + } + return false + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun checkRootMethod3(): Boolean { + var process: Process? = null + return try { + process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su")) + val stream = BufferedReader(InputStreamReader(process.inputStream)) + stream.readLine() != null + } catch (t: Throwable) { +// DebugLog.e("root-utils", "exception occurred", t) + false + } finally { + process?.destroy() + } + } + } +} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/AnimationKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/AnimationKtx.kt similarity index 96% rename from pluto/src/main/java/com/mocklets/pluto/core/extensions/AnimationKtx.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/AnimationKtx.kt index cade819e8..a8c4aa3a8 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/AnimationKtx.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/AnimationKtx.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.extensions +package com.pluto.utilities.extensions import android.view.animation.Animation diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/ContextKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/ContextKtx.kt new file mode 100644 index 000000000..e241314dd --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/ContextKtx.kt @@ -0,0 +1,53 @@ +package com.pluto.utilities.extensions + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import android.widget.Toast.LENGTH_LONG +import android.widget.Toast.LENGTH_SHORT +import androidx.activity.OnBackPressedCallback +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.FontRes +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.Fragment + +@ColorInt +fun Context.color(@ColorRes id: Int) = ContextCompat.getColor(this, id) + +fun Context.font(@FontRes id: Int) = ResourcesCompat.getFont(this, id) + +fun Context.drawable(@DrawableRes id: Int) = ContextCompat.getDrawable(this, id) + +fun Context.toast(message: String, isLong: Boolean = false) { + Toast.makeText(this, message, if (isLong) LENGTH_LONG else LENGTH_SHORT).show() +} + +fun Fragment.toast(message: String, isLong: Boolean = false) { + context?.let { + Toast.makeText(it, message, if (isLong) LENGTH_LONG else LENGTH_SHORT).show() + } +} + +fun Context.checkAndOpenSupportedApp(uri: Uri?) { + val intent = Intent(Intent.ACTION_VIEW, uri) + if (intent.resolveActivity(packageManager) != null) { + startActivity(intent) + } else { + toast("No app to perform this action") + } +} + +fun Fragment.onBackPressed(action: () -> Unit) { + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + action.invoke() + } + } + ) +} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/DateKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/DateKtx.kt similarity index 96% rename from pluto/src/main/java/com/mocklets/pluto/core/extensions/DateKtx.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/DateKtx.kt index 51cdc2ea3..11501b125 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/DateKtx.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/DateKtx.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.extensions +package com.pluto.utilities.extensions import java.text.SimpleDateFormat import java.util.Calendar diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/DimensKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/DimensKtx.kt new file mode 100644 index 000000000..89c761273 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/DimensKtx.kt @@ -0,0 +1,46 @@ +package com.pluto.utilities.extensions + +import android.content.res.Resources +import android.util.TypedValue +import java.text.DecimalFormat +import java.util.Locale + +val Float.dp: Float + get() { + val displayMetrics = Resources.getSystem().displayMetrics + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this, displayMetrics) + } + +val Float.sp: Float + get() { + val displayMetrics = Resources.getSystem().displayMetrics + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this, displayMetrics) + } + +val Float.dp2px: Float + get() { + val scale = Resources.getSystem().displayMetrics.density + return this * scale + 0.5f + } + +val Float.px2dp: Float + get() { + val scale = Resources.getSystem().displayMetrics.density + return this / scale + 0.5f + } + +val Float.px2sp: Float + get() { + val scale = Resources.getSystem().displayMetrics.scaledDensity + return this / scale + } + +val Int.twoDigit: String + get() { + return String.format(Locale.ENGLISH, "%02d", this) + } + +val Float.twoDecimal: String + get() { + return DecimalFormat("#.##").format(this) + } diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/KeyboardKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/KeyboardKtx.kt similarity index 97% rename from pluto/src/main/java/com/mocklets/pluto/core/extensions/KeyboardKtx.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/KeyboardKtx.kt index f1670e267..43d3ab56f 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/KeyboardKtx.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/KeyboardKtx.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.extensions +package com.pluto.utilities.extensions import android.content.Context import android.view.View diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/LayoutKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/LayoutKtx.kt similarity index 85% rename from pluto/src/main/java/com/mocklets/pluto/core/extensions/LayoutKtx.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/LayoutKtx.kt index c088e6d45..92cf51c4e 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/LayoutKtx.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/LayoutKtx.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.extensions +package com.pluto.utilities.extensions import android.animation.Animator import android.animation.AnimatorListenerAdapter @@ -21,18 +21,18 @@ fun View.fadeInAndOut(lifecycleScope: LifecycleCoroutineScope) { fadeOut.duration = SMOOTH_TRANSITION_DELAY fadeIn.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationStart(animation: Animator?) { + override fun onAnimationStart(animation: Animator) { visibility = View.VISIBLE } - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { lifecycleScope.delayedLaunchWhenResumed(FADE_IN_OUT_ANIMATION_DURATION) { fadeOut.start() } } }) fadeOut.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { visibility = View.GONE } }) diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/LifecycleKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/LifecycleKtx.kt similarity index 89% rename from pluto/src/main/java/com/mocklets/pluto/core/extensions/LifecycleKtx.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/LifecycleKtx.kt index fda597eb1..7e4045d30 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/LifecycleKtx.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/LifecycleKtx.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.extensions +package com.pluto.utilities.extensions import androidx.lifecycle.LifecycleCoroutineScope import kotlinx.coroutines.CoroutineScope diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/LiveDataKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/LiveDataKtx.kt similarity index 85% rename from pluto/src/main/java/com/mocklets/pluto/core/extensions/LiveDataKtx.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/LiveDataKtx.kt index b8bb111f9..d3a8644b8 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/LiveDataKtx.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/LiveDataKtx.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.extensions +package com.pluto.utilities.extensions import androidx.lifecycle.MutableLiveData diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/PairKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/PairKtx.kt new file mode 100644 index 000000000..3c1b24730 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/PairKtx.kt @@ -0,0 +1,15 @@ +package com.pluto.utilities.extensions + +fun Pair, Iterable>.forEachIndexed(action: (Int, A, B?) -> Unit) { + val ia = first.iterator().withIndex() + val ib = second.iterator().withIndex() + + while (ia.hasNext() && ib.hasNext()) { + val next = ia.next() + val index = next.index + val va = next.value + val vb = ib.next().value + + action(index, va, vb) + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/PopupKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/PopupKtx.kt new file mode 100644 index 000000000..8d9b171eb --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/PopupKtx.kt @@ -0,0 +1,58 @@ +package com.pluto.utilities.extensions + +import android.annotation.SuppressLint +import android.content.Context +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.annotation.MenuRes +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.view.menu.MenuPopupHelper +import androidx.appcompat.widget.PopupMenu +import androidx.core.view.forEach +import com.pluto.plugin.R +import com.pluto.utilities.spannable.createSpan + +@SuppressLint("RestrictedApi") +fun Context.showMoreOptions(view: View, @MenuRes menu: Int, listener: (MenuItem) -> Unit) { + val popup = PopupMenu(this, view, Gravity.END).apply { + menuInflater.inflate(menu, this.menu) + applyFontToMenu(this.menu) + setOnMenuItemClickListener { item -> + listener.invoke(item) + true + } + } + + val menuHelper = MenuPopupHelper(this, popup.menu as MenuBuilder, view) + menuHelper.setForceShowIcon(true) + menuHelper.gravity = Gravity.END + menuHelper.show() +} + +private fun Context.applyFontToMenu(m: Menu) { + for (i in 0 until m.size()) { + applyFontToMenuItem(m.getItem(i)) + } +} + +private fun Context.applyFontToMenuItem(mi: MenuItem) { + + mi.subMenu?.forEach { + applyFontToMenuItem(it) + } + + mi.title?.let { title -> + mi.title = createSpan { + append( + fontColor( + fontSize(semiBold(title), MENU_FONT_SIZE), + color(R.color.pluto___text_dark_80) + ) + ) + } + } +} + +private const val MENU_FONT_SIZE = 15 diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/RecyclerViewKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/RecyclerViewKtx.kt new file mode 100644 index 000000000..c6babf508 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/RecyclerViewKtx.kt @@ -0,0 +1,26 @@ +package com.pluto.utilities.extensions + +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.ListItem +import java.lang.IllegalStateException + +fun RecyclerView.linearLayoutManager(): LinearLayoutManager? { + if (layoutManager is LinearLayoutManager) { + return layoutManager as LinearLayoutManager + } + return null +} + +fun RecyclerView.setList(list: List) { + adapter?.let { + if (it is BaseAdapter) { + it.list = list + } else { + throw IllegalStateException("adapter is not BaseAdapter") + } + } ?: run { + throw IllegalStateException("adapter not set") + } +} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/SettingsKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/SettingsKtx.kt similarity index 78% rename from pluto/src/main/java/com/mocklets/pluto/core/extensions/SettingsKtx.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/SettingsKtx.kt index f2d2615c5..8927dc3cf 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/SettingsKtx.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/SettingsKtx.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.extensions +package com.pluto.utilities.extensions import android.content.Context import android.content.Intent @@ -6,7 +6,7 @@ import android.net.Uri import android.os.Build import android.provider.Settings -internal fun Context.canDrawOverlays(): Boolean { +fun Context.canDrawOverlays(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Settings.canDrawOverlays(this) } else { @@ -14,7 +14,7 @@ internal fun Context.canDrawOverlays(): Boolean { } } -internal fun Context.openOverlaySettings() { +fun Context.openOverlaySettings() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val intent = Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/StringsKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/StringsKtx.kt new file mode 100644 index 000000000..5516c075e --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/StringsKtx.kt @@ -0,0 +1,14 @@ +package com.pluto.utilities.extensions + +import java.util.Locale + +fun String.capitalizeText(default: Locale = Locale.getDefault()): String = + replaceFirstChar { if (it.isLowerCase()) it.titlecase(default) else it.toString() } + +fun String.truncateExcess(offset: Int): String { + return if (length > offset) { + "${take(offset)}..." + } else { + this + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/ViewKtx.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/ViewKtx.kt new file mode 100644 index 000000000..02e86e512 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/extensions/ViewKtx.kt @@ -0,0 +1,55 @@ +package com.pluto.utilities.extensions + +import android.content.Context +import android.content.res.Resources +import android.view.View +import android.view.WindowManager +import com.pluto.utilities.DebugLog + +@SuppressWarnings("TooGenericExceptionCaught") +fun Context.removeViewFromWindow(v: View) { + try { + val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + windowManager.removeView(v) + } catch (t: Throwable) { + DebugLog.e("pluto_sdk", "error while removing view", t) + } +} + +@SuppressWarnings("TooGenericExceptionCaught") +fun Context.addViewToWindow(v: View, params: WindowManager.LayoutParams): Boolean { + return try { + if (canDrawOverlays()) { + val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + windowManager.addView(v, params) + true + } else { + false + } + } catch (t: Throwable) { + DebugLog.e("pluto_sdk", "error while adding view", t) + removeViewFromWindow(v) + false + } +} + +@SuppressWarnings("MagicNumber") +@Throws(Resources.NotFoundException::class) +fun View.getIdInfo(): ViewIdInfo? { + val isViewIdGenerated: Boolean = id and -0x1000000 == 0 && id and 0x00FFFFFF != 0 + return if (id != View.NO_ID && !isViewIdGenerated) { + ViewIdInfo( + packageName = when (id and -0x1000000) { + 0x7f000000 -> "app" + 0x01000000 -> "android" + else -> resources.getResourcePackageName(id) + }, + typeName = resources.getResourceTypeName(id), + entryName = resources.getResourceEntryName(id) + ) + } else { + null + } +} + +data class ViewIdInfo(val packageName: String, val typeName: String, val entryName: String) diff --git a/pluto/src/main/java/com/mocklets/pluto/core/ui/list/BaseAdapter.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/list/BaseAdapter.kt similarity index 89% rename from pluto/src/main/java/com/mocklets/pluto/core/ui/list/BaseAdapter.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/list/BaseAdapter.kt index cdb9725fd..8372ddda4 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/ui/list/BaseAdapter.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/list/BaseAdapter.kt @@ -1,11 +1,11 @@ -package com.mocklets.pluto.core.ui.list +package com.pluto.utilities.list import android.content.Context import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -internal abstract class BaseAdapter : DiffAwareAdapter() { +abstract class BaseAdapter : DiffAwareAdapter() { abstract fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? abstract fun getItemViewType(item: ListItem): Int? @@ -24,7 +24,7 @@ internal abstract class BaseAdapter : DiffAwareAdapter() { } } -internal abstract class DiffAwareHolder( +abstract class DiffAwareHolder( view: View, private val listener: DiffAwareAdapter.OnActionListener? ) : RecyclerView.ViewHolder(view) { diff --git a/pluto/src/main/java/com/mocklets/pluto/core/ui/list/CustomItemDecorator.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/list/CustomItemDecorator.kt similarity index 80% rename from pluto/src/main/java/com/mocklets/pluto/core/ui/list/CustomItemDecorator.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/list/CustomItemDecorator.kt index c35370c8e..214cf8480 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/ui/list/CustomItemDecorator.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/list/CustomItemDecorator.kt @@ -1,14 +1,14 @@ -package com.mocklets.pluto.core.ui.list +package com.pluto.utilities.list import android.content.Context import android.graphics.Canvas import android.view.View import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ItemDecoration -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.drawable +import com.pluto.plugin.R +import com.pluto.utilities.extensions.drawable -internal class CustomItemDecorator(context: Context, private val edge: Int = 0) : ItemDecoration() { +class CustomItemDecorator(context: Context, private val edge: Int = 0) : ItemDecoration() { private val divider = context.drawable(R.drawable.pluto___line_divider) diff --git a/pluto/src/main/java/com/mocklets/pluto/core/ui/list/DiffAwareAdapter.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/list/DiffAwareAdapter.kt similarity index 92% rename from pluto/src/main/java/com/mocklets/pluto/core/ui/list/DiffAwareAdapter.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/list/DiffAwareAdapter.kt index 34b05b01a..0bed66c3d 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/ui/list/DiffAwareAdapter.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/list/DiffAwareAdapter.kt @@ -1,11 +1,11 @@ -package com.mocklets.pluto.core.ui.list +package com.pluto.utilities.list import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -internal abstract class DiffAwareAdapter : RecyclerView.Adapter() { +abstract class DiffAwareAdapter : RecyclerView.Adapter() { private val asyncListDiffer by lazy { AsyncListDiffer( @@ -58,7 +58,7 @@ internal abstract class DiffAwareAdapter : RecyclerView.Ad abstract override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH interface OnActionListener { - fun onAction(action: String, data: ListItem, holder: DiffAwareHolder? = null) + fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) } } diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/DataSelector.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/DataSelector.kt new file mode 100644 index 000000000..0e69fcbaa --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/DataSelector.kt @@ -0,0 +1,53 @@ +package com.pluto.utilities.selector + +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.ViewModel +import com.pluto.utilities.SingleLiveEvent +import com.pluto.utilities.list.ListItem + +class Selector : ViewModel() { + + internal val singleChoiceData: LiveData> + get() = _singleChoiceData + private val _singleChoiceData = SingleLiveEvent>() + internal val singleChoiceResult = SingleLiveEvent() + + internal val multiChoiceData: LiveData>> + get() = _multiChoiceData + private val _multiChoiceData = SingleLiveEvent>>() + internal val multiChoiceResult = SingleLiveEvent>() + + val data = MediatorLiveData() + + init { + data.addSource(singleChoiceData) { data.postValue(it) } + data.addSource(multiChoiceData) { data.postValue(it) } + } + + fun selectSingle(title: String, list: List, preSelected: SelectorOption? = null): SingleLiveEvent { + _singleChoiceData.postValue(SelectorData(title, list, preSelected)) + return singleChoiceResult + } + + fun selectMultiple(title: String, list: List, preSelected: List? = null): SingleLiveEvent> { + _multiChoiceData.postValue(SelectorData(title, list, preSelected)) + return multiChoiceResult + } +} + +abstract class SelectorOption : ListItem() { + abstract fun displayText(): CharSequence + + fun displayTextString(): String = displayText().toString() +} + +internal data class SelectorData(val title: String, val list: List, val preSelected: T?) + +fun Fragment.lazyDataSelector(): Lazy = activityViewModels() + +fun ComponentActivity.lazyDataSelector(): Lazy = viewModels() diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/ui/DataSelectorDialog.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/ui/DataSelectorDialog.kt new file mode 100644 index 000000000..3d3a80d46 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/ui/DataSelectorDialog.kt @@ -0,0 +1,112 @@ +package com.pluto.utilities.selector.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugin.R +import com.pluto.plugin.databinding.PlutoSelectorDialogBinding +import com.pluto.utilities.SingleLiveEvent +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.selector.SelectorData +import com.pluto.utilities.selector.SelectorOption +import com.pluto.utilities.selector.lazyDataSelector +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +class DataSelectorDialog : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoSelectorDialogBinding::bind) + private val selector by lazyDataSelector() + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto___selector_dialog, container, false) + + override fun getTheme(): Int = R.style.PlutoBottomSheetDialogTheme + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.setOnShowListener { + val dialog = it as BottomSheetDialog + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + dialog.behavior.peekHeight = Device(requireContext()).screen.heightPx + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + selector.singleChoiceData.observe(viewLifecycleOwner) { + binding.title.text = it.title + setupSingleChoiceUI(it) + } + selector.multiChoiceData.observe(viewLifecycleOwner) { + binding.title.text = it.title + setupMultiChoiceUI(it) + } + } + + private fun setupSingleChoiceUI(data: SelectorData) { + binding.doneCta.visibility = GONE + binding.clear.visibility = GONE + binding.list.apply { + adapter = SingleSelectorAdapter( + object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is SelectorOption) { + selector.singleChoiceResult.postValue(data) + dismiss() + } + } + }, + data.preSelected + ).apply { this.list = data.list } + addItemDecoration(CustomItemDecorator(requireContext(), DECORATOR_DIVIDER_PADDING)) + } + } + + private fun setupMultiChoiceUI(data: SelectorData>) { + val tempSelectedOptionLiveData = SingleLiveEvent>() + data.preSelected.let { tempSelectedOptionLiveData.postValue(it) } + + binding.list.apply { + adapter = MultiSelectorAdapter( + object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is SelectorOption) { + var tempSet = tempSelectedOptionLiveData.value?.toHashSet() ?: hashSetOf() + val isOptionAlreadyPresent = tempSet.any { it.displayTextString() == data.displayTextString() } + if (isOptionAlreadyPresent) { + tempSet = tempSet.filter { it.displayTextString() != data.displayTextString() }.toHashSet() + } else { + tempSet.add(data) + } + tempSelectedOptionLiveData.postValue(tempSet.toList()) + } + } + }, + tempSelectedOptionLiveData + ).apply { this.list = data.list } + addItemDecoration(CustomItemDecorator(requireContext(), DECORATOR_DIVIDER_PADDING)) + } + binding.doneCta.setOnDebounceClickListener { + selector.multiChoiceResult.postValue(tempSelectedOptionLiveData.value?.toList() ?: emptyList()) + dismiss() + } + + binding.clear.setOnDebounceClickListener { + selector.multiChoiceResult.postValue(emptyList()) + dismiss() + } + } + + private companion object { + val DECORATOR_DIVIDER_PADDING = 16f.dp.toInt() + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/ui/MultiSelectorAdapter.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/ui/MultiSelectorAdapter.kt new file mode 100644 index 000000000..85738f114 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/ui/MultiSelectorAdapter.kt @@ -0,0 +1,61 @@ +package com.pluto.utilities.selector.ui + +import android.view.ViewGroup +import androidx.lifecycle.Observer +import com.pluto.plugin.R +import com.pluto.plugin.databinding.PlutoMultiChoiceSelectorItemBinding +import com.pluto.utilities.SingleLiveEvent +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.selector.SelectorOption +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class MultiSelectorAdapter( + private val listener: OnActionListener, + private val selectedLiveData: SingleLiveEvent> +) : BaseAdapter() { + + override fun getItemViewType(item: ListItem): Int = 1 + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int) = MultiSelectorItemHolder(parent, listener, selectedLiveData) +} + +internal class MultiSelectorItemHolder( + parent: ViewGroup, + listener: DiffAwareAdapter.OnActionListener, + private val selectedLiveData: SingleLiveEvent> +) : DiffAwareHolder(parent.inflate(R.layout.pluto___multi_choice_selector_item), listener) { + + private val binding = PlutoMultiChoiceSelectorItemBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is SelectorOption) { + binding.title.setSpan { + append(item.displayText()) + } + + binding.root.setOnDebounceClickListener { + onAction("click") + } + } + } + + override fun onAttachViewHolder() { + super.onAttachViewHolder() + selectedLiveData.observeForever(selectedChoicesObserver) + } + + override fun onDetachViewHolder() { + super.onDetachViewHolder() + selectedLiveData.removeObserver(selectedChoicesObserver) + } + + private val selectedChoicesObserver = Observer> { list -> + binding.checkbox.isSelected = + item is SelectorOption && list?.any { it.displayTextString() == (item as SelectorOption).displayTextString() } ?: run { false } + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/ui/SingleSelectorAdapter.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/ui/SingleSelectorAdapter.kt new file mode 100644 index 000000000..b5b883ac8 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/selector/ui/SingleSelectorAdapter.kt @@ -0,0 +1,41 @@ +package com.pluto.utilities.selector.ui + +import android.view.ViewGroup +import com.pluto.plugin.R +import com.pluto.plugin.databinding.PlutoSingleChoiceSelectorItemBinding +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.selector.SelectorOption +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class SingleSelectorAdapter(private val listener: OnActionListener, private val selected: SelectorOption?) : BaseAdapter() { + + override fun getItemViewType(item: ListItem): Int = 1 + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder = SingleSelectorItemHolder(parent, listener, selected) +} + +internal class SingleSelectorItemHolder( + parent: ViewGroup, + listener: DiffAwareAdapter.OnActionListener, + private val selected: SelectorOption? +) : DiffAwareHolder(parent.inflate(R.layout.pluto___single_choice_selector_item), listener) { + + private val binding = PlutoSingleChoiceSelectorItemBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is SelectorOption) { + binding.title.setSpan { + append(item.displayText()) + } + binding.radio.isSelected = selected?.let { it.displayTextString() == item.displayTextString() } ?: run { false } + binding.root.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/ui/spannable/CustomTypefaceSpan.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/spannable/CustomTypefaceSpan.kt similarity index 93% rename from pluto/src/main/java/com/mocklets/pluto/core/ui/spannable/CustomTypefaceSpan.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/spannable/CustomTypefaceSpan.kt index ce80ad6fc..1dec4bc99 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/ui/spannable/CustomTypefaceSpan.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/spannable/CustomTypefaceSpan.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.ui.spannable +package com.pluto.utilities.spannable import android.graphics.Typeface import android.text.TextPaint diff --git a/pluto/src/main/java/com/mocklets/pluto/core/ui/spannable/SpanUtil.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/spannable/SpanUtil.kt similarity index 78% rename from pluto/src/main/java/com/mocklets/pluto/core/ui/spannable/SpanUtil.kt rename to pluto-plugins/base/lib/src/main/java/com/pluto/utilities/spannable/SpanUtil.kt index 014fc1cfb..bc1855f8f 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/ui/spannable/SpanUtil.kt +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/spannable/SpanUtil.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.ui.spannable +package com.pluto.utilities.spannable import android.content.Context import android.graphics.Typeface @@ -12,12 +12,12 @@ import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.text.style.UnderlineSpan import android.widget.TextView -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.font +import com.pluto.plugin.R +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.font import java.text.Normalizer -internal inline fun TextView.setSpan( +inline fun TextView.setSpan( bufferType: TextView.BufferType? = null, spanBuilder: Builder.() -> Unit ) { @@ -30,13 +30,13 @@ internal inline fun TextView.setSpan( } } -internal inline fun Context.createSpan(spanBuilder: Builder.() -> Unit): CharSequence { +inline fun Context.createSpan(spanBuilder: Builder.() -> Unit): CharSequence { val builder = Builder(this) builder.spanBuilder() return builder.build() } -internal class Builder(val context: Context) { +class Builder(val context: Context) { private val spanBuilder = SpannableStringBuilder() fun append(text: String) { @@ -55,12 +55,15 @@ internal class Builder(val context: Context) { is String -> SpannableString(s).apply { setSpan(o, 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } + is SpannableStringBuilder -> s.apply { setSpan(o, 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } + is SpannableString -> s.apply { setSpan(o, 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } + else -> throw IllegalArgumentException("unhandled type $o") } @@ -92,14 +95,20 @@ internal class Builder(val context: Context) { fun highlight(span: CharSequence, search: String?): CharSequence { if (search.isNullOrEmpty()) return span - val normalizedText = Normalizer.normalize(span, Normalizer.Form.NFD) - .replace("\\p{InCombiningDiacriticalMarks}+".toRegex(), "") - .lowercase() + val normalizedText = span.normalise().lowercase() val startIndexes = normalizedText.allOccurrences(search) if (startIndexes.isNotEmpty()) { + return highlight(span, search, startIndexes) + } + return span + } + + fun highlight(span: CharSequence, search: String?, indexes: List): CharSequence { + if (search.isNullOrEmpty()) return span + if (indexes.isNotEmpty()) { val highlighted: Spannable = SpannableString(span) - startIndexes.forEach { + indexes.forEach { highlighted.setSpan( BackgroundColorSpan(context.color(R.color.pluto___text_highlight)), it, @@ -112,6 +121,12 @@ internal class Builder(val context: Context) { return span } + fun occurrences(span: CharSequence, search: String?): List { + if (search.isNullOrEmpty()) return emptyList() + val normalizedText = span.normalise().lowercase() + return normalizedText.allOccurrences(search) + } + fun clickable(span: CharSequence, listener: ClickableSpan): CharSequence { return span(span, listener) } @@ -124,6 +139,11 @@ internal class Builder(val context: Context) { return span(span, StyleSpan(Typeface.ITALIC)) } + private fun CharSequence.normalise(): String { + return Normalizer.normalize(this, Normalizer.Form.NFD) + .replace("\\p{InCombiningDiacriticalMarks}+".toRegex(), "") + } + fun build(): CharSequence { return spanBuilder } diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/TabularDataView.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/TabularDataView.kt new file mode 100644 index 000000000..b61584ce5 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/TabularDataView.kt @@ -0,0 +1,24 @@ +package com.pluto.utilities.views + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import com.pluto.plugin.databinding.PlutoViewTabularDataBinding +import com.pluto.utilities.views.keyvalue.KeyValuePairData +import com.pluto.utilities.views.keyvalue.KeyValuePairView + +class TabularDataView : ConstraintLayout { + + private val binding = PlutoViewTabularDataBinding.inflate(LayoutInflater.from(context), this, true) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + fun set(title: String, keyValuePairs: List = arrayListOf()) { + binding.tabularDataTitle.text = title + binding.tabularDataContainer.removeAllViews() + keyValuePairs.forEach { binding.tabularDataContainer.addView(KeyValuePairView(context).apply { set(it) }) } + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/KeyValuePairData.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/KeyValuePairData.kt new file mode 100644 index 000000000..853fc8498 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/KeyValuePairData.kt @@ -0,0 +1,53 @@ +package com.pluto.utilities.views.keyvalue + +import android.text.InputType +import androidx.annotation.Keep +import com.pluto.utilities.list.ListItem + +@Keep +data class KeyValuePairData( + val key: String, + val value: CharSequence?, + val iconStart: Int? = null, + val showClickIndicator: Boolean = false, + val onClick: (() -> Unit)? = null +) + +@Keep +data class KeyValuePairEditRequest( + val key: String, + val value: String? = null, + val hint: String = "enter value", + private val candidateOptions: List? = null, + val inputType: KeyValuePairEditInputType = KeyValuePairEditInputType.String, + val metaData: KeyValuePairEditMetaData +) : ListItem() { + val shouldAllowFreeEdit: Boolean = inputType != KeyValuePairEditInputType.Selection && inputType != KeyValuePairEditInputType.Boolean + val processedCandidateOptions: List? = if (inputType == KeyValuePairEditInputType.Boolean) listOf("true", "false") else candidateOptions + fun isValidValue(text: String?) = when (inputType) { + is KeyValuePairEditInputType.Integer, is KeyValuePairEditInputType.Float -> !text.isNullOrEmpty() + else -> true + } +} + +@Keep +data class KeyValuePairEditResult( + val key: String, + val value: String?, + val metaData: KeyValuePairEditMetaData +) + +interface KeyValuePairEditMetaData + +@Keep +sealed class KeyValuePairEditInputType(val type: Int? = null) { + object Integer : KeyValuePairEditInputType(InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED) + + object Float : KeyValuePairEditInputType(InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_NUMBER_FLAG_DECIMAL) + + object Selection : KeyValuePairEditInputType() + + object Boolean : KeyValuePairEditInputType() + + object String : KeyValuePairEditInputType(InputType.TYPE_CLASS_TEXT) +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/KeyValuePairView.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/KeyValuePairView.kt new file mode 100644 index 000000000..609f71e96 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/KeyValuePairView.kt @@ -0,0 +1,39 @@ +package com.pluto.utilities.views.keyvalue + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import com.pluto.plugin.R +import com.pluto.plugin.databinding.PlutoViewKeyValuePairBinding +import com.pluto.utilities.extensions.color +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +class KeyValuePairView : ConstraintLayout { + + private val binding = PlutoViewKeyValuePairBinding.inflate(LayoutInflater.from(context), this, true) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + fun set(data: KeyValuePairData) { + binding.key.text = data.key + binding.value.setSpan { + data.value?.let { + append(it) + } ?: run { + append(italic(fontColor("~ null ~", context.color(R.color.pluto___text_dark_40)))) + } + } + + binding.value.setCompoundDrawablesWithIntrinsicBounds(0, 0, if (data.showClickIndicator) R.drawable.pluto___ic_chevron_right else 0, 0) + data.onClick?.let { + binding.root.isClickable = true + binding.root.setOnDebounceClickListener { data.onClick.invoke() } + } ?: run { + binding.root.isClickable = false + } + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/edit/KeyValuePairEditDialog.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/edit/KeyValuePairEditDialog.kt new file mode 100644 index 000000000..04dd61255 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/edit/KeyValuePairEditDialog.kt @@ -0,0 +1,136 @@ +package com.pluto.utilities.views.keyvalue.edit + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.core.content.ContextCompat +import androidx.core.widget.doOnTextChanged +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.chip.Chip +import com.pluto.plugin.R +import com.pluto.plugin.databinding.PlutoFragmentKeyValuePairEditBinding +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import com.pluto.utilities.viewBinding +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest +import com.pluto.utilities.views.keyvalue.KeyValuePairEditResult + +class KeyValuePairEditDialog : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoFragmentKeyValuePairEditBinding::bind) + private val keyValuePairEditor: KeyValuePairEditor by lazyKeyValuePairEditor() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto___fragment_key_value_pair_edit, container, false) + + override fun getTheme(): Int = R.style.PlutoBottomSheetDialogTheme + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.setOnShowListener { + val dialog = it as BottomSheetDialog + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + dialog.behavior.peekHeight = Device(requireContext()).screen.heightPx + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + keyValuePairEditor.data.value?.let { data -> + handleUI(data) + } + } + } + + binding.close.setOnDebounceClickListener { + dismiss() + } + } + + private fun handleUI(data: KeyValuePairEditRequest) { + binding.editKeyDescription.setSpan { + append(getString(R.string.pluto___key_value_editor_description)) + append(" ") + append(bold(fontColor(data.key, context.color(R.color.pluto___text_dark_80)))) + } + if (data.shouldAllowFreeEdit) { + handleInputType(data) + } else { + binding.editGroup.visibility = GONE + } + handleCandidateOptions(data) + binding.saveCta.setOnDebounceClickListener { + saveResult(data.key, binding.editValue.text.toString()) + } + } + + private fun handleCandidateOptions(data: KeyValuePairEditRequest) { + data.processedCandidateOptions?.let { list -> + binding.candidateOptionDescription.visibility = if (data.shouldAllowFreeEdit) VISIBLE else GONE + binding.candidateOptions.visibility = VISIBLE + list.forEach { + binding.candidateOptions.addView( + Chip(context).apply { + text = it.trim() + setTextAppearance(R.style.PlutoChipTextStyle) + if (it == data.value) { + chipIcon = ContextCompat.getDrawable(context, R.drawable.pluto___ic_edit_option_selected) + chipIconSize = 18f.dp + iconStartPadding = CHIP_ICON_PADDING + textStartPadding = CHIP_TEXT_PADDING + } + setOnDebounceClickListener { _ -> + saveResult(data.key, it) + } + } + ) + } + } + } + + private fun handleInputType(data: KeyValuePairEditRequest) { + binding.editGroup.visibility = VISIBLE + binding.editValue.setText(data.value) + binding.editValue.hint = data.hint + binding.editValue.requestFocus() + binding.editValue.isFocusableInTouchMode = true + binding.editValue.isFocusable = true + binding.editValue.post { + binding.editValue.setSelection(data.value?.length ?: 0) + } + data.inputType.type?.let { + binding.editValue.inputType = it + } + binding.editValue.doOnTextChanged { text, _, _, _ -> + val isValid = data.isValidValue(text?.toString()) + binding.saveCta.isEnabled = isValid + binding.saveCtaIcon.setBackgroundColor( + requireContext().color(if (isValid) R.color.pluto___emerald else R.color.pluto___disabled_cta) + ) + } + binding.editValue.setOnEditorActionListener { _, actionId, _ -> + if (data.isValidValue(binding.editValue.text?.toString()) && actionId == EditorInfo.IME_ACTION_DONE) { + saveResult(data.key, binding.editValue.text.toString()) + true + } else false + } + } + + private fun saveResult(key: String, value: String) { + keyValuePairEditor.data.value?.let { data -> + keyValuePairEditor.saveResult(KeyValuePairEditResult(key, value, data.metaData)) + } + dismiss() + } + + companion object { + private const val CHIP_TEXT_PADDING = 8f + private const val CHIP_ICON_PADDING = 10f + } +} diff --git a/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/edit/KeyValuePairEditor.kt b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/edit/KeyValuePairEditor.kt new file mode 100644 index 000000000..da26e5cd0 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/java/com/pluto/utilities/views/keyvalue/edit/KeyValuePairEditor.kt @@ -0,0 +1,34 @@ +package com.pluto.utilities.views.keyvalue.edit + +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import com.pluto.utilities.SingleLiveEvent +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest +import com.pluto.utilities.views.keyvalue.KeyValuePairEditResult + +class KeyValuePairEditor : ViewModel() { + + val data: LiveData + get() = _data + private val _data = SingleLiveEvent() + + val result: LiveData + get() = _result + private val _result = SingleLiveEvent() + + fun edit(data: KeyValuePairEditRequest) { + _data.postValue(data) + } + + fun saveResult(result: KeyValuePairEditResult) { + _result.postValue(result) + } +} + +fun Fragment.lazyKeyValuePairEditor(): Lazy = activityViewModels() + +fun ComponentActivity.lazyKeyValuePairEditor(): Lazy = viewModels() diff --git a/pluto/src/main/res/anim/pluto___fragment_default_enter.xml b/pluto-plugins/base/lib/src/main/res/anim/pluto___fragment_default_enter.xml similarity index 100% rename from pluto/src/main/res/anim/pluto___fragment_default_enter.xml rename to pluto-plugins/base/lib/src/main/res/anim/pluto___fragment_default_enter.xml diff --git a/pluto/src/main/res/anim/pluto___fragment_default_exit.xml b/pluto-plugins/base/lib/src/main/res/anim/pluto___fragment_default_exit.xml similarity index 100% rename from pluto/src/main/res/anim/pluto___fragment_default_exit.xml rename to pluto-plugins/base/lib/src/main/res/anim/pluto___fragment_default_exit.xml diff --git a/pluto/src/main/res/anim/pluto___fragment_default_reenter.xml b/pluto-plugins/base/lib/src/main/res/anim/pluto___fragment_default_reenter.xml similarity index 100% rename from pluto/src/main/res/anim/pluto___fragment_default_reenter.xml rename to pluto-plugins/base/lib/src/main/res/anim/pluto___fragment_default_reenter.xml diff --git a/pluto/src/main/res/anim/pluto___fragment_default_return.xml b/pluto-plugins/base/lib/src/main/res/anim/pluto___fragment_default_return.xml similarity index 100% rename from pluto/src/main/res/anim/pluto___fragment_default_return.xml rename to pluto-plugins/base/lib/src/main/res/anim/pluto___fragment_default_return.xml diff --git a/pluto-plugins/base/lib/src/main/res/drawable/pluto___bg_bottom_sheet.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___bg_bottom_sheet.xml new file mode 100644 index 000000000..48e20428b --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/drawable/pluto___bg_bottom_sheet.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/base/lib/src/main/res/drawable/pluto___bg_key_value_pair_input.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___bg_key_value_pair_input.xml new file mode 100644 index 000000000..5fd263b79 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/drawable/pluto___bg_key_value_pair_input.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_check.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_check.xml new file mode 100644 index 000000000..98f52f1ee --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_check_box.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_check_box.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_check_box.xml rename to pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_check_box.xml diff --git a/pluto/src/main/res/drawable/pluto___ic_check_box_selected.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_check_box_selected.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_check_box_selected.xml rename to pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_check_box_selected.xml diff --git a/pluto/src/main/res/drawable/pluto___ic_check_box_unselected.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_check_box_unselected.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_check_box_unselected.xml rename to pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_check_box_unselected.xml diff --git a/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_chevron_right.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_chevron_right.xml new file mode 100644 index 000000000..7c382f777 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_close.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_close.xml similarity index 86% rename from pluto/src/main/res/drawable/pluto___ic_close.xml rename to pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_close.xml index 52ec74ab6..82eaa9f97 100644 --- a/pluto/src/main/res/drawable/pluto___ic_close.xml +++ b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_close.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/pluto/src/main/res/drawable/pluto___ic_success.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_edit_option_selected.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_success.xml rename to pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_edit_option_selected.xml diff --git a/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_plugin_group_placeholder_icon.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_plugin_group_placeholder_icon.xml new file mode 100644 index 000000000..3a88c8217 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_plugin_group_placeholder_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_plugin_placeholder_icon.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_plugin_placeholder_icon.xml new file mode 100644 index 000000000..16740b74d --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_plugin_placeholder_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_radio.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_radio.xml new file mode 100644 index 000000000..64ede9fbf --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_radio.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_radio_selected.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_radio_selected.xml new file mode 100644 index 000000000..05375ff7d --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_radio_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_radio_unselected.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_radio_unselected.xml new file mode 100644 index 000000000..9a4d80c2e --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_radio_unselected.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_search.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_search.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_search.xml rename to pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_search.xml diff --git a/pluto/src/main/res/drawable/pluto___ic_share_as_copy.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_share_as_copy.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_share_as_copy.xml rename to pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_share_as_copy.xml diff --git a/pluto/src/main/res/drawable/pluto___ic_share_as_file.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_share_as_file.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_share_as_file.xml rename to pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_share_as_file.xml diff --git a/pluto/src/main/res/drawable/pluto___ic_share_as_text.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_share_as_text.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_share_as_text.xml rename to pluto-plugins/base/lib/src/main/res/drawable/pluto___ic_share_as_text.xml diff --git a/pluto/src/main/res/drawable/pluto___line_divider.xml b/pluto-plugins/base/lib/src/main/res/drawable/pluto___line_divider.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___line_divider.xml rename to pluto-plugins/base/lib/src/main/res/drawable/pluto___line_divider.xml diff --git a/pluto/src/main/res/font/muli.ttf b/pluto-plugins/base/lib/src/main/res/font/muli.ttf similarity index 100% rename from pluto/src/main/res/font/muli.ttf rename to pluto-plugins/base/lib/src/main/res/font/muli.ttf diff --git a/pluto/src/main/res/font/muli_bold.ttf b/pluto-plugins/base/lib/src/main/res/font/muli_bold.ttf similarity index 100% rename from pluto/src/main/res/font/muli_bold.ttf rename to pluto-plugins/base/lib/src/main/res/font/muli_bold.ttf diff --git a/pluto/src/main/res/font/muli_light.ttf b/pluto-plugins/base/lib/src/main/res/font/muli_light.ttf similarity index 100% rename from pluto/src/main/res/font/muli_light.ttf rename to pluto-plugins/base/lib/src/main/res/font/muli_light.ttf diff --git a/pluto/src/main/res/font/muli_semibold.ttf b/pluto-plugins/base/lib/src/main/res/font/muli_semibold.ttf similarity index 100% rename from pluto/src/main/res/font/muli_semibold.ttf rename to pluto-plugins/base/lib/src/main/res/font/muli_semibold.ttf diff --git a/pluto-plugins/base/lib/src/main/res/layout/pluto___fragment_key_value_pair_edit.xml b/pluto-plugins/base/lib/src/main/res/layout/pluto___fragment_key_value_pair_edit.xml new file mode 100644 index 000000000..0a7e19406 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/layout/pluto___fragment_key_value_pair_edit.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/base/lib/src/main/res/layout/pluto___fragment_share.xml b/pluto-plugins/base/lib/src/main/res/layout/pluto___fragment_share.xml new file mode 100644 index 000000000..198e00187 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/layout/pluto___fragment_share.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___layout_content_share_options.xml b/pluto-plugins/base/lib/src/main/res/layout/pluto___layout_content_share_options.xml similarity index 99% rename from pluto/src/main/res/layout/pluto___layout_content_share_options.xml rename to pluto-plugins/base/lib/src/main/res/layout/pluto___layout_content_share_options.xml index a1f915a41..198e00187 100644 --- a/pluto/src/main/res/layout/pluto___layout_content_share_options.xml +++ b/pluto-plugins/base/lib/src/main/res/layout/pluto___layout_content_share_options.xml @@ -82,7 +82,6 @@ android:drawableLeft="@drawable/pluto___ic_share_as_file" android:drawablePadding="@dimen/pluto___margin_xsmall" android:fontFamily="@font/muli_semibold" - android:gravity="center_vertical" android:paddingHorizontal="@dimen/pluto___margin_medium" android:paddingTop="@dimen/pluto___margin_small" diff --git a/pluto-plugins/base/lib/src/main/res/layout/pluto___multi_choice_selector_item.xml b/pluto-plugins/base/lib/src/main/res/layout/pluto___multi_choice_selector_item.xml new file mode 100644 index 000000000..f2015b62e --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/layout/pluto___multi_choice_selector_item.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/base/lib/src/main/res/layout/pluto___selector_dialog.xml b/pluto-plugins/base/lib/src/main/res/layout/pluto___selector_dialog.xml new file mode 100644 index 000000000..cde426cf1 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/layout/pluto___selector_dialog.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/base/lib/src/main/res/layout/pluto___single_choice_selector_item.xml b/pluto-plugins/base/lib/src/main/res/layout/pluto___single_choice_selector_item.xml new file mode 100644 index 000000000..1890b6779 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/layout/pluto___single_choice_selector_item.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/base/lib/src/main/res/layout/pluto___view_key_value_pair.xml b/pluto-plugins/base/lib/src/main/res/layout/pluto___view_key_value_pair.xml new file mode 100644 index 000000000..31c7a9620 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/layout/pluto___view_key_value_pair.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/base/lib/src/main/res/layout/pluto___view_tabular_data.xml b/pluto-plugins/base/lib/src/main/res/layout/pluto___view_tabular_data.xml new file mode 100644 index 000000000..607443761 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/layout/pluto___view_tabular_data.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/pluto-plugins/base/lib/src/main/res/values/colors.xml b/pluto-plugins/base/lib/src/main/res/values/colors.xml new file mode 100644 index 000000000..55451af4b --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/values/colors.xml @@ -0,0 +1,69 @@ + + + #008577 + #00574B + @color/pluto___text_dark_60 + + #f8f8f8 + #eeecec + #f0f0f0 + #f6f6f6 + #ffffff + #11141c + #0D11141c + #1A11141c + #3311141c + #6611141c + #9911141c + #cc11141c + + #231f40 + #33231f40 + #66231f40 + #99231f40 + #cc231f40 + @color/pluto___dark + #0Dffffff + #1Affffff + #33ffffff + #66ffffff + #99ffffff + #ccffffff + #e50914 + #ff5733 + #0Dff5733 + #33ff5733 + #66ff5733 + #99ff5733 + #ccff5733 + #ffa000 + #0Dffa000 + #66ffa000 + #99ffa000 + #ccffa000 + #009463 + #20c997 + #00000000 + #eeecec + #73a964 + #1873a964 + #0D73a964 + + #f5f5f5 + #366BD1 + #cc366BD1 + #66366BD1 + + #aaff9100 + + #eeecec + #ff5733 + + #11141c + #ff9100 + + #a9a9a9 + + #11141c + #6611141c + diff --git a/pluto-plugins/base/lib/src/main/res/values/dimens.xml b/pluto-plugins/base/lib/src/main/res/values/dimens.xml new file mode 100644 index 000000000..3bf372447 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/values/dimens.xml @@ -0,0 +1,36 @@ + + + 70dp + 8dp + 60dp + 44dp + + 1dp + + 2dp + 4dp + 8dp + 12dp + 16dp + 20dp + 24dp + 28dp + 32dp + 36dp + 40dp + 72dp + + 10sp + 11sp + 12sp + 13sp + 14sp + 15sp + 16sp + 18sp + 20sp + 24sp + 30sp + + 48dp + \ No newline at end of file diff --git a/pluto/src/main/res/values/integers.xml b/pluto-plugins/base/lib/src/main/res/values/integers.xml similarity index 100% rename from pluto/src/main/res/values/integers.xml rename to pluto-plugins/base/lib/src/main/res/values/integers.xml diff --git a/pluto-plugins/base/lib/src/main/res/values/strings.xml b/pluto-plugins/base/lib/src/main/res/values/strings.xml new file mode 100644 index 000000000..a14e6fd75 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + Edit Value + editing value of + enter value here + or choose from these options + + Share As + Share as Text + Share as File + Copy to Clipboard + Prefer this option while sharing on Slack. + Clear + Done + diff --git a/pluto-plugins/base/lib/src/main/res/values/styles.xml b/pluto-plugins/base/lib/src/main/res/values/styles.xml new file mode 100644 index 000000000..ccec36e90 --- /dev/null +++ b/pluto-plugins/base/lib/src/main/res/values/styles.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/bundle/lib-no-op/.gitignore b/pluto-plugins/bundle/lib-no-op/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/bundle/lib-no-op/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/bundle/lib-no-op/build.gradle.kts b/pluto-plugins/bundle/lib-no-op/build.gradle.kts new file mode 100644 index 000000000..10325711d --- /dev/null +++ b/pluto-plugins/bundle/lib-no-op/build.gradle.kts @@ -0,0 +1,98 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.bundle.core" + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "bundle-core-no-op" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Plugin Bundle" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Bundle module for Android Pluto plugins" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + api(project(":pluto-plugins:plugins:exceptions:lib-no-op")) + api(project(":pluto-plugins:plugins:network:lib-no-op")) + api(project(":pluto-plugins:plugins:shared-preferences:lib-no-op")) + api(project(":pluto-plugins:plugins:logger:lib-no-op")) + api(project(":pluto-plugins:plugins:datastore:lib-no-op")) + api(project(":pluto-plugins:plugins:rooms-database:lib-no-op")) + api(project(":pluto-plugins:plugins:layout-inspector:lib-no-op")) +} diff --git a/pluto-plugins/bundle/lib-no-op/src/main/AndroidManifest.xml b/pluto-plugins/bundle/lib-no-op/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/bundle/lib-no-op/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/bundle/lib/.gitignore b/pluto-plugins/bundle/lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/bundle/lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/bundle/lib/build.gradle.kts b/pluto-plugins/bundle/lib/build.gradle.kts new file mode 100644 index 000000000..715329e28 --- /dev/null +++ b/pluto-plugins/bundle/lib/build.gradle.kts @@ -0,0 +1,100 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.bundle.core" + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "bundle-core" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Plugin Bundle" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Bundle module for Android Pluto plugins" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + api(project(":pluto-plugins:plugins:exceptions:lib")) + api(project(":pluto-plugins:plugins:network:lib")) +// api(project(":pluto-plugins:plugins:network:interceptor-ktor:lib")) +// api(project(":pluto-plugins:plugins:network:interceptor-okhttp:lib")) + api(project(":pluto-plugins:plugins:shared-preferences:lib")) + api(project(":pluto-plugins:plugins:logger:lib")) + api(project(":pluto-plugins:plugins:datastore:lib")) + api(project(":pluto-plugins:plugins:rooms-database:lib")) + api(project(":pluto-plugins:plugins:layout-inspector:lib")) +} diff --git a/pluto-plugins/bundle/lib/src/main/AndroidManifest.xml b/pluto-plugins/bundle/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/bundle/lib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/datastore/README.md b/pluto-plugins/plugins/datastore/README.md new file mode 100644 index 000000000..99745bb8a --- /dev/null +++ b/pluto-plugins/plugins/datastore/README.md @@ -0,0 +1,51 @@ +## Integrate Datastore Preferences Plugin in your application + +[DataStore Preferences Demo.webm](https://github.com/user-attachments/assets/0b4dfda2-f597-4bcd-8600-2802d0595d0c) + + +### Add Gradle Dependencies +Pluto Datastore Preferences is distributed through [***mavenCentral***](https://central.sonatype.com/artifact/com.androidpluto.plugins/datastore-pref). To use it, you need to add the following Gradle dependency to your build.gradle file of you android app module. + +> Note: add the `no-op` variant to isolate the plugin from release builds. +```groovy +dependencies { + debugImplementation "com.androidpluto.plugins:datastore-pref:$plutoVersion" + releaseImplementation "com.androidpluto.plugins:datastore-pref-no-op:$plutoVersion" +} +``` +
+ +### Install plugin to Pluto + +Now to start using the plugin, add it to Pluto +```kotlin +Pluto.Installer(this) + .addPlugin(PlutoDatastorePreferencesPlugin()) + .install() +``` +
+ +### Start watching Datastore Preference + +Create intance of DataStore Preferences and start watching in Pluto. +```kotlin +val Context.appPreferences by preferencesDataStore( + name = PREF_NAME +) + +PlutoDatastoreWatcher.watch(PREF_NAME, appPreferences) +``` +
+ +πŸŽ‰  You are all done! + +Now re-build and run your app and open Pluto, you will see the Datastore Preferences plugin installed. + +
+ +### Open Plugin view programmatically +To open Datastore plugin screen via code, use this +```kotlin +Pluto.open(PlutoDatastorePreferencesPlugin.ID) +``` + diff --git a/pluto-plugins/plugins/datastore/lib-no-op/.gitignore b/pluto-plugins/plugins/datastore/lib-no-op/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib-no-op/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/datastore/lib-no-op/build.gradle.kts b/pluto-plugins/plugins/datastore/lib-no-op/build.gradle.kts new file mode 100644 index 000000000..f1bb514c0 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib-no-op/build.gradle.kts @@ -0,0 +1,92 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.datastore.pref" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "datastore-pref-no-op" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Datastore Preferences Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to manage Datastore preferences in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(libs.datastore.preferences) +} diff --git a/pluto-plugins/plugins/datastore/lib-no-op/src/main/AndroidManifest.xml b/pluto-plugins/plugins/datastore/lib-no-op/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib-no-op/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/datastore/lib-no-op/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastorePreferencesPlugin.kt b/pluto-plugins/plugins/datastore/lib-no-op/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastorePreferencesPlugin.kt new file mode 100644 index 000000000..b0270034f --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib-no-op/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastorePreferencesPlugin.kt @@ -0,0 +1,8 @@ +package com.pluto.plugins.datastore.pref + +@SuppressWarnings("UnusedPrivateMember") +class PlutoDatastorePreferencesPlugin @JvmOverloads constructor(identifier: String = ID) { + companion object { + const val ID = "datastore-preferences" + } +} diff --git a/pluto-plugins/plugins/datastore/lib-no-op/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastoreWatcher.kt b/pluto-plugins/plugins/datastore/lib-no-op/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastoreWatcher.kt new file mode 100644 index 000000000..31607ad61 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib-no-op/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastoreWatcher.kt @@ -0,0 +1,10 @@ +package com.pluto.plugins.datastore.pref + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences + +@Suppress("UnusedPrivateMember", "EmptyFunctionBlock") +object PlutoDatastoreWatcher { + fun watch(name: String, store: DataStore) { } // noop + fun remove(name: String) { } // noop +} diff --git a/pluto-plugins/plugins/datastore/lib/.gitignore b/pluto-plugins/plugins/datastore/lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/datastore/lib/build.gradle.kts b/pluto-plugins/plugins/datastore/lib/build.gradle.kts new file mode 100644 index 000000000..88be0581f --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/build.gradle.kts @@ -0,0 +1,105 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) + alias(libs.plugins.ksp) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.datastore.pref" + resourcePrefix = "pluto_dts___" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"$verPublish\"") + buildConfigField("long", "VERSION_CODE", "$verCode") + buildConfigField("String", "GIT_SHA", "\"$verGitSHA\"") + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "datastore-pref" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Datastore Preferences Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to manage Datastore preferences in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(project(":pluto-plugins:base:lib")) + + implementation(libs.androidx.core) + implementation(libs.datastore.preferences) + + implementation(libs.moshi) + ksp(libs.moshi.codegen) +} diff --git a/pluto-plugins/plugins/datastore/lib/src/main/AndroidManifest.xml b/pluto-plugins/plugins/datastore/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/BaseFragment.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/BaseFragment.kt new file mode 100644 index 000000000..1aa50dc07 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/BaseFragment.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.datastore.pref + +import androidx.fragment.app.Fragment + +internal class BaseFragment : Fragment(R.layout.pluto_dts___fragment_base) diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastorePreferencesPlugin.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastorePreferencesPlugin.kt new file mode 100644 index 000000000..d4ecd5216 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastorePreferencesPlugin.kt @@ -0,0 +1,39 @@ +package com.pluto.plugins.datastore.pref + +import androidx.fragment.app.Fragment +import com.pluto.plugin.DeveloperDetails +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginConfiguration + +class PlutoDatastorePreferencesPlugin() : Plugin(ID) { + + @SuppressWarnings("UnusedPrivateMember") + @Deprecated("Use the default constructor PlutoDatastorePreferencesPlugin() instead.") + constructor(identifier: String) : this() + + override fun getConfig() = PluginConfiguration( + name = "DataStore Preferences", + version = BuildConfig.VERSION_NAME, + icon = R.drawable.pluto_dts___ic_logo + ) + + override fun getDeveloperDetails(): DeveloperDetails { + return DeveloperDetails( + website = "https://androidpluto.com", + vcsLink = "https://github.com/androidPluto/pluto", + twitter = "https://twitter.com/android_pluto" + ) + } + + override fun getView(): Fragment { + return BaseFragment() + } + + override fun onPluginInstalled() {} + + override fun onPluginDataCleared() {} + + companion object { + const val ID = "datastore-preferences" + } +} diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastoreWatcher.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastoreWatcher.kt new file mode 100644 index 000000000..52eae9caa --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/PlutoDatastoreWatcher.kt @@ -0,0 +1,41 @@ +package com.pluto.plugins.datastore.pref + +import androidx.annotation.Keep +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.pluto.utilities.selector.SelectorOption +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +object PlutoDatastoreWatcher { + + internal val sources = MutableStateFlow>(emptySet()) + + fun watch(name: String, store: DataStore) { + sources.update { oldSet -> + mutableSetOf().apply { + addAll(oldSet) + add(PreferenceHolder(name, store)) + } + } + } + + fun remove(name: String) { + sources.update { oldSet -> + mutableSetOf().apply { + oldSet.forEach { + if (it.name != name) add(it) + } + } + } + } + + internal fun getSource(name: String): PreferenceHolder { + return sources.value.toList().first { it.name == name } + } +} + +@Keep +internal data class PreferenceHolder(val name: String, val preferences: DataStore) : SelectorOption() { + override fun displayText(): CharSequence = name +} diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/Session.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/Session.kt new file mode 100644 index 000000000..62c672477 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/Session.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.datastore.pref + +internal object Session { + var searchText: String? = null +} diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/DatastorePrefUtils.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/DatastorePrefUtils.kt new file mode 100644 index 000000000..6b41d5500 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/DatastorePrefUtils.kt @@ -0,0 +1,38 @@ +package com.pluto.plugins.datastore.pref.internal + +import android.content.Context +import com.pluto.plugins.datastore.pref.PlutoDatastoreWatcher +import com.pluto.plugins.datastore.pref.PreferenceHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.views.keyvalue.KeyValuePairEditMetaData +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types + +@SuppressWarnings("UseDataClass") +internal class DatastorePrefUtils(context: Context) { + + private val preferences: Preferences = Preferences(context) + private val moshi: Moshi = Moshi.Builder().build() + private val moshiAdapter: JsonAdapter?> = moshi.adapter(Types.newParameterizedType(List::class.java, String::class.java)) + + internal var selectedPreferenceFiles: List = arrayListOf() + get() { + return preferences.selectedPreferenceFiles?.let { + moshiAdapter.fromJson(it)?.map { label -> PlutoDatastoreWatcher.getSource(label) } + } ?: run { + selectedPreferenceFiles = PlutoDatastoreWatcher.sources.value.toList() + selectedPreferenceFiles + } + } + set(value) { + preferences.selectedPreferenceFiles = moshiAdapter.toJson(value.map { it.name }) + field = value + } +} + +internal data class DatastorePrefKeyValuePair( + val key: String, + val value: Any?, + val prefLabel: String? +) : ListItem(), KeyValuePairEditMetaData diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/EditProcessor.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/EditProcessor.kt new file mode 100644 index 000000000..b85a501e4 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/EditProcessor.kt @@ -0,0 +1,35 @@ +package com.pluto.plugins.datastore.pref.internal + +import com.pluto.utilities.views.keyvalue.KeyValuePairEditInputType +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +internal fun DatastorePrefKeyValuePair.toEditorData(): KeyValuePairEditRequest { + return KeyValuePairEditRequest( + key = key, + value = value?.toString(), + hint = when (value) { + is Int, is Long -> "12345" + is Boolean -> "true / false" + is Float, is Double -> "1234.89" + else -> "abcde 123" + }, + inputType = when (value) { + is Int, is Long -> KeyValuePairEditInputType.Integer + is Float, is Double -> KeyValuePairEditInputType.Float + is Boolean -> KeyValuePairEditInputType.Boolean + else -> KeyValuePairEditInputType.String + }, + metaData = this + ) +} + +internal fun DatastorePrefKeyValuePair.fromEditorData(text: String): Any { + return when (value) { + is Int -> text.toInt() + is Double -> text.toDouble() + is Long -> text.toLong() + is Float -> text.toFloat() + is Boolean -> text.toBoolean() + else -> text + } +} diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/Preferences.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/Preferences.kt new file mode 100644 index 000000000..ffb0a8a23 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/Preferences.kt @@ -0,0 +1,18 @@ +package com.pluto.plugins.datastore.pref.internal + +import android.content.Context + +internal class Preferences(context: Context) { + + private val settingsPrefs by lazy { context.preferences("_pluto_datastore_pref_settings") } + + internal var selectedPreferenceFiles: String? + get() = settingsPrefs.getString(SELECTED_PREF_FILE, null) + set(value) = settingsPrefs.edit().putString(SELECTED_PREF_FILE, value).apply() + + companion object { + private const val SELECTED_PREF_FILE = "selected_datastore_pref_file" + } +} + +private fun Context.preferences(name: String, mode: Int = Context.MODE_PRIVATE) = getSharedPreferences(name, mode) diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/DatastorePrefAdapter.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/DatastorePrefAdapter.kt new file mode 100644 index 000000000..7217a39ed --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/DatastorePrefAdapter.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.datastore.pref.internal.ui + +import android.view.ViewGroup +import com.pluto.plugins.datastore.pref.internal.DatastorePrefKeyValuePair +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class DatastorePrefAdapter(private val listener: OnActionListener) : BaseAdapter() { + + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is DatastorePrefKeyValuePair -> ITEM_TYPE_PAIR + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_PAIR -> KeyValueItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_PAIR = 1001 + } +} diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/DatastorePrefViewModel.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/DatastorePrefViewModel.kt new file mode 100644 index 000000000..ed77e9e5b --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/DatastorePrefViewModel.kt @@ -0,0 +1,72 @@ +package com.pluto.plugins.datastore.pref.internal.ui + +import android.app.Application +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.pluto.plugins.datastore.pref.PlutoDatastoreWatcher +import com.pluto.plugins.datastore.pref.PreferenceHolder +import com.pluto.plugins.datastore.pref.internal.DatastorePrefKeyValuePair +import com.pluto.plugins.datastore.pref.internal.DatastorePrefUtils +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +internal class DatastorePrefViewModel(application: Application) : AndroidViewModel(application) { + + val preferenceList: LiveData> + get() = _preferences + private val _preferences = MutableLiveData>() + + private val sharePrefUtils = DatastorePrefUtils(application.applicationContext) + + fun getSelectedPrefFiles(): List = sharePrefUtils.selectedPreferenceFiles + + fun setSelectedPrefFiles(files: List) { + sharePrefUtils.selectedPreferenceFiles = files + refresh() + } + + fun setPrefData(pair: DatastorePrefKeyValuePair, value: Any) { + viewModelScope.launch { + val preferences = PlutoDatastoreWatcher.sources.value.find { + it.name == pair.prefLabel + }?.preferences + + preferences?.edit { preference -> + when (pair.value) { + is Boolean -> preference[booleanPreferencesKey(pair.key)] = value as Boolean + is Double -> preference[doublePreferencesKey(pair.key)] = value as Double + is Int -> preference[intPreferencesKey(pair.key)] = value as Int + is Float -> preference[floatPreferencesKey(pair.key)] = value as Float + is Long -> preference[longPreferencesKey(pair.key)] = value as Long + is String -> preference[stringPreferencesKey(pair.key)] = value as String + else -> { + // show some error + // add validation before sending data here + } + } + } + refresh() + } + } + + fun refresh() { + viewModelScope.launch { + val list = arrayListOf() + getSelectedPrefFiles().forEach { + it.preferences.data.first().asMap().map { (key, value) -> + list.add(DatastorePrefKeyValuePair(key.name, value, it.name)) + } + } + _preferences.postValue(list) + } + } +} diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/KeyValueItemHolder.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/KeyValueItemHolder.kt new file mode 100644 index 000000000..8e30e50e0 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/KeyValueItemHolder.kt @@ -0,0 +1,55 @@ +package com.pluto.plugins.datastore.pref.internal.ui + +import android.view.ViewGroup +import com.pluto.plugins.datastore.pref.R +import com.pluto.plugins.datastore.pref.databinding.PlutoDtsItemSharedPrefKeyValueBinding +import com.pluto.plugins.datastore.pref.internal.DatastorePrefKeyValuePair +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.createSpan + +internal class KeyValueItemHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_dts___item_shared_pref_key_value), actionListener) { + + private val binding = PlutoDtsItemSharedPrefKeyValueBinding.bind(itemView) + private val key = binding.key + private val value = binding.value + private val file = binding.file + + override fun onBind(item: ListItem) { + if (item is DatastorePrefKeyValuePair) { + key.text = item.key + val fileName = item.prefLabel + file.text = if (fileName != null) { + if (fileName.length > MAX_FILENAME_LENGTH) { + "${fileName.substring(0, MAX_FILENAME_LENGTH - 2)}..." + } else { + fileName + } + } else { + itemView.context.createSpan { + append(fontColor(light(italic("null")), context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + } + item.value?.let { value.text = it.toString() } + + itemView.setOnDebounceClickListener { + onAction("click") + } + itemView.setOnLongClickListener { + onAction("long_click") + return@setOnLongClickListener true + } + } + } + + companion object { + const val MAX_FILENAME_LENGTH = 18 + } +} diff --git a/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/ListFragment.kt b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/ListFragment.kt new file mode 100644 index 000000000..0f76f541a --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/java/com/pluto/plugins/datastore/pref/internal/ui/ListFragment.kt @@ -0,0 +1,139 @@ +package com.pluto.plugins.datastore.pref.internal.ui + +import android.os.Bundle +import android.view.View +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.datastore.pref.PlutoDatastoreWatcher +import com.pluto.plugins.datastore.pref.PreferenceHolder +import com.pluto.plugins.datastore.pref.R +import com.pluto.plugins.datastore.pref.Session +import com.pluto.plugins.datastore.pref.databinding.PlutoDtsFragmentListBinding +import com.pluto.plugins.datastore.pref.internal.DatastorePrefKeyValuePair +import com.pluto.plugins.datastore.pref.internal.fromEditorData +import com.pluto.plugins.datastore.pref.internal.toEditorData +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.hideKeyboard +import com.pluto.utilities.extensions.linearLayoutManager +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.selector.lazyDataSelector +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding +import com.pluto.utilities.views.keyvalue.KeyValuePairEditResult +import com.pluto.utilities.views.keyvalue.edit.KeyValuePairEditor +import com.pluto.utilities.views.keyvalue.edit.lazyKeyValuePairEditor + +internal class ListFragment : Fragment(R.layout.pluto_dts___fragment_list) { + private val binding by viewBinding(PlutoDtsFragmentListBinding::bind) + private val viewModel: DatastorePrefViewModel by activityViewModels() + private val keyValuePairEditor: KeyValuePairEditor by lazyKeyValuePairEditor() + private val prefAdapter: BaseAdapter by autoClearInitializer { + DatastorePrefAdapter(onActionListener) + } + private val contentSharer by lazyContentSharer() + private val dataSelector by lazyDataSelector() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.refresh() + + binding.list.apply { + adapter = prefAdapter + addItemDecoration(CustomItemDecorator(requireContext())) + } + + binding.search.doOnTextChanged { text, _, _, _ -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + text?.toString()?.let { + Session.searchText = it + prefAdapter.list = filteredPrefs(it) + if (it.isEmpty()) { + binding.list.linearLayoutManager()?.scrollToPositionWithOffset(0, 0) + } + } + } + } + binding.filter.setOnDebounceClickListener { openFilterView() } + binding.search.setText(Session.searchText) + viewModel.preferenceList.removeObserver(sharedPrefObserver) + viewModel.preferenceList.observe(viewLifecycleOwner, sharedPrefObserver) + keyValuePairEditor.result.removeObserver(keyValuePairEditObserver) + keyValuePairEditor.result.observe(viewLifecycleOwner, keyValuePairEditObserver) + + binding.close.setOnDebounceClickListener { + activity?.finish() + } + } + + private fun openFilterView() { + dataSelector.selectMultiple( + title = getString(R.string.pluto_dts___datastore_pref_filter), + list = PlutoDatastoreWatcher.sources.value.toList(), + preSelected = viewModel.getSelectedPrefFiles() + ).observe(viewLifecycleOwner) { + val listOfSharePrefFiles = arrayListOf() + it.forEach { option -> + if (option is PreferenceHolder) { + listOfSharePrefFiles.add(option) + } + } + viewModel.setSelectedPrefFiles(listOfSharePrefFiles) + } + } + + private fun filteredPrefs(search: String): List { + var list = emptyList() + viewModel.preferenceList.value?.let { + list = it.filter { pref -> + pref.key.contains(search, true) || + pref.value.toString().contains(search, ignoreCase = true) + } + } + binding.noItemText.visibility = if (list.isEmpty()) View.VISIBLE else View.GONE + return list + } + + private val keyValuePairEditObserver = Observer { + it.value?.let { value -> + if (it.metaData is DatastorePrefKeyValuePair) { + val pref: DatastorePrefKeyValuePair = it.metaData as DatastorePrefKeyValuePair + viewModel.setPrefData(pref, pref.fromEditorData(value)) + } + } + } + + private val sharedPrefObserver = Observer> { + prefAdapter.list = filteredPrefs(binding.search.text.toString()) + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is DatastorePrefKeyValuePair) { + when (action) { + "click" -> activity?.let { + it.hideKeyboard(viewLifecycleOwner.lifecycleScope) { + keyValuePairEditor.edit(data.toEditorData()) + } + } + + "long_click" -> contentSharer.share( + Shareable( + content = "${data.key} : ${data.value}", + title = "Share Shared Preference", + fileName = "Preference data from Pluto" + ) + ) + } + } + } + } +} diff --git a/pluto/src/main/res/drawable/pluto___bg_shared_pref_file_badge.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___bg_shared_pref_file_badge.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___bg_shared_pref_file_badge.xml rename to pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___bg_shared_pref_file_badge.xml diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_back.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_back.xml new file mode 100644 index 000000000..204c2bc76 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_check.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_check.xml new file mode 100644 index 000000000..74ef80b31 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_clear.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_clear.xml new file mode 100644 index 000000000..27e240025 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_clear.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_close.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_close.xml new file mode 100644 index 000000000..82eaa9f97 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_expand.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_expand.xml new file mode 100644 index 000000000..0b3592968 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_expand.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_filter.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_filter.xml new file mode 100644 index 000000000..38550c5be --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_filter.xml @@ -0,0 +1,14 @@ + + + + diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_logo.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_logo.xml new file mode 100644 index 000000000..bd55d420d --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/drawable/pluto_dts___ic_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/layout/pluto_dts___fragment_base.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/layout/pluto_dts___fragment_base.xml new file mode 100644 index 000000000..84204aaa1 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/layout/pluto_dts___fragment_base.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/layout/pluto_dts___fragment_list.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/layout/pluto_dts___fragment_list.xml new file mode 100644 index 000000000..850fe4a2c --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/layout/pluto_dts___fragment_list.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/layout/pluto_dts___item_shared_pref_key_value.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/layout/pluto_dts___item_shared_pref_key_value.xml new file mode 100644 index 000000000..b475ea891 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/layout/pluto_dts___item_shared_pref_key_value.xml @@ -0,0 +1,71 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/navigation/pluto_dts___navigation.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/navigation/pluto_dts___navigation.xml new file mode 100644 index 000000000..876f4842a --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/navigation/pluto_dts___navigation.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/datastore/lib/src/main/res/values/strings.xml b/pluto-plugins/plugins/datastore/lib/src/main/res/values/strings.xml new file mode 100644 index 000000000..fb26cedc3 --- /dev/null +++ b/pluto-plugins/plugins/datastore/lib/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + Datastore Preferences + Choose Datastore + No DataStore Preferences present.\nAdd a DataStore Preference or check Filter settings. + Search Preferences + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/README.md b/pluto-plugins/plugins/exceptions/README.md new file mode 100644 index 000000000..e3df1b787 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/README.md @@ -0,0 +1,59 @@ +## Integrate Exceptions Plugin in your application + +[Crashes and ANRs Demo.webm](https://github.com/user-attachments/assets/ed690b3b-5d7e-415e-9373-6f6d183a4def) + +### Add Gradle Dependencies +Pluto Exceptions is distributed through [***mavenCentral***](https://central.sonatype.com/artifact/com.androidpluto.plugins/exceptions). To use it, you need to add the following Gradle dependency to your build.gradle file of you android app module. + +> Note: add the `no-op` variant to isolate the plugin from release builds. +```groovy +dependencies { + debugImplementation "com.androidpluto.plugins:exceptions:$plutoVersion" + releaseImplementation "com.androidpluto.plugins:exceptions-no-op:$plutoVersion" +} +``` +
+ +### Install plugin to Pluto + +Now to start using the plugin, add it to Pluto +```kotlin +Pluto.Installer(this) + .addPlugin(PlutoExceptionsPlugin()) + .install() +``` +
+ +### Set Global Exception Handler + +To intercept uncaught exceptions in your app, attach `UncaughtExceptionHandler` to PlutoExceptions +```kotlin +PlutoExceptions.setExceptionHandler { thread, throwable -> + Log.d("exception_demo", "uncaught exception handled on thread: " + thread.name, throwable) +} +``` + +To intercept & report potential ANRs in your app, attach `UncaughtANRHandler` to PlutoExceptions +```kotlin +PlutoExceptions.setANRHandler { thread, exception -> + Log.d("anr_demo", "potential ANR detected on thread: " + thread.name, exception) +} +``` + +You can also modify the Main thread response time, after which the above callback will be triggered. +```kotlin +PlutoExceptions.mainThreadResponseThreshold = 10_000 +``` +
+ +πŸŽ‰  You are all done! + +Now re-build and run your app and open Pluto, you will see the Exceptions plugin installed. + +
+ +### Open Plugin view programmatically +To open Exceptions plugin screen via code, use this +```kotlin +Pluto.open(PlutoExceptionsPlugin.ID) +``` diff --git a/pluto-plugins/plugins/exceptions/lib-no-op/.gitignore b/pluto-plugins/plugins/exceptions/lib-no-op/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib-no-op/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib-no-op/build.gradle.kts b/pluto-plugins/plugins/exceptions/lib-no-op/build.gradle.kts new file mode 100644 index 000000000..04cdf7563 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib-no-op/build.gradle.kts @@ -0,0 +1,91 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.exceptions" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "exceptions-no-op" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Exceptions Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to capture expections & ANRs in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { +} diff --git a/pluto-plugins/plugins/exceptions/lib-no-op/src/main/AndroidManifest.xml b/pluto-plugins/plugins/exceptions/lib-no-op/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib-no-op/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/ANRException.kt b/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/ANRException.kt new file mode 100644 index 000000000..0101d7da6 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/ANRException.kt @@ -0,0 +1,7 @@ +package com.pluto.plugins.exceptions + +import androidx.annotation.Keep + +@Keep +@SuppressWarnings("UnusedPrivateMember", "EmptyFunctionBlock") +class ANRException(thread: Thread) : Exception("ANR detected") diff --git a/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/PlutoExceptions.kt b/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/PlutoExceptions.kt new file mode 100644 index 000000000..63b544242 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/PlutoExceptions.kt @@ -0,0 +1,17 @@ +package com.pluto.plugins.exceptions + +@SuppressWarnings("UnusedPrivateMember", "EmptyFunctionBlock") +object PlutoExceptions { + + /** + * The threshold for main thread response + * time before resulting in ANR. + */ + var mainThreadResponseThreshold = 0 + + fun setExceptionHandler(uncaughtExceptionHandler: Thread.UncaughtExceptionHandler) { + } + + fun setANRHandler(anrHandler: UncaughtANRHandler) { + } +} diff --git a/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/PlutoExceptionsPlugin.kt b/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/PlutoExceptionsPlugin.kt new file mode 100644 index 000000000..06cd65719 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/PlutoExceptionsPlugin.kt @@ -0,0 +1,8 @@ +package com.pluto.plugins.exceptions + +@SuppressWarnings("UnusedPrivateMember") +class PlutoExceptionsPlugin @JvmOverloads constructor(identifier: String = ID) { + companion object { + const val ID = "exceptions" + } +} diff --git a/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/UncaughtANRHandler.kt b/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/UncaughtANRHandler.kt new file mode 100644 index 000000000..ed716d5da --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib-no-op/src/main/java/com/pluto/plugins/exceptions/UncaughtANRHandler.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.exceptions + +fun interface UncaughtANRHandler { + fun uncaughtANR(thread: Thread, exception: ANRException) +} diff --git a/pluto-plugins/plugins/exceptions/lib/.gitignore b/pluto-plugins/plugins/exceptions/lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/build.gradle.kts b/pluto-plugins/plugins/exceptions/lib/build.gradle.kts new file mode 100644 index 000000000..bb6085b9d --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/build.gradle.kts @@ -0,0 +1,108 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.ksp) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.exceptions" + resourcePrefix = "pluto_excep___" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"$verPublish\"") + buildConfigField("long", "VERSION_CODE", "$verCode") + buildConfigField("String", "GIT_SHA", "\"$verGitSHA\"") + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "exceptions" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Exceptions Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to capture expections & ANRs in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(project(":pluto-plugins:base:lib")) + + implementation(libs.androidx.core) + + implementation(libs.moshi) + ksp(libs.moshi.codegen) + + implementation(libs.room) + ksp(libs.room.compiler) +} diff --git a/pluto-plugins/plugins/exceptions/lib/detekt-baseline.xml b/pluto-plugins/plugins/exceptions/lib/detekt-baseline.xml new file mode 100644 index 000000000..126e7dbf4 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/detekt-baseline.xml @@ -0,0 +1,8 @@ + + + + + TopLevelPropertyNaming:DetailsFragment.kt$private const val SHARE_SECTION_DIVIDER = "\n\n==================\n\n" + TopLevelPropertyNaming:DetailsFragment.kt$private const val STACK_TRACE_LENGTH = 25 + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/AndroidManifest.xml b/pluto-plugins/plugins/exceptions/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/ANRException.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/ANRException.kt new file mode 100644 index 000000000..5225a93db --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/ANRException.kt @@ -0,0 +1,78 @@ +package com.pluto.plugins.exceptions + +import androidx.annotation.Keep +import com.pluto.plugins.exceptions.internal.ProcessThread +import com.pluto.plugins.exceptions.internal.asStringArray +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.util.Locale + +@Keep +class ANRException(thread: Thread) : Exception("ANR detected") { + + val threadStateMap: String + get() = generateProcessMap() + internal val threadStateList: List + get() = generateProcessList() + + init { + stackTrace = thread.stackTrace + } + + /** + * Logs the current process and all its threads + */ + private fun generateProcessMap(): String { + val bos = ByteArrayOutputStream() + val ps = PrintStream(bos) + printProcessMap(ps) + return String(bos.toByteArray()) + } + + /** + * Prints the current process and all its threads + * + * @param ps the [PrintStream] to which the + * info is written + */ + private fun printProcessMap(ps: PrintStream) { + // Get all stack traces in the system + val stackTraces = Thread.getAllStackTraces() + ps.println("Process map:") + for (thread in stackTraces.keys) { + if (!stackTraces[thread].isNullOrEmpty()) { + printThread(ps, Locale.getDefault(), thread, stackTraces[thread]!!) + ps.println() + } + } + } + + /** + * Prints the given thread + * @param ps the [PrintStream] to which the + * info is written + * @param l the [Locale] to use + * @param thread the [Thread] to print + * @param stack the [Thread]'s stack trace + */ + private fun printThread(ps: PrintStream, l: Locale, thread: Thread, stack: Array) { + ps.println(String.format(l, "\t%s (%s)", thread.name, thread.state)) + for (element in stack) { + element.apply { + ps.println(String.format(l, "\t\t%s.%s(%s:%d)", className, methodName, fileName, lineNumber)) + } + } + } + + private fun generateProcessList(): List { + val list = arrayListOf() + val stackTraces = Thread.getAllStackTraces() + for (thread in stackTraces.keys) { + if (!stackTraces[thread].isNullOrEmpty()) { + val process = ProcessThread(thread.name, thread.state.name, stackTraces[thread]!!.asStringArray()) + list.add(process) + } + } + return list + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/PlutoExceptions.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/PlutoExceptions.kt new file mode 100644 index 000000000..2187e03e6 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/PlutoExceptions.kt @@ -0,0 +1,52 @@ +package com.pluto.plugins.exceptions + +import android.content.Context +import com.pluto.plugins.exceptions.internal.Session +import com.pluto.plugins.exceptions.internal.anr.AnrSupervisor +import com.pluto.plugins.exceptions.internal.anr.AnrSupervisor.Companion.DEFAULT_MAIN_THREAD_RESPONSE_THRESHOLD +import com.pluto.plugins.exceptions.internal.crash.CrashHandler +import com.pluto.plugins.exceptions.internal.persistence.ExceptionDBHandler + +object PlutoExceptions { + + private var crashHandler: CrashHandler? = null + private var anrHandler: AnrSupervisor? = null + internal lateinit var devIdentifier: String + private set + internal val session = Session() + internal lateinit var appPackageName: String + + internal fun initialize(context: Context, identifier: String) { + appPackageName = context.packageName + crashHandler = CrashHandler(context) + anrHandler = AnrSupervisor().apply { start() } + devIdentifier = identifier + Thread.setDefaultUncaughtExceptionHandler(crashHandler) + } + + /** + * The threshold for main thread response + * time before resulting in ANR. + */ + var mainThreadResponseThreshold = DEFAULT_MAIN_THREAD_RESPONSE_THRESHOLD + + fun setExceptionHandler(uncaughtExceptionHandler: Thread.UncaughtExceptionHandler) { + this.crashHandler?.let { + it.setExceptionHandler(uncaughtExceptionHandler) + return + } + throw IllegalStateException("UncaughtExceptionHandler cannot be set as Pluto is not initialised.") + } + + fun setANRHandler(anrHandler: UncaughtANRHandler) { + this.anrHandler?.let { + it.setListener(anrHandler) + return + } + throw IllegalStateException("UncaughtANRHandler cannot be set as Pluto is not initialised.") + } + + internal fun clear() { + ExceptionDBHandler.flush() + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/PlutoExceptionsPlugin.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/PlutoExceptionsPlugin.kt new file mode 100644 index 000000000..3a8dd0e85 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/PlutoExceptionsPlugin.kt @@ -0,0 +1,44 @@ +package com.pluto.plugins.exceptions + +import androidx.fragment.app.Fragment +import com.pluto.plugin.DeveloperDetails +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginConfiguration +import com.pluto.plugins.exceptions.internal.BaseFragment +import com.pluto.plugins.exceptions.internal.persistence.ExceptionDBHandler + +class PlutoExceptionsPlugin() : Plugin(ID) { + + @SuppressWarnings("UnusedPrivateMember") + @Deprecated("Use the default constructor PlutoExceptionsPlugin() instead.") + constructor(identifier: String) : this() + + override fun getConfig() = PluginConfiguration( + name = context.getString(R.string.pluto_excep___plugin_name), + icon = R.drawable.pluto_excep___ic_plugin_logo, + version = BuildConfig.VERSION_NAME + ) + + override fun getView(): Fragment = BaseFragment() + + override fun getDeveloperDetails(): DeveloperDetails { + return DeveloperDetails( + website = "https://androidpluto.com", + vcsLink = "https://github.com/androidPluto/pluto", + twitter = "https://twitter.com/android_pluto" + ) + } + + override fun onPluginDataCleared() { + PlutoExceptions.clear() + } + + override fun onPluginInstalled() { + ExceptionDBHandler.initialize(context) + PlutoExceptions.initialize(context, ID) + } + + companion object { + const val ID = "exceptions" + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/UncaughtANRHandler.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/UncaughtANRHandler.kt new file mode 100644 index 000000000..ed716d5da --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/UncaughtANRHandler.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.exceptions + +fun interface UncaughtANRHandler { + fun uncaughtANR(thread: Thread, exception: ANRException) +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/BaseFragment.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/BaseFragment.kt new file mode 100644 index 000000000..1ad2053ae --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/BaseFragment.kt @@ -0,0 +1,6 @@ +package com.pluto.plugins.exceptions.internal + +import androidx.fragment.app.Fragment +import com.pluto.plugins.exceptions.R + +class BaseFragment : Fragment(R.layout.pluto_excep___fragment_base) diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/DataModel.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/DataModel.kt new file mode 100644 index 000000000..85014f091 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/DataModel.kt @@ -0,0 +1,182 @@ +package com.pluto.plugins.exceptions.internal + +import android.content.Context +import androidx.annotation.Keep +import com.pluto.plugins.exceptions.BuildConfig +import com.pluto.plugins.exceptions.R +import com.pluto.plugins.exceptions.internal.extensions.getPriorityString +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.color +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.spannable.createSpan +import com.squareup.moshi.JsonClass + +@Keep +@JsonClass(generateAdapter = true) +internal data class ExceptionAllData( + val thread: ThreadData? = null, + val exception: ExceptionData, + val threadStateList: ThreadStates? = null +) + +@Keep +@JsonClass(generateAdapter = true) +internal data class ThreadData( + val id: Long, + val name: String, + val priority: Int, + val isDaemon: Boolean, + val state: String, + val group: ThreadGroupData? +) : ListItem() { + val priorityString: String = getPriorityString(priority) +} + +@Keep +@JsonClass(generateAdapter = true) +internal data class ThreadGroupData( + val name: String, + val parent: String, + val activeCount: Int +) + +@Keep +@JsonClass(generateAdapter = true) +internal data class ExceptionData( + val message: String?, + val name: String?, + val file: String?, + val lineNumber: Int, + val stackTrace: List, + val stackTraceAdditionalLineCount: Int, + val timeStamp: Long = System.currentTimeMillis(), + val isANRException: Boolean = false +) : ListItem() + +@Keep +@JsonClass(generateAdapter = true) +internal data class ThreadStates( + val states: List +) : ListItem() + +@Keep +@JsonClass(generateAdapter = true) +internal data class ProcessThread( + val name: String, + val state: String, + val stackTrace: List +) : ListItem() + +@Keep +@JsonClass(generateAdapter = true) +internal data class DeviceInfo( + val appVersionName: String, + val appVersionCode: Long, + val androidOs: String?, + val androidAPILevel: String?, + val isRooted: Boolean, + val buildBrand: String?, + val buildModel: String?, + val screenHeightPx: Int, + val screenWidthPx: Int, + val screenDensityDpi: Int, + val screenSizeInch: Double, + val screenOrientation: String +) : ListItem() + +internal fun Throwable.asExceptionData(isANR: Boolean = false): ExceptionData { + val truncatedStackTrace = stackTrace.asStringArray() + return ExceptionData( + name = this.toString().replace(": $message", "", true), + message = message, + stackTrace = truncatedStackTrace, + stackTraceAdditionalLineCount = stackTrace.size - truncatedStackTrace.size, + file = stackTrace.getOrNull(0)?.fileName, + lineNumber = stackTrace.getOrNull(0)?.lineNumber ?: Int.MIN_VALUE, + isANRException = isANR + ) +} + +internal fun Array.asStringArray(stackTraceSize: Int = STACK_TRACE_LENGTH): ArrayList { + val array = arrayListOf() + take(stackTraceSize).forEach { + if (it.isNativeMethod) { + array.add("${it.className}.${it.methodName}(Native Method)") + } else { + array.add("${it.className}.${it.methodName}(${it.fileName}:${it.lineNumber})") + } + } + return array +} + +internal fun Thread.asThreadData(): ThreadData { + return ThreadData( + id = id, + name = name, + isDaemon = isDaemon, + state = state.name, + group = threadGroup.convert(), + priority = priority + ) +} + +internal fun Device.asDeviceInfo(): DeviceInfo { + return DeviceInfo( + appVersionName = app.version.name, + appVersionCode = app.version.code, + androidOs = software.androidOs, + androidAPILevel = software.androidAPILevel, + isRooted = software.isRooted, + buildBrand = build.brand, + buildModel = build.model, + screenHeightPx = screen.heightPx, + screenWidthPx = screen.widthPx, + screenDensityDpi = screen.density, + screenSizeInch = screen.sizeInches, + screenOrientation = screen.orientation + ) +} + +private fun ThreadGroup?.convert(): ThreadGroupData? { + this?.let { + ThreadGroupData( + name = it.name, + parent = it.parent.name, + activeCount = it.activeCount() + ) + } + return null +} + +internal fun getStateStringSpan(context: Context, state: String): CharSequence { + return context.createSpan { + append( + bold( + fontColor( + state.uppercase(), + context.color( + when (state) { + Thread.State.BLOCKED.name -> com.pluto.plugin.R.color.pluto___red_dark + Thread.State.WAITING.name -> com.pluto.plugin.R.color.pluto___orange + else -> com.pluto.plugin.R.color.pluto___text_dark_80 + } + ) + ) + ) + ) + } +} + +@Keep +@JsonClass(generateAdapter = true) +data class ReportData( + val message: String?, + val name: String?, + val stackTrace: List, + val client: String?, + val gitSha: String = BuildConfig.GIT_SHA, + val version: String = BuildConfig.VERSION_NAME, + val buildType: String = BuildConfig.BUILD_TYPE +) + +internal const val STACK_TRACE_LENGTH = 25 diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/Session.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/Session.kt new file mode 100644 index 000000000..723d74487 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/Session.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.exceptions.internal + +class Session { + var lastSearchText: String? = null +} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/anrs/AnrSupervisor.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/anr/AnrSupervisor.kt similarity index 81% rename from pluto/src/main/java/com/mocklets/pluto/modules/exceptions/anrs/AnrSupervisor.kt rename to pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/anr/AnrSupervisor.kt index 00d37a9cd..d4ec84956 100644 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/anrs/AnrSupervisor.kt +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/anr/AnrSupervisor.kt @@ -1,6 +1,6 @@ -package com.mocklets.pluto.modules.exceptions.anrs +package com.pluto.plugins.exceptions.internal.anr -import com.mocklets.pluto.modules.exceptions.ANRListener +import com.pluto.plugins.exceptions.UncaughtANRHandler import java.util.concurrent.Executors /** @@ -41,14 +41,14 @@ internal class AnrSupervisor { mSupervisor.stop() } - fun setListener(listener: ANRListener) { - mSupervisor.setListener(listener) + fun setListener(handler: UncaughtANRHandler) { + mSupervisor.setListener(handler) } companion object { const val LOGTAG = "Pluto-ANR-watcher" const val ANR_WATCHER_THREAD_NAME = "pluto-anr-watcher" const val ANR_WATCHER_TIMEOUT: Long = 10_000 - const val MAIN_THREAD_RESPONSE_THRESHOLD: Long = 2_000 + const val DEFAULT_MAIN_THREAD_RESPONSE_THRESHOLD: Long = 2_000 } } diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/anr/AnrSupervisorCallback.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/anr/AnrSupervisorCallback.kt new file mode 100644 index 000000000..6a1ec3792 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/anr/AnrSupervisorCallback.kt @@ -0,0 +1,26 @@ +package com.pluto.plugins.exceptions.internal.anr + +import com.pluto.plugins.exceptions.internal.extensions.notifyAll + +/** + * A [Runnable] which calls [.notifyAll] when run. + */ +internal class AnrSupervisorCallback : Runnable { + /** + * Returns whether [.run] was called yet + * + * @return true if called, false if not + */ + /** + * Flag storing whether [.run] was called + */ + @get:Synchronized + var isCalled = false + private set + + @Synchronized + override fun run() { + isCalled = true + this.notifyAll() + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/anr/AnrSupervisorRunnable.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/anr/AnrSupervisorRunnable.kt new file mode 100644 index 000000000..6888efc58 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/anr/AnrSupervisorRunnable.kt @@ -0,0 +1,132 @@ +package com.pluto.plugins.exceptions.internal.anr + +import android.os.Handler +import android.os.Looper +import com.pluto.plugins.exceptions.ANRException +import com.pluto.plugins.exceptions.PlutoExceptions +import com.pluto.plugins.exceptions.UncaughtANRHandler +import com.pluto.plugins.exceptions.internal.ExceptionAllData +import com.pluto.plugins.exceptions.internal.ThreadStates +import com.pluto.plugins.exceptions.internal.anr.AnrSupervisor.Companion.ANR_WATCHER_THREAD_NAME +import com.pluto.plugins.exceptions.internal.anr.AnrSupervisor.Companion.ANR_WATCHER_TIMEOUT +import com.pluto.plugins.exceptions.internal.anr.AnrSupervisor.Companion.LOGTAG +import com.pluto.plugins.exceptions.internal.asExceptionData +import com.pluto.plugins.exceptions.internal.asThreadData +import com.pluto.plugins.exceptions.internal.extensions.wait +import com.pluto.plugins.exceptions.internal.persistence.ExceptionDBHandler +import com.pluto.utilities.DebugLog + +/** + * A [Runnable] testing the UI thread every 10s until [ ][.stop] is called + */ +internal class AnrSupervisorRunnable : Runnable { + /** + * The [Handler] to access the UI threads message queue + */ + private val mHandler = Handler(Looper.getMainLooper()) + private var anrHandler: UncaughtANRHandler? = null + + /** + * The stop flag + */ + private var mStopped = false + /** + * Returns whether the stop is completed + * + * @return true if stop is completed, false if not + */ + /** + * Flag indicating the stop was performed + */ + @get:Synchronized + var isStopped = true + private set + + override fun run() { + Thread.currentThread().name = ANR_WATCHER_THREAD_NAME + isStopped = false + + while (!Thread.interrupted()) { + try { + DebugLog.d(LOGTAG, "Check for ANR...") + + // Create new callback + val callback = AnrSupervisorCallback() + + // Perform test, Handler should run the callback within 1s + synchronized(callback) { + mHandler.post(callback) + callback.wait(PlutoExceptions.mainThreadResponseThreshold) + + // Check if called + if (!callback.isCalled) { + val e = ANRException(mHandler.looper.thread) + anrHandler?.uncaughtANR(mHandler.looper.thread, e) + persistException(mHandler.looper.thread, e) +// ExceptionDBHandler.persist(e.asExceptionData()) + // todo save exception to db + // Pluto.exceptionRepo.saveANR(e) + /** Wait until the thread responds again */ + callback.wait() + } else { + DebugLog.d(LOGTAG, "UI Thread responded within 1s") + } + } + // Check if stopped + checkStopped() + /** Sleep for next test */ + Thread.sleep(ANR_WATCHER_TIMEOUT) + } catch (e: InterruptedException) { + break + } + } + + // Set stop completed flag + isStopped = true + DebugLog.d(LOGTAG, "ANR supervision stopped") + } + + private fun persistException(thread: Thread, exception: ANRException) { + ExceptionDBHandler.persist( + timestamp = System.currentTimeMillis(), + exception = ExceptionAllData( + thread = thread.asThreadData(), + exception = exception.asExceptionData(isANR = true), + threadStateList = ThreadStates(exception.threadStateList) + ) + ) + } + + @Synchronized + @Throws(InterruptedException::class) + private fun checkStopped() { + if (mStopped) { + Thread.sleep(PlutoExceptions.mainThreadResponseThreshold) + if (mStopped) { + throw InterruptedException() + } + } + } + + /** + * Stops the check + */ + @Synchronized + fun stop() { + DebugLog.d(LOGTAG, "Stopping...") + mStopped = true + } + + /** + * Stops the check + */ + @Synchronized + fun unstop() { + DebugLog.d(LOGTAG, "Revert stopping...") + mStopped = false + } + + fun setListener(handler: UncaughtANRHandler) { + anrHandler = handler + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/crash/CrashHandler.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/crash/CrashHandler.kt new file mode 100644 index 000000000..ae7c763cd --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/crash/CrashHandler.kt @@ -0,0 +1,20 @@ +package com.pluto.plugins.exceptions.internal.crash + +import android.content.Context +import com.pluto.plugins.exceptions.internal.persistence.ExceptionDBHandler + +internal class CrashHandler(context: Context) : Thread.UncaughtExceptionHandler { + + private var handler: Thread.UncaughtExceptionHandler? = Thread.getDefaultUncaughtExceptionHandler() + private val crashNotification: CrashNotification = CrashNotification(context) + + override fun uncaughtException(t: Thread, e: Throwable) { + ExceptionDBHandler.tempPersist(e, t) + crashNotification.add() + handler?.uncaughtException(t, e) + } + + internal fun setExceptionHandler(handler: Thread.UncaughtExceptionHandler) { + this.handler = handler + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/crash/CrashNotification.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/crash/CrashNotification.kt new file mode 100644 index 000000000..ff5b100cb --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/crash/CrashNotification.kt @@ -0,0 +1,69 @@ +package com.pluto.plugins.exceptions.internal.crash + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import com.pluto.plugin.libinterface.PlutoInterface +import com.pluto.plugins.exceptions.PlutoExceptions +import com.pluto.plugins.exceptions.R +import com.pluto.utilities.device.Device + +internal class CrashNotification(private val context: Context) { + + private val manager: NotificationManager? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + context.getSystemService(NotificationManager::class.java) + } else { + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? + } + private val device = Device(context) + + fun add() { + createChannel() + val notification: Notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentTitle(context.getString(R.string.pluto_excep___notification_title, device.app.name)) + .setContentText(context.getString(R.string.pluto_excep___notification_subtitle)) + .setSmallIcon(R.drawable.pluto_excep___ic_plugin_logo) + .setContentIntent(PlutoInterface.notification.getPendingIntent(PlutoExceptions.devIdentifier)) + .setOngoing(false) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setSilent(true) + .setSound(null) + .build() + manager?.notify(NOTIFICATION_ID, notification) + } + + fun remove() { + manager?.cancel(NOTIFICATION_ID) + } + + private fun createNotificationChannel(channel: NotificationChannel) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager?.createNotificationChannel(channel) + } + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ) + channel.setShowBadge(false) + createNotificationChannel(channel) + } + } + + companion object { + const val NOTIFICATION_ID = 10_001 + const val CHANNEL_ID = "pluto_notifications" + const val GROUP_ID = "pluto_notifications_group" + const val CHANNEL_NAME = "Pluto Notifications" + } +} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/ConcurrencyKtx.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/extensions/ConcurrencyKtx.kt similarity index 79% rename from pluto/src/main/java/com/mocklets/pluto/core/extensions/ConcurrencyKtx.kt rename to pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/extensions/ConcurrencyKtx.kt index e1dd839d9..c29ae8125 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/ConcurrencyKtx.kt +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/extensions/ConcurrencyKtx.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.extensions +package com.pluto.plugins.exceptions.internal.extensions @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") internal fun Any.wait(timeout: Long = 0) = (this as Object).wait(timeout) diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/extensions/ThreadKtx.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/extensions/ThreadKtx.kt new file mode 100644 index 000000000..822ed3379 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/extensions/ThreadKtx.kt @@ -0,0 +1,10 @@ +package com.pluto.plugins.exceptions.internal.extensions + +import com.pluto.utilities.extensions.capitalizeText + +fun getPriorityString(priority: Int) = + when (priority) { + Thread.MAX_PRIORITY -> "maximum" + Thread.MIN_PRIORITY -> "minimum" + else -> "normal" + }.capitalizeText() diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/EntityConverters.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/EntityConverters.kt new file mode 100644 index 000000000..bdf1fce62 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/EntityConverters.kt @@ -0,0 +1,36 @@ +package com.pluto.plugins.exceptions.internal.persistence + +import androidx.room.TypeConverter +import com.pluto.plugins.exceptions.internal.DeviceInfo +import com.pluto.plugins.exceptions.internal.ExceptionAllData +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi + +internal class EntityConverters { + + private var moshi = Moshi.Builder().build() + private var exceptionMoshiAdapter: JsonAdapter = moshi.adapter(ExceptionAllData::class.java) + private var deviceMoshiAdapter: JsonAdapter = moshi.adapter(DeviceInfo::class.java) + + @TypeConverter + fun stringToException(data: String?): ExceptionAllData? { + data?.let { return exceptionMoshiAdapter.fromJson(data) } + return null + } + + @TypeConverter + fun exceptionToString(data: ExceptionAllData): String { + return exceptionMoshiAdapter.toJson(data) + } + + @TypeConverter + fun stringToDevice(data: String?): DeviceInfo? { + data?.let { return deviceMoshiAdapter.fromJson(data) } + return null + } + + @TypeConverter + fun deviceToString(data: DeviceInfo): String { + return deviceMoshiAdapter.toJson(data) + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/ExceptionDBHandler.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/ExceptionDBHandler.kt new file mode 100644 index 000000000..e7e53c5c7 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/ExceptionDBHandler.kt @@ -0,0 +1,81 @@ +package com.pluto.plugins.exceptions.internal.persistence + +import android.content.Context +import androidx.annotation.Keep +import com.pluto.plugins.exceptions.internal.DeviceInfo +import com.pluto.plugins.exceptions.internal.ExceptionAllData +import com.pluto.plugins.exceptions.internal.asDeviceInfo +import com.pluto.plugins.exceptions.internal.asExceptionData +import com.pluto.plugins.exceptions.internal.asThreadData +import com.pluto.plugins.exceptions.internal.persistence.database.DatabaseManager +import com.pluto.utilities.device.Device +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal object ExceptionDBHandler { + + private lateinit var exceptionDao: ExceptionDao + private val coroutineScope = CoroutineScope(Dispatchers.IO) + private lateinit var deviceInfo: DeviceInfo + private lateinit var preferences: Preferences + private var moshi = Moshi.Builder().build() + private var moshiAdapter: JsonAdapter = moshi.adapter(ExceptionTempData::class.java) + + fun initialize(context: Context) { + deviceInfo = Device(context).asDeviceInfo() + exceptionDao = DatabaseManager(context).db.exceptionDao() + preferences = Preferences(context) + checkAndSaveCrash() + } + + private fun checkAndSaveCrash() { + preferences.lastSessionCrash?.let { + moshiAdapter.fromJson(it)?.let { exception -> + persist(exception.exception, exception.timestamp) { + preferences.lastSessionCrash = null + } + } + } + } + + fun persist(exception: ExceptionAllData, timestamp: Long, onSuccess: (() -> Unit)? = null) { + coroutineScope.launch { + exceptionDao.save( + ExceptionEntity( + timestamp = timestamp, + data = exception, + device = deviceInfo + ) + ) + onSuccess?.invoke() + } + } + + fun flush() { + coroutineScope.launch { + exceptionDao.deleteAll() + } + } + + fun tempPersist(exception: Throwable, thread: Thread) { + val exceptionData = ExceptionTempData( + exception = ExceptionAllData( + thread = thread.asThreadData(), + exception = exception.asExceptionData() + ), + timestamp = System.currentTimeMillis() + ) + preferences.lastSessionCrash = moshiAdapter.toJson(exceptionData) + } +} + +@Keep +@JsonClass(generateAdapter = true) +internal data class ExceptionTempData( + val timestamp: Long, + val exception: ExceptionAllData +) diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/dao/ExceptionDao.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/ExceptionDao.kt similarity index 91% rename from pluto/src/main/java/com/mocklets/pluto/modules/exceptions/dao/ExceptionDao.kt rename to pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/ExceptionDao.kt index d1140796c..da2d3f308 100644 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/dao/ExceptionDao.kt +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/ExceptionDao.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.modules.exceptions.dao +package com.pluto.plugins.exceptions.internal.persistence import androidx.room.Dao import androidx.room.Insert diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/ExceptionEntity.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/ExceptionEntity.kt new file mode 100644 index 000000000..f26f9bd1f --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/ExceptionEntity.kt @@ -0,0 +1,27 @@ +package com.pluto.plugins.exceptions.internal.persistence + +import androidx.annotation.Keep +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.pluto.plugins.exceptions.internal.DeviceInfo +import com.pluto.plugins.exceptions.internal.ExceptionAllData +import com.pluto.utilities.list.ListItem +import com.squareup.moshi.JsonClass + +@Keep +@JsonClass(generateAdapter = true) +@TypeConverters(EntityConverters::class) +@Entity(tableName = "exceptions") +internal data class ExceptionEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Int? = null, + @ColumnInfo(name = "timestamp") + val timestamp: Long, + @ColumnInfo(name = "data") + val data: ExceptionAllData, + @ColumnInfo(name = "device") + val device: DeviceInfo +) : ListItem() diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/Preferences.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/Preferences.kt new file mode 100644 index 000000000..eb3cff45f --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/Preferences.kt @@ -0,0 +1,23 @@ +package com.pluto.plugins.exceptions.internal.persistence + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences + +@SuppressWarnings("UseDataClass") +internal class Preferences(context: Context) { + + private val statePrefs: SharedPreferences = context.getSharedPreferences("_pluto_pref_exception", Context.MODE_PRIVATE) + + internal var lastSessionCrash: String? + get() = statePrefs.getString(LAST_SESSION_CRASH, null) + @SuppressLint("ApplySharedPref") + /* added commit, as apply() is getting missed on crash */ + set(value) { + statePrefs.edit().putString(LAST_SESSION_CRASH, value).commit() + } + + private companion object { + const val LAST_SESSION_CRASH = "last_session_crash" + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/database/DatabaseManager.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/database/DatabaseManager.kt new file mode 100644 index 000000000..01e49c54a --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/database/DatabaseManager.kt @@ -0,0 +1,18 @@ +package com.pluto.plugins.exceptions.internal.persistence.database + +import android.content.Context +import androidx.room.Room + +internal class DatabaseManager(context: Context) { + + val db by lazy { + Room.databaseBuilder(context, PlutoDatabase::class.java, DATABASE_NAME) + .addMigrations() + .fallbackToDestructiveMigration() + .build() + } + + companion object { + private const val DATABASE_NAME = "_pluto_exception_database" + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/database/PlutoDatabase.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/database/PlutoDatabase.kt new file mode 100644 index 000000000..f65b39d91 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/persistence/database/PlutoDatabase.kt @@ -0,0 +1,16 @@ +package com.pluto.plugins.exceptions.internal.persistence.database + +import androidx.room.RoomDatabase +import com.pluto.plugins.exceptions.internal.persistence.ExceptionDao +import com.pluto.plugins.exceptions.internal.persistence.ExceptionEntity + +@androidx.room.Database( + entities = [ + ExceptionEntity::class, + ], + version = 4, + exportSchema = false +) +internal abstract class PlutoDatabase : RoomDatabase() { + abstract fun exceptionDao(): ExceptionDao +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/CrashesAdapter.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/CrashesAdapter.kt new file mode 100644 index 000000000..362843be0 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/CrashesAdapter.kt @@ -0,0 +1,48 @@ +package com.pluto.plugins.exceptions.internal.ui + +import android.view.ViewGroup +import com.pluto.plugins.exceptions.internal.DeviceInfo +import com.pluto.plugins.exceptions.internal.ExceptionData +import com.pluto.plugins.exceptions.internal.ThreadData +import com.pluto.plugins.exceptions.internal.ThreadStates +import com.pluto.plugins.exceptions.internal.persistence.ExceptionEntity +import com.pluto.plugins.exceptions.internal.ui.holder.CrashItemDetailsDeviceHolder +import com.pluto.plugins.exceptions.internal.ui.holder.CrashItemDetailsHeaderHolder +import com.pluto.plugins.exceptions.internal.ui.holder.CrashItemDetailsThreadHolder +import com.pluto.plugins.exceptions.internal.ui.holder.CrashItemDetailsThreadStackThreadHolder +import com.pluto.plugins.exceptions.internal.ui.holder.CrashItemHolder +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class CrashesAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is ExceptionEntity -> ITEM_TYPE_CRASH + is ExceptionData -> ITEM_DETAILS_TYPE_HEADER + is ThreadData -> ITEM_DETAILS_TYPE_THREAD + is DeviceInfo -> ITEM_DETAILS_TYPE_DEVICE + is ThreadStates -> ITEM_DETAILS_TYPE_THREAD_STATES + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_CRASH -> CrashItemHolder(parent, listener) + ITEM_DETAILS_TYPE_HEADER -> CrashItemDetailsHeaderHolder(parent, listener) + ITEM_DETAILS_TYPE_THREAD -> CrashItemDetailsThreadHolder(parent, listener) + ITEM_DETAILS_TYPE_DEVICE -> CrashItemDetailsDeviceHolder(parent, listener) + ITEM_DETAILS_TYPE_THREAD_STATES -> CrashItemDetailsThreadStackThreadHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_CRASH = 1000 + const val ITEM_DETAILS_TYPE_HEADER = 1100 + const val ITEM_DETAILS_TYPE_THREAD = 1101 + const val ITEM_DETAILS_TYPE_DEVICE = 1102 + const val ITEM_DETAILS_TYPE_THREAD_STATES = 1103 + } +} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashesViewModel.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/CrashesViewModel.kt similarity index 83% rename from pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashesViewModel.kt rename to pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/CrashesViewModel.kt index 26f03c9a0..7993bb504 100644 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashesViewModel.kt +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/CrashesViewModel.kt @@ -1,13 +1,13 @@ -package com.mocklets.pluto.modules.exceptions.ui +package com.pluto.plugins.exceptions.internal.ui import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import com.mocklets.pluto.core.database.DatabaseManager -import com.mocklets.pluto.modules.exceptions.dao.ExceptionDao -import com.mocklets.pluto.modules.exceptions.dao.ExceptionEntity +import com.pluto.plugins.exceptions.internal.persistence.ExceptionDao +import com.pluto.plugins.exceptions.internal.persistence.ExceptionEntity +import com.pluto.plugins.exceptions.internal.persistence.database.DatabaseManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -15,10 +15,6 @@ internal class CrashesViewModel(application: Application) : AndroidViewModel(app private val exceptionDao: ExceptionDao by lazy { DatabaseManager(application.applicationContext).db.exceptionDao() } - init { - fetchAll() - } - val exceptions: LiveData> get() = _exceptions private val _exceptions = MutableLiveData>() @@ -27,7 +23,7 @@ internal class CrashesViewModel(application: Application) : AndroidViewModel(app get() = _currentException private val _currentException = MutableLiveData() - private fun fetchAll() { + fun fetchAll() { viewModelScope.launch(Dispatchers.IO) { val list = exceptionDao.fetchAll() ?: arrayListOf() _exceptions.postValue(list) diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/DetailsFragment.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/DetailsFragment.kt new file mode 100644 index 000000000..bc4b203ea --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/DetailsFragment.kt @@ -0,0 +1,167 @@ +package com.pluto.plugins.exceptions.internal.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Base64.DEFAULT +import android.util.Base64.encodeToString +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.exceptions.PlutoExceptions +import com.pluto.plugins.exceptions.R +import com.pluto.plugins.exceptions.databinding.PlutoExcepFragmentDetailsBinding +import com.pluto.plugins.exceptions.internal.ReportData +import com.pluto.plugins.exceptions.internal.persistence.ExceptionEntity +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.capitalizeText +import com.pluto.utilities.extensions.delayedLaunchWhenResumed +import com.pluto.utilities.extensions.onBackPressed +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import java.net.URLEncoder + +internal class DetailsFragment : Fragment(R.layout.pluto_excep___fragment_details) { + + private val binding by viewBinding(PlutoExcepFragmentDetailsBinding::bind) + private val viewModel: CrashesViewModel by activityViewModels() + private val crashAdapter: BaseAdapter by autoClearInitializer { CrashesAdapter(onActionListener) } + private val exceptionCipher by lazy { getCipheredException() } + private val contentSharer by lazyContentSharer() + + private var moshi = Moshi.Builder().build() + private var moshiAdapter: JsonAdapter = moshi.adapter(ReportData::class.java) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBackPressed { findNavController().navigateUp() } + binding.list.apply { + adapter = crashAdapter + } + binding.close.setOnDebounceClickListener { + activity?.onBackPressed() + } + + binding.delete.setOnDebounceClickListener { + viewModel.currentException.value?.id?.let { id -> + lifecycleScope.delayedLaunchWhenResumed(SCREEN_CLOSE_DELAY) { + viewModel.delete(id) + activity?.onBackPressed() + context?.toast("Crash logs deleted.") + } + } + } + + binding.share.setOnDebounceClickListener { + viewModel.currentException.value?.let { + contentSharer.share(Shareable(title = "Share Crash Report", content = it.toShareText(), fileName = "Crash Report from Pluto")) + } + } + + viewModel.currentException.removeObservers(viewLifecycleOwner) + viewModel.currentException.observe(viewLifecycleOwner, exceptionObserver) + } + + private fun getCipheredException(): String? { + viewModel.currentException.value?.data?.exception?.let { + val reportData = ReportData( + name = it.name, + message = it.message, + stackTrace = it.stackTrace.take(STACK_TRACE_SHORT_LENGTH) as ArrayList, + client = PlutoExceptions.appPackageName + ) + val exceptionString = moshiAdapter.toJson(reportData) + + val encodedByteArray = exceptionString.toByteArray(Charsets.UTF_8) + val encodedString = encodeToString(encodedByteArray, DEFAULT) + + return URLEncoder.encode(encodedString, "utf-8") + } + return null + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + when (action) { + "report_crash" -> exceptionCipher?.let { + val url = "https://androidpluto.com/exception/$it/a0bbe9cd-2f02-4a12-b7b7-36fce61a6b48" + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(browserIntent) + } + "thread_stack_trace" -> findNavController().navigate(R.id.openStackTrace) + } + } + } + + private val exceptionObserver = Observer { + val list = arrayListOf() + list.add(it.data.exception) + it.data.threadStateList?.let { states -> list.add(states) } + it.data.thread?.let { thread -> list.add(thread) } + list.add(it.device) + + crashAdapter.list = list + } + + private companion object { + const val SCREEN_CLOSE_DELAY = 200L + private const val STACK_TRACE_SHORT_LENGTH = 15 + } +} + +private const val SHARE_SECTION_DIVIDER = "\n\n==================\n\n" +private fun ExceptionEntity.toShareText(): String { + val text = StringBuilder() + text.append("EXCEPTION : \n") + text.append("${this.data.exception.name}: ${this.data.exception.message}\n") + this.data.exception.stackTrace.forEach { + text.append("\t at $it\n") + } + if (this.data.exception.stackTraceAdditionalLineCount > 0) { + text.append("\t + ${this.data.exception.stackTraceAdditionalLineCount} more lines\n\n") + } + + text.append(SHARE_SECTION_DIVIDER) + + this.data.thread?.let { + text.append("Thread : ") + text.append("${it.name.uppercase()} (") + text.append("id : ${it.id}, ") + text.append("priority : ${it.priorityString}, ") + text.append("is_Daemon : ${it.isDaemon}, ") + text.append("state : ${it.state}") + text.append(")") + + text.append(SHARE_SECTION_DIVIDER) + } + + text.append("APP STATE : \n") + this.device.appVersionName.let { + text.append("App Version : $it (${this.device.appVersionCode})\n") + } + text.append("Android (OS : ${this.device.androidOs}, API_Level : ${this.device.androidAPILevel})\n") + text.append("Orientation : ${this.device.screenOrientation}\n") + text.append("is_Rooted : ${this.device.isRooted}") + + text.append(SHARE_SECTION_DIVIDER) + + text.append("DEVICE INFO : \n") + text.append("Model : ${this.device.buildBrand?.capitalizeText()} ${this.device.buildModel}\n") + text.append( + "Screen : { height : ${this.device.screenHeightPx}px, width : ${this.device.screenWidthPx}px, " + + "density : ${this.device.screenDensityDpi}, size : ${this.device.screenSizeInch} inches }" + ) + return text.toString() +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/ListFragment.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/ListFragment.kt new file mode 100644 index 000000000..023b86b1d --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/ListFragment.kt @@ -0,0 +1,113 @@ +package com.pluto.plugins.exceptions.internal.ui + +import android.os.Bundle +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.pluto.plugins.exceptions.PlutoExceptions +import com.pluto.plugins.exceptions.R +import com.pluto.plugins.exceptions.databinding.PlutoExcepFragmentListBinding +import com.pluto.plugins.exceptions.internal.persistence.ExceptionEntity +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.hideKeyboard +import com.pluto.utilities.extensions.linearLayoutManager +import com.pluto.utilities.extensions.showMoreOptions +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +class ListFragment : Fragment(R.layout.pluto_excep___fragment_list) { + + private val binding by viewBinding(PlutoExcepFragmentListBinding::bind) + private val viewModel: CrashesViewModel by activityViewModels() + private val crashAdapter by autoClearInitializer { + CrashesAdapter(onActionListener) + } + private var isFetchingInProgress: Boolean = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.crashList.apply { + adapter = crashAdapter + addItemDecoration(CustomItemDecorator(requireContext())) + } + binding.search.doOnTextChanged { text, _, _, _ -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + text?.toString()?.let { + PlutoExceptions.session.lastSearchText = it + crashAdapter.list = filteredLogs(it) + if (it.isEmpty()) { + binding.crashList.linearLayoutManager()?.scrollToPositionWithOffset(0, 0) + } + } + } + } + binding.search.setText(PlutoExceptions.session.lastSearchText) + binding.close.setOnDebounceClickListener { + requireActivity().finish() + } + binding.options.setOnDebounceClickListener { + context?.showMoreOptions(it, R.menu.pluto_excep___menu_more_options) { item -> + when (item.itemId) { + R.id.clear -> viewModel.deleteAll() + } + } + } + + isFetchingInProgress = true + viewModel.fetchAll() + + viewModel.exceptions.removeObserver(exceptionObserver) + viewModel.exceptions.observe(viewLifecycleOwner, exceptionObserver) + } + + private val exceptionObserver = Observer> { + isFetchingInProgress = false + crashAdapter.list = filteredLogs(binding.search.text.toString()) + } + + private fun filteredLogs(search: String): List { + var list = emptyList() + if (isFetchingInProgress) { + binding.loaderGroup.visibility = VISIBLE + } else { + binding.loaderGroup.visibility = GONE + viewModel.exceptions.value?.let { + list = it.filter { exception -> + (exception.data.exception.name ?: "").contains(search, true) || + (exception.data.exception.file ?: "").contains(search, true) + } + } + binding.noItemText.text = getString( + if (search.isNotEmpty()) R.string.pluto_excep___no_search_result else R.string.pluto_excep___no_crashes_text + ) + binding.noItemText.visibility = if (list.isEmpty()) VISIBLE else GONE + } + return list + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is ExceptionEntity) { + requireActivity().hideKeyboard() + if (data.id != null) { + viewModel.fetch(data.id) + findNavController().navigate(R.id.openDetails) + } else { + requireContext().toast(getString(R.string.pluto_excep___invalid_id)) + } + } + } + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/StackTracesAdapter.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/StackTracesAdapter.kt new file mode 100644 index 000000000..526dc829c --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/StackTracesAdapter.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.exceptions.internal.ui + +import android.view.ViewGroup +import com.pluto.plugins.exceptions.internal.ProcessThread +import com.pluto.plugins.exceptions.internal.ui.holder.StackTraceListItemHolder +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class StackTracesAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is ProcessThread -> ITEM_TYPE_THREAD_STACK_TRACE + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_THREAD_STACK_TRACE -> StackTraceListItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_THREAD_STACK_TRACE = 1201 + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/ThreadStackTraceFragment.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/ThreadStackTraceFragment.kt new file mode 100644 index 000000000..8ec0e9452 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/ThreadStackTraceFragment.kt @@ -0,0 +1,116 @@ +package com.pluto.plugins.exceptions.internal.ui + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.exceptions.R +import com.pluto.plugins.exceptions.databinding.PlutoExcepFragmentThreadStackTraceBinding +import com.pluto.plugins.exceptions.internal.ProcessThread +import com.pluto.plugins.exceptions.internal.ThreadStates +import com.pluto.plugins.exceptions.internal.persistence.ExceptionEntity +import com.pluto.plugins.exceptions.internal.ui.holder.StackTraceListItemHolder +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.onBackPressed +import com.pluto.utilities.extensions.showMoreOptions +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +class ThreadStackTraceFragment : Fragment(R.layout.pluto_excep___fragment_thread_stack_trace) { + private val binding by viewBinding(PlutoExcepFragmentThreadStackTraceBinding::bind) + private val viewModel: CrashesViewModel by activityViewModels() + private val threadAdapter: BaseAdapter by autoClearInitializer { StackTracesAdapter(onActionListener) } + private var linearLayoutManager: LinearLayoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) + private val contentSharer by lazyContentSharer() + private var filterValue: String? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBackPressed { findNavController().navigateUp() } + binding.list.apply { + adapter = threadAdapter + layoutManager = linearLayoutManager + addItemDecoration(CustomItemDecorator(requireContext())) + } + binding.close.setOnDebounceClickListener { + activity?.onBackPressed() + } + + binding.filterCta.setOnDebounceClickListener { + context?.showMoreOptions(it, R.menu.pluto_excep___menu_stack_trace_filter) { item -> + filterValue = when (item.itemId) { + R.id.filterBlocked -> getString(R.string.pluto_excep___trace_filter_blocked) + R.id.filterWaiting -> getString(R.string.pluto_excep___trace_filter_waiting) + R.id.filterTimedWaiting -> getString(R.string.pluto_excep___trace_filter_timed_waiting) + R.id.filterRunnable -> getString(R.string.pluto_excep___trace_filter_runnable) + else -> null + } + threadAdapter.list = (viewModel.currentException.value?.data?.threadStateList?.states ?: emptyList()).process(filterValue) + binding.filterCta.text = filterValue ?: getString(R.string.pluto_excep___trace_filter_all) + } + } + binding.share.setOnDebounceClickListener { + viewModel.currentException.value?.data?.threadStateList?.let { + contentSharer.share(Shareable(title = "Share Thread Stack Trace", content = it.toShareText(), fileName = "ANR Thread Stack Trace from Pluto")) + } + } + + viewModel.currentException.removeObservers(viewLifecycleOwner) + viewModel.currentException.observe(viewLifecycleOwner, exceptionObserver) + } + + private val exceptionObserver = Observer { + threadAdapter.list = (it.data.threadStateList?.states ?: emptyList()).process(filterValue) + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + } + } +} + +private fun ThreadStates.toShareText(): String { + val text = StringBuilder() + text.append("ANR Thread Stack Trace - ${states.size} threads : \n") + states.process().forEach { + text.append("\n* ${it.name}\t ${it.state.uppercase()}\n") + it.stackTrace.take(StackTraceListItemHolder.MAX_STACK_TRACE_LINES).forEach { trace -> + text.append("\tat $trace\n") + } + val extraTrace = it.stackTrace.size - StackTraceListItemHolder.MAX_STACK_TRACE_LINES + if (extraTrace > 0) { + text.append("\t +$extraTrace more lines\n") + } + } + return text.toString() +} + +private fun List.process(filterValue: String? = null): List { + val list = arrayListOf() + val mainThreadName = "main" + var mainThread: ProcessThread? = null + forEach { + if (filterValue == null || it.state.equals(filterValue, true)) { + if (it.name == mainThreadName) { + mainThread = it + } else { + list.add(it) + } + } + } + return arrayListOf().apply { + mainThread?.let { add(it) } + addAll(list) + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsDeviceHolder.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsDeviceHolder.kt new file mode 100644 index 000000000..ed06fab97 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsDeviceHolder.kt @@ -0,0 +1,111 @@ +package com.pluto.plugins.exceptions.internal.ui.holder + +import android.view.ViewGroup +import com.pluto.plugins.exceptions.R +import com.pluto.plugins.exceptions.databinding.PlutoExcepItemCrashDetailsDeviceBinding +import com.pluto.plugins.exceptions.internal.DeviceInfo +import com.pluto.utilities.extensions.capitalizeText +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.spannable.createSpan +import com.pluto.utilities.views.keyvalue.KeyValuePairData + +internal class CrashItemDetailsDeviceHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_excep___item_crash_details_device), actionListener) { + + private val binding = PlutoExcepItemCrashDetailsDeviceBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is DeviceInfo) { + setupAppDataTable(item) + setupDeviceDataTable(item) + } + } + + private fun setupAppDataTable(item: DeviceInfo) { + val dataList = arrayListOf().apply { + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___app_version_label), + value = context.createSpan { + append(semiBold(item.appVersionName)) + append(" (${item.appVersionCode})") + } + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___android_os_label), + value = item.androidOs + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___android_api_level_label), + value = item.androidAPILevel + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___orientation_label), + value = item.screenOrientation.capitalizeText() + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___rooted_label), + value = context.createSpan { + append(bold(item.isRooted.toString())) + } + ) + ) + } + binding.appDataTable.set( + title = context.getString(R.string.pluto_excep___app_state_label), + keyValuePairs = dataList + ) + } + + private fun setupDeviceDataTable(item: DeviceInfo) { + val dataList = arrayListOf().apply { + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___app_version_label), + value = "${item.buildBrand?.capitalizeText()} ${item.buildModel}" + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___height_label), + value = "${item.screenHeightPx} px" + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___width_label), + value = "${item.screenWidthPx} px" + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___density_label), + value = "${item.screenDensityDpi} dpi" + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___size_label), + value = "${item.screenSizeInch} inches" + ) + ) + } + binding.deviceDataTable.set( + title = context.getString(R.string.pluto_excep___device_label), + keyValuePairs = dataList + ) + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsHeaderHolder.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsHeaderHolder.kt new file mode 100644 index 000000000..a02ea6578 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsHeaderHolder.kt @@ -0,0 +1,103 @@ +package com.pluto.plugins.exceptions.internal.ui.holder + +import android.view.ViewGroup +import com.pluto.plugins.exceptions.PlutoExceptions +import com.pluto.plugins.exceptions.R +import com.pluto.plugins.exceptions.databinding.PlutoExcepItemCrashDetailsHeaderBinding +import com.pluto.plugins.exceptions.internal.ExceptionData +import com.pluto.utilities.extensions.asFormattedDate +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.spannable.setSpan + +internal class CrashItemDetailsHeaderHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_excep___item_crash_details_header), actionListener) { + + private val binding = PlutoExcepItemCrashDetailsHeaderBinding.bind(itemView) + private val timestamp = binding.timestamp + private val stacktrace = binding.stackTrace +// private val reportCrash = binding.reportCrash.crashReportRoot + private val message = binding.message + private val title = binding.title + + override fun onBind(item: ListItem) { + if (item is ExceptionData) { + handleTitle(item) + timestamp.text = item.timeStamp.asFormattedDate() + + stacktrace.setSpan { + append("${item.name}: ${item.message}") + item.stackTrace.forEach { + append("\n\t\t\t") + append( + fontColor( + " at ", context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + append(it) + } + if (item.stackTraceAdditionalLineCount > 0) { + append( + fontColor( + "\n\t\t\t + ${item.stackTraceAdditionalLineCount} more lines", context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + } + } + +// if (isPlutoCrash(item.stackTrace.take(MAX_STACK_TRACE_LINES))) { +// reportCrash.visibility = VISIBLE +// reportCrash.setDebounceClickListener { +// onAction("report_crash") +// } +// } else { +// reportCrash.visibility = GONE +// reportCrash.setDebounceClickListener {} +// } + } + } + + private fun handleTitle(item: ExceptionData) { + if (item.isANRException) { + message.text = + context.getString(R.string.pluto_excep___anr_list_message, PlutoExceptions.mainThreadResponseThreshold) + title.setSpan { + context.apply { + append( + fontColor( + getString(R.string.pluto_excep___anr_list_title), + color(com.pluto.plugin.R.color.pluto___text_dark_80) + ) + ) + } + } + } else { + title.setSpan { + append("${item.file}\t\t") + append( + fontColor("line: ${item.lineNumber}", context.color(com.pluto.plugin.R.color.pluto___text_dark_80)) + ) + } + message.setSpan { + append("${item.name}\n") + append( + fontColor("${item.message}", context.color(com.pluto.plugin.R.color.pluto___text_dark_60)) + ) + } + } + } + +// private fun isPlutoCrash(trace: List): Boolean { +// trace.forEach { +// if (it.startsWith(BuildConfig.LIBRARY_PACKAGE_NAME)) { +// return true +// } +// } +// return false +// } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsThreadHolder.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsThreadHolder.kt new file mode 100644 index 000000000..dacda981b --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsThreadHolder.kt @@ -0,0 +1,71 @@ +package com.pluto.plugins.exceptions.internal.ui.holder + +import android.view.ViewGroup +import com.pluto.plugins.exceptions.R +import com.pluto.plugins.exceptions.databinding.PlutoExcepItemCrashDetailsThreadBinding +import com.pluto.plugins.exceptions.internal.ThreadData +import com.pluto.plugins.exceptions.internal.getStateStringSpan +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.spannable.createSpan +import com.pluto.utilities.views.keyvalue.KeyValuePairData + +internal class CrashItemDetailsThreadHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_excep___item_crash_details_thread), actionListener) { + + private val binding = PlutoExcepItemCrashDetailsThreadBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is ThreadData) { + setupTabularData(item) + } + } + + private fun setupTabularData(item: ThreadData) { + val dataList = arrayListOf().apply { + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___thread_name_label), + value = context.createSpan { + append(semiBold("${item.name.uppercase()}\t")) + append(fontColor("(", context.color(com.pluto.plugin.R.color.pluto___text_dark_60))) + append(fontColor("id: ", context.color(com.pluto.plugin.R.color.pluto___text_dark_60))) + append(bold(fontColor("${item.id}", context.color(com.pluto.plugin.R.color.pluto___text_dark_60)))) + append(fontColor(")", context.color(com.pluto.plugin.R.color.pluto___text_dark_60))) + } + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___priority_label), + value = context.createSpan { + append(item.priorityString) + } + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___daemon_label), + value = context.createSpan { + append(bold(item.isDaemon.toString())) + } + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_excep___thread_run_state_label), + value = getStateStringSpan(context, item.state) + ) + ) + } + binding.table.set( + title = context.getString(R.string.pluto_excep___thread_state_label), + keyValuePairs = dataList + ) + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsThreadStackThreadHolder.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsThreadStackThreadHolder.kt new file mode 100644 index 000000000..097d114f5 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemDetailsThreadStackThreadHolder.kt @@ -0,0 +1,33 @@ +package com.pluto.plugins.exceptions.internal.ui.holder + +import android.view.ViewGroup +import com.pluto.plugins.exceptions.R +import com.pluto.plugins.exceptions.databinding.PlutoExcepItemCrashDetailsThreadStackTraceBinding +import com.pluto.plugins.exceptions.internal.ThreadStates +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class CrashItemDetailsThreadStackThreadHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_excep___item_crash_details_thread_stack_trace), actionListener) { + + private val binding = PlutoExcepItemCrashDetailsThreadStackTraceBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is ThreadStates) { + binding.label.setSpan { + append(context.getString(R.string.pluto_excep___thread_stack_traces_label)) + append(fontColor(" (${item.states.size})", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + binding.root.setOnDebounceClickListener { + onAction("thread_stack_trace") + } + } + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemHolder.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemHolder.kt new file mode 100644 index 000000000..46452d49b --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/CrashItemHolder.kt @@ -0,0 +1,61 @@ +package com.pluto.plugins.exceptions.internal.ui.holder + +import android.view.ViewGroup +import com.pluto.plugins.exceptions.PlutoExceptions +import com.pluto.plugins.exceptions.R +import com.pluto.plugins.exceptions.databinding.PlutoExcepItemCrashBinding +import com.pluto.plugins.exceptions.internal.persistence.ExceptionEntity +import com.pluto.utilities.extensions.asTimeElapsed +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class CrashItemHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_excep___item_crash), actionListener) { + + private val binding = PlutoExcepItemCrashBinding.bind(itemView) + private val timeElapsed = binding.timeElapsed + + override fun onBind(item: ListItem) { + if (item is ExceptionEntity) { + with(item.data.exception) { + if (isANRException) { + binding.message.text = + context.getString(R.string.pluto_excep___anr_list_message, PlutoExceptions.mainThreadResponseThreshold) + binding.title.setSpan { + context.apply { + append( + fontColor( + getString(R.string.pluto_excep___anr_list_title), color(com.pluto.plugin.R.color.pluto___text_dark_80) + ) + ) + } + } + binding.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.pluto_excep___ic_anr_warning, 0, 0, 0) + } else { + binding.message.setSpan { + append("${item.data.exception.file}\t\t") + append( + fontColor( + "line:${item.data.exception.lineNumber}", + context.color(com.pluto.plugin.R.color.pluto___text_dark_60) + ) + ) + } + binding.title.text = item.data.exception.name + binding.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) + } + } + timeElapsed.text = item.timestamp.asTimeElapsed() + itemView.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/StackTraceListItemHolder.kt b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/StackTraceListItemHolder.kt new file mode 100644 index 000000000..162f2aecb --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/java/com/pluto/plugins/exceptions/internal/ui/holder/StackTraceListItemHolder.kt @@ -0,0 +1,52 @@ +package com.pluto.plugins.exceptions.internal.ui.holder + +import android.view.ViewGroup +import com.pluto.plugins.exceptions.R +import com.pluto.plugins.exceptions.databinding.PlutoExcepItemCrashDetailsThreadStackTraceListBinding +import com.pluto.plugins.exceptions.internal.ProcessThread +import com.pluto.plugins.exceptions.internal.getStateStringSpan +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.spannable.setSpan + +internal class StackTraceListItemHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_excep___item_crash_details_thread_stack_trace_list), actionListener) { + + private val binding = PlutoExcepItemCrashDetailsThreadStackTraceListBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is ProcessThread) { + binding.thread.text = item.name + binding.thread.setCompoundDrawablesWithIntrinsicBounds( + if (item.name == "main") R.drawable.pluto_excep___ic_main_thread else R.drawable.pluto_excep___ic_non_main_thread, + 0, 0, 0 + ) + binding.thread.compoundDrawablePadding = 8f.dp.toInt() + binding.threadState.text = getStateStringSpan(context, item.state) + binding.stackTrace.setSpan { + item.stackTrace.take(MAX_STACK_TRACE_LINES).forEach { + append(fontColor(" at ", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + append("$it\n") + } + val extraTrace = item.stackTrace.size - MAX_STACK_TRACE_LINES + if (extraTrace > 0) { + append( + fontColor( + "\t + $extraTrace more lines\n", context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + } + } + } + } + + companion object { + internal const val MAX_STACK_TRACE_LINES = 10 + } +} diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___bg_section_body.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___bg_section_body.xml new file mode 100644 index 000000000..03ed5cb53 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___bg_section_body.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___bg_section_header.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___bg_section_header.xml new file mode 100644 index 000000000..755f56187 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___bg_section_header.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___bg_section_light.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___bg_section_light.xml new file mode 100644 index 000000000..9277baf22 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___bg_section_light.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_anr_warning.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_anr_warning.xml new file mode 100644 index 000000000..c654e361e --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_anr_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_arrow_back.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_arrow_back.xml new file mode 100644 index 000000000..bd6a5a9cb --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_chevron_right.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_chevron_right.xml new file mode 100644 index 000000000..f04321855 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_clear_all.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_clear_all.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_clear_all.xml rename to pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_clear_all.xml diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_close.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_close.xml new file mode 100644 index 000000000..82eaa9f97 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_delete.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_delete.xml new file mode 100644 index 000000000..af232e55b --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_dropdown_cta.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_dropdown_cta.xml new file mode 100644 index 000000000..a458d77bf --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_dropdown_cta.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_error.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_error.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_error.xml rename to pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_error.xml diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_filter.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_filter.xml new file mode 100644 index 000000000..0f02e925e --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_filter.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_filter_light.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_filter_light.xml new file mode 100644 index 000000000..dcfa71067 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_filter_light.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_main_thread.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_main_thread.xml new file mode 100644 index 000000000..b335b3580 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_main_thread.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_more.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_more.xml new file mode 100644 index 000000000..e5db6edf0 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_non_main_thread.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_non_main_thread.xml new file mode 100644 index 000000000..33fd292f0 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_non_main_thread.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_plugin_logo.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_plugin_logo.xml new file mode 100644 index 000000000..70b4e7952 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_plugin_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_share.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_share.xml new file mode 100644 index 000000000..85c8262d6 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_thread_traces.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_thread_traces.xml new file mode 100644 index 000000000..96f1496d5 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/drawable/pluto_excep___ic_thread_traces.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_base.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_base.xml new file mode 100644 index 000000000..a2a5f313b --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_base.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_details.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_details.xml new file mode 100644 index 000000000..a16b3eff2 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_details.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_list.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_list.xml new file mode 100644 index 000000000..1d2b2d14f --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_list.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_thread_stack_trace.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_thread_stack_trace.xml new file mode 100644 index 000000000..cc118e56a --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___fragment_thread_stack_trace.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash.xml new file mode 100644 index 000000000..1511b3d40 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash.xml @@ -0,0 +1,61 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_device.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_device.xml new file mode 100644 index 000000000..284539713 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_device.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_header.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_header.xml new file mode 100644 index 000000000..4b4476b6d --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_header.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_thread.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_thread.xml new file mode 100644 index 000000000..227280409 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_thread.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_thread_stack_trace.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_thread_stack_trace.xml new file mode 100644 index 000000000..0c0e84fab --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_thread_stack_trace.xml @@ -0,0 +1,50 @@ + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_thread_stack_trace_list.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_thread_stack_trace_list.xml new file mode 100644 index 000000000..f374eaa3d --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___item_crash_details_thread_stack_trace_list.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___stub_crash_report.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___stub_crash_report.xml new file mode 100644 index 000000000..a86ca7b78 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/layout/pluto_excep___stub_crash_report.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/menu/pluto_excep___menu_more_options.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/menu/pluto_excep___menu_more_options.xml new file mode 100644 index 000000000..4170cc2cb --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/menu/pluto_excep___menu_more_options.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/menu/pluto_excep___menu_stack_trace_filter.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/menu/pluto_excep___menu_stack_trace_filter.xml new file mode 100644 index 000000000..da55ae642 --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/menu/pluto_excep___menu_stack_trace_filter.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/navigation/pluto_excep___navigation.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/navigation/pluto_excep___navigation.xml new file mode 100644 index 000000000..cf37bf3fd --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/navigation/pluto_excep___navigation.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/exceptions/lib/src/main/res/values/strings.xml b/pluto-plugins/plugins/exceptions/lib/src/main/res/values/strings.xml new file mode 100644 index 000000000..d5a269dfe --- /dev/null +++ b/pluto-plugins/plugins/exceptions/lib/src/main/res/values/strings.xml @@ -0,0 +1,43 @@ + + + Crashes & ANRs + Pluto detected a crash in %s + Tap here to see crash details. + No Crashes or ANRs. + Search Exceptions + Delete All Crashes + No search result found. + Exit Crash Details + App State + App Version + Android OS + Android API Level + Orientation + Is Rooted + Device Information + Model + Height + Width + Density + Screen Size + Thread State + Name + State + Priority + Is Daemon + Oops! this looks like our miss. + Tap here to report this crash to us, so that you don\'t have to face this crash again. + Invalid Crash id + Loading Crashes & ANRs + ANR Thread Stack Traces + Main thread unresponsive for +%d ms + ANR detected + Look for threads in BLOCKED state. + Filter + Showing Thread traces for + Blocked + Waiting + Timed_Waiting + Runnable + All States + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/README.md b/pluto-plugins/plugins/layout-inspector/README.md new file mode 100644 index 000000000..5502b49d9 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/README.md @@ -0,0 +1,38 @@ +## Integrate Layout Inspector Plugin in your application + +[Layout Inspector.webm](https://github.com/user-attachments/assets/ac566253-6787-4515-9cca-0d8d0a820976) + +### Add Gradle Dependencies +Pluto Layout Inspector is distributed through [***mavenCentral***](https://central.sonatype.com/artifact/com.androidpluto.plugins/layout-inspector). To use it, you need to add the following Gradle dependency to your build.gradle file of you android app module. + +> Note: add the `no-op` variant to isolate the plugin from release builds. +```groovy +dependencies { + debugImplementation "com.androidpluto.plugins:layout-inspector:$plutoVersion" + releaseImplementation "com.androidpluto.plugins:layout-inspector-no-op:$plutoVersion" +} +``` +
+ +### Install plugin to Pluto + +Now to start using the plugin, add it to Pluto +```kotlin +Pluto.Installer(this) + .addPlugin(PlutoLayoutInspectorPlugin()) + .install() +``` +
+ +πŸŽ‰  You are all done! + +Now re-build and run your app and open Pluto, you will see the Layout Inspector plugin installed. + +
+ + +### Open Plugin view programmatically +To open Layout Inspector plugin screen via code, use this +```kotlin +Pluto.open(PlutoLayoutInspectorPlugin.ID) +``` diff --git a/pluto-plugins/plugins/layout-inspector/lib-no-op/.gitignore b/pluto-plugins/plugins/layout-inspector/lib-no-op/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib-no-op/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib-no-op/build.gradle.kts b/pluto-plugins/plugins/layout-inspector/lib-no-op/build.gradle.kts new file mode 100644 index 000000000..f5ea5f2e4 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib-no-op/build.gradle.kts @@ -0,0 +1,91 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.layoutinspector" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "layout-inspector-no-op" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Layout inspector Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to modify screen layout in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { +} diff --git a/pluto-plugins/plugins/layout-inspector/lib-no-op/src/main/AndroidManifest.xml b/pluto-plugins/plugins/layout-inspector/lib-no-op/src/main/AndroidManifest.xml new file mode 100644 index 000000000..69fc41290 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib-no-op/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib-no-op/src/main/java/com/pluto/plugins/layoutinspector/PlutoLayoutInspectorPlugin.kt b/pluto-plugins/plugins/layout-inspector/lib-no-op/src/main/java/com/pluto/plugins/layoutinspector/PlutoLayoutInspectorPlugin.kt new file mode 100644 index 000000000..d6b194575 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib-no-op/src/main/java/com/pluto/plugins/layoutinspector/PlutoLayoutInspectorPlugin.kt @@ -0,0 +1,8 @@ +package com.pluto.plugins.layoutinspector + +@SuppressWarnings("UnusedPrivateMember") +class PlutoLayoutInspectorPlugin @JvmOverloads constructor(identifier: String = ID) { + companion object { + const val ID = "layout-inspector" + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/.gitignore b/pluto-plugins/plugins/layout-inspector/lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/build.gradle.kts b/pluto-plugins/plugins/layout-inspector/lib/build.gradle.kts new file mode 100644 index 000000000..d522e4c4a --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/build.gradle.kts @@ -0,0 +1,100 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.layoutinspector" + resourcePrefix = "pluto_li___" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"$verPublish\"") + buildConfigField("long", "VERSION_CODE", "$verCode") + buildConfigField("String", "GIT_SHA", "\"$verGitSHA\"") + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "layout-inspector" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Layout inspector Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to modify screen layout in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(project(":pluto-plugins:base:lib")) + + implementation(libs.androidx.core) +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/AndroidManifest.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..69fc41290 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/BaseFragment.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/BaseFragment.kt new file mode 100644 index 000000000..79d25b820 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/BaseFragment.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.layoutinspector + +import androidx.fragment.app.Fragment + +internal class BaseFragment : Fragment(R.layout.pluto_li___fragment_base) diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/PlutoLayoutInspectorPlugin.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/PlutoLayoutInspectorPlugin.kt new file mode 100644 index 000000000..f8bce1d1d --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/PlutoLayoutInspectorPlugin.kt @@ -0,0 +1,41 @@ +package com.pluto.plugins.layoutinspector + +import androidx.fragment.app.Fragment +import com.pluto.plugin.DeveloperDetails +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginConfiguration +import com.pluto.plugins.layoutinspector.internal.ActivityLifecycle + +class PlutoLayoutInspectorPlugin() : Plugin(ID) { + + @SuppressWarnings("UnusedPrivateMember") + @Deprecated("Use the default constructor PlutoLayoutInspectorPlugin() instead.") + constructor(identifier: String) : this() + + override fun getConfig() = PluginConfiguration( + name = context.getString(R.string.pluto_li___plugin_name), + icon = R.drawable.pluto_li___ic_plugin_logo, + version = BuildConfig.VERSION_NAME + ) + + override fun getView(): Fragment = BaseFragment() + + override fun getDeveloperDetails(): DeveloperDetails { + return DeveloperDetails( + website = "https://androidpluto.com", + vcsLink = "https://github.com/androidPluto/pluto", + twitter = "https://twitter.com/android_pluto" + ) + } + + override fun onPluginDataCleared() { + } + + override fun onPluginInstalled() { + application.registerActivityLifecycleCallbacks(ActivityLifecycle()) + } + + companion object { + const val ID = "layout-inspector" + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/ViewInfoFragment.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/ViewInfoFragment.kt new file mode 100644 index 000000000..20d894fc3 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/ViewInfoFragment.kt @@ -0,0 +1,137 @@ +package com.pluto.plugins.layoutinspector + +import android.os.Bundle +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.navigation.fragment.findNavController +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.pluto.plugins.layoutinspector.databinding.PlutoLiFragmentViewInfoBinding +import com.pluto.plugins.layoutinspector.internal.ActivityLifecycle +import com.pluto.plugins.layoutinspector.internal.control.ControlCta +import com.pluto.plugins.layoutinspector.internal.control.ControlsWidget +import com.pluto.plugins.layoutinspector.internal.hierarchy.ViewHierarchyFragment.Companion.SCROLL_TO_TARGET +import com.pluto.plugins.layoutinspector.internal.hint.HintFragment +import com.pluto.plugins.layoutinspector.internal.inspect.InspectViewModel +import com.pluto.plugins.layoutinspector.internal.inspect.assignTargetTag +import com.pluto.plugins.layoutinspector.internal.inspect.clearTargetTag +import com.pluto.utilities.viewBinding + +internal class ViewInfoFragment : Fragment(R.layout.pluto_li___fragment_view_info), View.OnClickListener { + + private lateinit var behavior: BottomSheetBehavior + private var targetView: View? = null + private val binding by viewBinding(PlutoLiFragmentViewInfoBinding::bind) + private val inspectViewModel: InspectViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets -> + val systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + binding.bsContainer.previewPanelBottomSheet.setPadding(0, 0, 0, systemBarsInsets.bottom) + insets + } + ActivityLifecycle.topActivity?.let { binding.operableView.tryGetFrontView(it) } + binding.operableView.setOnClickListener(this) + binding.leftControls.initialise( + listOf( + ControlCta(ID_CLOSE, R.drawable.pluto_li___ic_control_close, getString(R.string.pluto_li___cntrl_title_close)), + ControlCta(ID_HINT, R.drawable.pluto_li___ic_control_hint, getString(R.string.pluto_li___cntrl_title_hint)), + ControlCta(ID_HIERARCHY, R.drawable.pluto_li___ic_view_hierarchy, getString(R.string.pluto_li___cntrl_title_hierarchy)), + ControlCta(ID_MOVE_RIGHT, R.drawable.pluto_li___ic_control_move_right, getString(R.string.pluto_li___cntrl_title_move_to_right)) + ), + onControlCtaListener + ) + + binding.rightControls.initialise( + listOf( + ControlCta(ID_MOVE_LEFT, R.drawable.pluto_li___ic_control_move_left, getString(R.string.pluto_li___cntrl_title_move_to_left)), + ControlCta(ID_HIERARCHY, R.drawable.pluto_li___ic_view_hierarchy, getString(R.string.pluto_li___cntrl_title_hierarchy)), + ControlCta(ID_HINT, R.drawable.pluto_li___ic_control_hint, getString(R.string.pluto_li___cntrl_title_hint)), + ControlCta(ID_CLOSE, R.drawable.pluto_li___ic_control_close, getString(R.string.pluto_li___cntrl_title_close)) + ), + onControlCtaListener + ) + binding.leftControls.visibility = View.GONE + setupPreviewPanel() + + inspectViewModel.view.removeObserver(inspectRequestObserver) + inspectViewModel.view.observe(viewLifecycleOwner, inspectRequestObserver) + } + + private fun setupPreviewPanel() { + behavior = BottomSheetBehavior.from(binding.bsContainer.previewPanelBottomSheet) + behavior.state = BottomSheetBehavior.STATE_HIDDEN + } + + override fun onClick(view: View) { + clearPreviousSelection() + if (binding.operableView.isSelectedEmpty()) { + behavior.state = BottomSheetBehavior.STATE_HIDDEN + } else { + targetView = view + targetView?.assignTargetTag() + behavior.state = BottomSheetBehavior.STATE_EXPANDED + refreshViewDetails(view) + } + } + + private fun clearPreviousSelection() { + targetView?.clearTargetTag() + targetView = null + } + + private fun refreshViewDetails(view: View) { + binding.bsContainer.previewPanel.refresh( + view = view, + onViewAttrRequested = { + findNavController().navigate(R.id.openAttrView) + }, + onViewHierarchyRequested = { + val bundle = bundleOf(SCROLL_TO_TARGET to true) + findNavController().navigate(R.id.openHierarchyView, bundle) + }, + onCloseRequested = { + clearPreviousSelection() + binding.operableView.handleClick(view, true) + } + ) + } + + private val inspectRequestObserver = Observer { + binding.operableView.handleClick(it, false) + } + + private val onControlCtaListener = object : ControlsWidget.OnClickListener { + override fun onClick(id: String) { + when (id) { + ID_MOVE_RIGHT -> { + binding.leftControls.visibility = View.GONE + binding.rightControls.visibility = View.VISIBLE + } + + ID_MOVE_LEFT -> { + binding.leftControls.visibility = View.VISIBLE + binding.rightControls.visibility = View.GONE + } + + ID_HIERARCHY -> findNavController().navigate(R.id.openHierarchyView) + ID_CLOSE -> requireActivity().finish() + ID_HINT -> HintFragment().show(requireActivity().supportFragmentManager, "hint") + } + } + } + + private companion object { + const val ID_CLOSE = "close" + const val ID_HINT = "hint" + const val ID_HIERARCHY = "hierarchy" + const val ID_MOVE_LEFT = "moveToLeft" + const val ID_MOVE_RIGHT = "moveToRight" + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/ActivityLifecycle.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/ActivityLifecycle.kt new file mode 100644 index 000000000..09484db1d --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/ActivityLifecycle.kt @@ -0,0 +1,32 @@ +package com.pluto.plugins.layoutinspector.internal + +import android.app.Activity +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle +import com.pluto.plugin.libinterface.PlutoInterface + +internal class ActivityLifecycle : ActivityLifecycleCallbacks { + + companion object { + var topActivity: Activity? = null + } + + override fun onActivityStarted(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityResumed(activity: Activity) { + if (activity.javaClass != PlutoInterface.libInfo.pluginActivityClass && activity.javaClass != PlutoInterface.libInfo.selectorActivityClass) { + topActivity = activity + } + } + + override fun onActivityPaused(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) { + topActivity?.let { + if (it == activity) { + topActivity = null + } + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/ParamsPreviewPanel.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/ParamsPreviewPanel.kt new file mode 100644 index 000000000..4e1ff1bed --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/ParamsPreviewPanel.kt @@ -0,0 +1,45 @@ +package com.pluto.plugins.layoutinspector.internal + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.databinding.PlutoLiParamsPreviewPanelBinding +import com.pluto.plugins.layoutinspector.internal.inspect.getIdString +import com.pluto.utilities.extensions.color +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class ParamsPreviewPanel : ConstraintLayout { + + private val binding = PlutoLiParamsPreviewPanelBinding.inflate(LayoutInflater.from(context), this, true) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + fun refresh(view: View, onViewAttrRequested: () -> Unit, onViewHierarchyRequested: () -> Unit, onCloseRequested: () -> Unit) { + binding.viewId.setSpan { + view.getIdString()?.let { + append(it) + } ?: run { + append(regular(italic(fontColor("NO_ID", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))))) + } + } + binding.viewType.text = if (view is ViewGroup) "viewGroup" else "view" + binding.viewClass.text = view.javaClass.canonicalName + + binding.viewAttrCta.setOnDebounceClickListener { + onViewAttrRequested.invoke() + } + binding.viewHierarchyCta.setOnDebounceClickListener { + onViewHierarchyRequested.invoke() + } + binding.close.setOnDebounceClickListener { + onCloseRequested.invoke() + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/ViewAttrFragment.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/ViewAttrFragment.kt new file mode 100644 index 000000000..3a9ca17b6 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/ViewAttrFragment.kt @@ -0,0 +1,156 @@ +package com.pluto.plugins.layoutinspector.internal.attributes + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.VISIBLE +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.lifecycle.Observer +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugin.share.ContentShareViewModel +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.databinding.PlutoLiFragmentViewAttrBinding +import com.pluto.plugins.layoutinspector.internal.ActivityLifecycle +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.plugins.layoutinspector.internal.attributes.list.AttributeAdapter +import com.pluto.plugins.layoutinspector.internal.inspect.clearTargetTag +import com.pluto.plugins.layoutinspector.internal.inspect.findViewByTargetTag +import com.pluto.plugins.layoutinspector.internal.inspect.getFrontView +import com.pluto.plugins.layoutinspector.internal.inspect.getIdString +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.color +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import com.pluto.utilities.viewBinding +import com.pluto.utilities.views.keyvalue.KeyValuePairEditResult +import com.pluto.utilities.views.keyvalue.edit.KeyValuePairEditor +import com.pluto.utilities.views.keyvalue.edit.lazyKeyValuePairEditor + +internal class ViewAttrFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoLiFragmentViewAttrBinding::bind) + private val contentSharer: ContentShareViewModel by lazyContentSharer() + private var targetView: View? = null + private val attributeAdapter: BaseAdapter by autoClearInitializer { + AttributeAdapter(onActionListener) + } + private val viewModel: ViewAttrViewModel by viewModels() + private val keyValuePairEditor: KeyValuePairEditor by lazyKeyValuePairEditor() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_li___fragment_view_attr, container, false) + + override fun getTheme(): Int = R.style.PlutoLIBottomSheetDialog + + override fun onStart() { + super.onStart() + val width = ViewGroup.LayoutParams.MATCH_PARENT + val height = ViewGroup.LayoutParams.MATCH_PARENT + dialog?.window?.setLayout(width, height) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ActivityLifecycle.topActivity?.let { + it.getFrontView().findViewByTargetTag()?.let { view -> + targetView = view + } ?: run { + targetView?.clearTargetTag() + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.setOnShowListener { + val dialog = it as BottomSheetDialog + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + dialog.behavior.peekHeight = Device(requireContext()).screen.heightPx + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + targetView?.let { target -> + binding.close.setOnDebounceClickListener { + dismiss() + } + binding.share.setOnDebounceClickListener { + viewModel.shareableAttr.value?.let { + contentSharer.share( + Shareable( + title = "Sharing View Attributes", content = it, fileName = "View Attributes generated via Pluto" + ) + ) + } + } + binding.title.setSpan { + append(semiBold(target.javaClass.simpleName)) + append("\n") + target.getIdString()?.let { + append(regular(fontSize(it, SUBTITLE_TEXT_SIZE_IN_SP))) + } ?: run { + append( + regular( + fontSize( + italic( + fontColor( + "NO_ID", + context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ), + SUBTITLE_TEXT_SIZE_IN_SP + ) + ) + ) + } + } + binding.attrList.apply { + adapter = attributeAdapter + } + + keyValuePairEditor.result.removeObserver(keyValuePairEditObserver) + keyValuePairEditor.result.observe(viewLifecycleOwner, keyValuePairEditObserver) + + viewModel.list.removeObserver(parsedAttrObserver) + viewModel.list.observe(viewLifecycleOwner, parsedAttrObserver) + viewModel.parse(target) + } + } + + private val parsedAttrObserver = Observer> { + binding.share.visibility = VISIBLE + attributeAdapter.list = it + } + + private val keyValuePairEditObserver = Observer { + targetView?.let { view -> + it.value?.let { value -> + if (it.metaData is MutableAttribute) { + viewModel.updateAttributeValue(view, it.metaData as MutableAttribute, value) + } + } + } + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is MutableAttribute) { + keyValuePairEditor.edit(data.requestEdit()) + } + } + } + + private companion object { + const val SUBTITLE_TEXT_SIZE_IN_SP = 12 + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/ViewAttrViewModel.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/ViewAttrViewModel.kt new file mode 100644 index 000000000..c835b102e --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/ViewAttrViewModel.kt @@ -0,0 +1,76 @@ +package com.pluto.plugins.layoutinspector.internal.attributes + +import android.app.Application +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.parser.AttributeParser +import com.pluto.plugins.layoutinspector.internal.attributes.list.AttributeTitle +import com.pluto.plugins.layoutinspector.internal.inspect.getIdString +import com.pluto.utilities.list.ListItem +import kotlinx.coroutines.launch + +internal class ViewAttrViewModel(application: Application) : AndroidViewModel(application) { + + private val parser = AttributeParser() + + val list: LiveData> + get() = _list + private val _list = MutableLiveData>() + + val shareableAttr: LiveData + get() = _shareableAttr + private val _shareableAttr = MutableLiveData() + + fun parse(v: View) { + viewModelScope.launch { + val attrList = generateAttributes(v) + _list.postValue(attrList) + + val shareableAttr = generateAttributeShareable(attrList) + _shareableAttr.postValue(shareableAttr) + } + } + + private fun generateAttributeShareable(attrList: ArrayList): String { + val text = StringBuilder() + text.append("View Attributes") + attrList.forEach { attr -> + when (attr) { + is AttributeTitle -> text.append("\n\n*** attributes from: ${attr.title}") + is Attribute<*> -> text.append("\n\t${attr.title}: ${attr.value}") + } + } + return text.toString() + } + + private fun generateAttributes(v: View): ArrayList { + val attrList = arrayListOf() + v.getIdString()?.let { + attrList.add(Attribute("id", it)) + } + val tempAttrList = arrayListOf>( + Attribute("view_type", if (v is ViewGroup) "viewGroup" else "view"), + Attribute("view_class", v.javaClass.canonicalName) + ) + attrList.addAll(tempAttrList.sortedBy { it.title }) + parser.parse(v).forEach { attr -> + attrList.add(AttributeTitle(attr.parameterizedType)) + attrList.addAll(attr.attributes) + } + return attrList + } + + fun updateAttributeValue(view: View, it: MutableAttribute, value: String) { + viewModelScope.launch { + it.handleEdit(view, value) + view.requestLayout() + view.post { parse(view) } + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/Attribute.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/Attribute.kt new file mode 100644 index 000000000..c861a7bc2 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/Attribute.kt @@ -0,0 +1,14 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data + +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.views.keyvalue.KeyValuePairEditMetaData + +internal open class Attribute(val title: String, val value: T) : + ListItem(), + KeyValuePairEditMetaData { + open fun displayText(): CharSequence? = if (value is CharSequence) value else value?.toString() + + override fun isSame(other: Any): Boolean { + return other is Attribute<*> && other.title == title + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/MutableAttribute.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/MutableAttribute.kt new file mode 100644 index 000000000..1ba498f63 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/MutableAttribute.kt @@ -0,0 +1,9 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data + +import android.view.View +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +interface MutableAttribute { + fun requestEdit(): KeyValuePairEditRequest + fun handleEdit(view: View, updatedValue: String) +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeAlpha.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeAlpha.kt new file mode 100644 index 000000000..4897be559 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeAlpha.kt @@ -0,0 +1,22 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.mutability + +import android.util.Log +import android.view.View +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +internal class AttributeAlpha(title: String, value: Float) : Attribute(title, value), MutableAttribute { + + override fun requestEdit(): KeyValuePairEditRequest = KeyValuePairEditRequest( + key = "$title (0 to 1)", value = value.toString(), hint = "enter value (0.0 - 1.0)", metaData = this + ) + + override fun handleEdit(view: View, updatedValue: String) { + if (updatedValue.toFloat() in 0f..1f) { + view.alpha = updatedValue.toFloat() + } else { + Log.e("layout-inspector", "improper alpha value, should be between 0f to 1f") + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeColor.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeColor.kt new file mode 100644 index 000000000..21c334d2a --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeColor.kt @@ -0,0 +1,40 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.mutability + +import android.graphics.Color +import android.view.View +import android.widget.TextView +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +internal abstract class AttributeColor(title: String, value: Int) : Attribute(title, value), MutableAttribute { + override fun displayText(): CharSequence? = formatColor(value) + + override fun requestEdit(): KeyValuePairEditRequest = KeyValuePairEditRequest( + key = title, value = formatColor(value), hint = "enter value", metaData = this + ) + + class Text(title: String, value: Int) : AttributeColor(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + if (view is TextView) { + view.setTextColor(Color.parseColor(updatedValue)) + } + } + } + + class Hint(title: String, value: Int) : AttributeColor(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + if (view is TextView) { + view.setHintTextColor(Color.parseColor(updatedValue)) + } + } + } + + class Background(title: String, value: Int) : AttributeColor(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + view.setBackgroundColor(Color.parseColor(updatedValue)) + } + } + + private fun formatColor(value: Int): String = "#${Integer.toHexString(value).uppercase()}" +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeDimenDP.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeDimenDP.kt new file mode 100644 index 000000000..f4f9ce23b --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeDimenDP.kt @@ -0,0 +1,76 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.mutability + +import android.view.View +import android.view.ViewGroup +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.utilities.extensions.dp2px +import com.pluto.utilities.extensions.px2dp +import com.pluto.utilities.views.keyvalue.KeyValuePairEditInputType +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +internal abstract class AttributeDimenDP(title: String, value: Float) : Attribute(title, value), MutableAttribute { + override fun displayText(): CharSequence? = "${value.px2dp.toInt()} dp" + + override fun requestEdit(): KeyValuePairEditRequest = KeyValuePairEditRequest( + key = "$title (dp)", value = "${value.px2dp.toInt()}", hint = "enter value (in dp)", inputType = KeyValuePairEditInputType.Integer, metaData = this + ) + + /* padding */ + class PaddingStart(title: String, value: Float) : AttributeDimenDP(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + view.setPaddingRelative( + updatedValue.toFloat().dp2px.toInt(), view.paddingTop, view.paddingEnd, view.paddingBottom + ) + } + } + + class PaddingEnd(title: String, value: Float) : AttributeDimenDP(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + view.setPaddingRelative( + view.paddingStart, view.paddingTop, updatedValue.toFloat().dp2px.toInt(), view.paddingBottom + ) + } + } + + class PaddingTop(title: String, value: Float) : AttributeDimenDP(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + view.setPaddingRelative( + view.paddingStart, updatedValue.toFloat().dp2px.toInt(), view.paddingEnd, view.paddingBottom + ) + } + } + + class PaddingBottom(title: String, value: Float) : AttributeDimenDP(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + view.setPaddingRelative( + view.paddingStart, view.paddingTop, view.paddingEnd, updatedValue.toFloat().dp2px.toInt(), + ) + } + } + + /* margin */ + class MarginStart(title: String, value: Float) : AttributeDimenDP(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + (view.layoutParams as ViewGroup.MarginLayoutParams).marginStart = updatedValue.toFloat().dp2px.toInt() + } + } + + class MarginEnd(title: String, value: Float) : AttributeDimenDP(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + (view.layoutParams as ViewGroup.MarginLayoutParams).marginEnd = updatedValue.toFloat().dp2px.toInt() + } + } + + class MarginTop(title: String, value: Float) : AttributeDimenDP(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + (view.layoutParams as ViewGroup.MarginLayoutParams).topMargin = updatedValue.toFloat().dp2px.toInt() + } + } + + class MarginBottom(title: String, value: Float) : AttributeDimenDP(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + (view.layoutParams as ViewGroup.MarginLayoutParams).bottomMargin = updatedValue.toFloat().dp2px.toInt() + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeDimenSP.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeDimenSP.kt new file mode 100644 index 000000000..e00584248 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeDimenSP.kt @@ -0,0 +1,24 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.mutability + +import android.view.View +import android.widget.TextView +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.utilities.extensions.px2sp +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +internal abstract class AttributeDimenSP(title: String, value: Float) : Attribute(title, value), MutableAttribute { + override fun displayText(): CharSequence? = "${value.px2sp.toInt()} sp" + + override fun requestEdit(): KeyValuePairEditRequest = KeyValuePairEditRequest( + key = "$title (sp)", value = value.px2sp.toInt().toString(), hint = "enter value (in sp)", metaData = this + ) + + class TextSize(title: String, value: Float) : AttributeDimenSP(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + if (view is TextView) { + view.textSize = updatedValue.toFloat() + } + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeGravity.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeGravity.kt new file mode 100644 index 000000000..4b6e31505 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeGravity.kt @@ -0,0 +1,105 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.mutability + +import android.view.Gravity +import androidx.annotation.GravityInt +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute + +internal class AttributeGravity(title: String, value: Int) : Attribute(title, value) { + override fun displayText(): CharSequence? = formatGravity(value) + +// override fun requestEdit(): KeyValuePairEditRequest = KeyValuePairEditRequest( +// key = title, +// value = formatGravity(value), +// metaData = this, +// inputType = KeyValuePairEditInputType.Selection, +// candidateOptions = listOf( +// LABEL_NO_GRAVITY, +// LABEL_LEFT, +// LABEL_TOP, +// LABEL_RIGHT, +// LABEL_BOTTOM, +// LABEL_CENTER, +// LABEL_START, +// LABEL_END, +// LABEL_CENTER_HORIZONTAL, +// LABEL_CENTER_VERTICAL, +// LABEL_CLIP_HORIZONTAL, +// LABEL_CLIP_VERTICAL, +// LABEL_FILL_HORIZONTAL, +// LABEL_FILL_VERTICAL, +// LABEL_FILL, +// LABEL_NOT_SET +// ) +// ) +// +// override fun handleEdit(view: View, updatedValue: String) { +// var gravity: Int? = null +// when (updatedValue) { +// LABEL_NO_GRAVITY -> "NO_GRAVITY" +// LABEL_LEFT -> "LEFT" +// LABEL_TOP -> "TOP" +// LABEL_RIGHT -> "RIGHT" +// LABEL_BOTTOM -> "BOTTOM" +// LABEL_CENTER -> "CENTER" +// LABEL_CENTER_HORIZONTAL -> "CENTER_HORIZONTAL" +// LABEL_CENTER_VERTICAL -> "CENTER_VERTICAL" +// LABEL_START -> "START" +// LABEL_END -> "END" +// LABEL_CLIP_HORIZONTAL -> "CLIP_HORIZONTAL" +// LABEL_CLIP_VERTICAL -> "CLIP_VERTICAL" +// LABEL_FILL -> "FILL" +// LABEL_FILL_HORIZONTAL -> "FILL_HORIZONTAL" +// LABEL_FILL_VERTICAL -> "FILL_VERTICAL" +// else -> "NOT SET" +// } +// +// if(view is TextView) { +// Log.d("prateek") +// view.gravity = Gravity.LEFT +// } +// if (scaleType != null) { +// (view as ImageView).scaleType = scaleType +// } else { +// Log.e("layout-inspector", "improper scale type value, should be between 0f to 1f") +// } +// } + + @SuppressWarnings("ComplexMethod") + private fun formatGravity(@GravityInt gravity: Int): String = when (gravity) { + Gravity.NO_GRAVITY -> LABEL_NO_GRAVITY + Gravity.LEFT -> LABEL_LEFT + Gravity.TOP -> LABEL_TOP + Gravity.RIGHT -> LABEL_RIGHT + Gravity.BOTTOM -> LABEL_BOTTOM + Gravity.CENTER -> LABEL_CENTER + Gravity.CENTER_HORIZONTAL -> LABEL_CENTER_HORIZONTAL + Gravity.CENTER_VERTICAL -> LABEL_CENTER_VERTICAL + Gravity.START -> LABEL_START + Gravity.END -> LABEL_END + Gravity.CLIP_HORIZONTAL -> LABEL_CLIP_HORIZONTAL + Gravity.CLIP_VERTICAL -> LABEL_CLIP_VERTICAL + Gravity.FILL -> LABEL_FILL + Gravity.FILL_HORIZONTAL -> LABEL_FILL_HORIZONTAL + Gravity.FILL_VERTICAL -> LABEL_FILL_VERTICAL + else -> LABEL_NOT_SET + } + + private companion object { + const val LABEL_NO_GRAVITY = "NO_GRAVITY" + const val LABEL_LEFT = "LEFT" + const val LABEL_TOP = "TOP" + const val LABEL_RIGHT = "RIGHT" + const val LABEL_BOTTOM = "BOTTOM" + const val LABEL_CENTER = "CENTER" + const val LABEL_CENTER_HORIZONTAL = "CENTER_HORIZONTAL" + const val LABEL_CENTER_VERTICAL = "CENTER_VERTICAL" + const val LABEL_START = "START" + const val LABEL_END = "END" + const val LABEL_CLIP_HORIZONTAL = "CLIP_HORIZONTAL" + const val LABEL_CLIP_VERTICAL = "CLIP_VERTICAL" + const val LABEL_FILL = "FILL" + const val LABEL_FILL_HORIZONTAL = "FILL_HORIZONTAL" + const val LABEL_FILL_VERTICAL = "FILL_VERTICAL" + const val LABEL_NOT_SET = "NOT SET" + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeLayoutParam.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeLayoutParam.kt new file mode 100644 index 000000000..8c8d70317 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeLayoutParam.kt @@ -0,0 +1,74 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.mutability + +import android.view.View +import android.view.ViewGroup +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.utilities.extensions.dp2px +import com.pluto.utilities.extensions.px2dp +import com.pluto.utilities.views.keyvalue.KeyValuePairEditInputType +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +internal abstract class AttributeLayoutParam(title: String, value: Data) : Attribute(title, value), MutableAttribute { + override fun displayText(): CharSequence? = value.toDisplayText() + override fun requestEdit(): KeyValuePairEditRequest = KeyValuePairEditRequest( + key = "$title (dp)", + value = if (value.actualValue() != LABEL_WRAP_CONTENT && value.actualValue() != LABEL_MATCH_PARENT) { + value.actualValue() + } else { + null + }, + hint = "enter value (in dp)", + metaData = this, + inputType = KeyValuePairEditInputType.Integer, + candidateOptions = listOf( + LABEL_WRAP_CONTENT, + LABEL_MATCH_PARENT + ) + ) + + class Height(title: String, value: Data) : AttributeLayoutParam(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + view.layoutParams.height = when (updatedValue) { + LABEL_WRAP_CONTENT -> ViewGroup.LayoutParams.WRAP_CONTENT + LABEL_MATCH_PARENT -> ViewGroup.LayoutParams.MATCH_PARENT + else -> updatedValue.toFloat().dp2px.toInt() + } + } + } + + class Width(title: String, value: Data) : AttributeLayoutParam(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + view.layoutParams.width = when (updatedValue) { + LABEL_WRAP_CONTENT -> ViewGroup.LayoutParams.WRAP_CONTENT + LABEL_MATCH_PARENT -> ViewGroup.LayoutParams.MATCH_PARENT + else -> updatedValue.toFloat().dp2px.toInt() + } + } + } + + private companion object { + const val LABEL_WRAP_CONTENT = "wrap_content" + const val LABEL_MATCH_PARENT = "match_parent" + } + + internal data class Data( + val layoutParam: Int, + val size: Int + ) { + fun toDisplayText(): CharSequence { + val dp = "${size.toFloat().px2dp.toInt()} dp" + return when (layoutParam) { + ViewGroup.LayoutParams.WRAP_CONTENT -> "$LABEL_WRAP_CONTENT ($dp)" + ViewGroup.LayoutParams.MATCH_PARENT -> "$LABEL_MATCH_PARENT ($dp)" + else -> dp + } + } + + fun actualValue(): String = when (layoutParam) { + ViewGroup.LayoutParams.WRAP_CONTENT -> LABEL_WRAP_CONTENT + ViewGroup.LayoutParams.MATCH_PARENT -> LABEL_MATCH_PARENT + else -> "${size.toFloat().px2dp.toInt()}" + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeScaleType.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeScaleType.kt new file mode 100644 index 000000000..825fd69e4 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeScaleType.kt @@ -0,0 +1,60 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.mutability + +import android.util.Log +import android.view.View +import android.widget.ImageView +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.utilities.views.keyvalue.KeyValuePairEditInputType +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +internal class AttributeScaleType(title: String, value: ImageView.ScaleType) : Attribute(title, value), MutableAttribute { + override fun displayText(): CharSequence? = value.name + + override fun requestEdit(): KeyValuePairEditRequest = KeyValuePairEditRequest( + key = title, + value = value.name, + metaData = this, + inputType = KeyValuePairEditInputType.Selection, + candidateOptions = listOf( + ImageView.ScaleType.CENTER_INSIDE.name, + ImageView.ScaleType.CENTER_CROP.name, + ImageView.ScaleType.CENTER.name, + ImageView.ScaleType.FIT_CENTER.name, + ImageView.ScaleType.FIT_END.name, + ImageView.ScaleType.FIT_START.name, + ImageView.ScaleType.FIT_XY.name, + ImageView.ScaleType.MATRIX.name + ) + ) + + override fun handleEdit(view: View, updatedValue: String) { + var scaleType: ImageView.ScaleType? = null + when (updatedValue) { + LABEL_CENTER_INSIDE -> scaleType = ImageView.ScaleType.CENTER_INSIDE + LABEL_CENTER_CROP -> scaleType = ImageView.ScaleType.CENTER_CROP + LABEL_CENTER -> scaleType = ImageView.ScaleType.CENTER + LABEL_FIT_CENTER -> scaleType = ImageView.ScaleType.FIT_CENTER + LABEL_FIT_END -> scaleType = ImageView.ScaleType.FIT_END + LABEL_FIT_START -> scaleType = ImageView.ScaleType.FIT_START + LABEL_FIT_XY -> scaleType = ImageView.ScaleType.FIT_XY + LABEL_MATRIX -> scaleType = ImageView.ScaleType.MATRIX + } + if (scaleType != null) { + (view as ImageView).scaleType = scaleType + } else { + Log.e("layout-inspector", "improper scale type value, should be between 0f to 1f") + } + } + + private companion object { + const val LABEL_CENTER_INSIDE = "CENTER_INSIDE" + const val LABEL_CENTER_CROP = "CENTER_CROP" + const val LABEL_CENTER = "CENTER" + const val LABEL_FIT_CENTER = "FIT_CENTER" + const val LABEL_FIT_END = "FIT_END" + const val LABEL_FIT_START = "FIT_START" + const val LABEL_FIT_XY = "FIT_XY" + const val LABEL_MATRIX = "MATRIX" + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeText.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeText.kt new file mode 100644 index 000000000..0be45de36 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeText.kt @@ -0,0 +1,30 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.mutability + +import android.view.View +import android.widget.TextView +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +internal abstract class AttributeText(title: String, value: CharSequence?) : Attribute(title, value), MutableAttribute { + + override fun requestEdit(): KeyValuePairEditRequest = KeyValuePairEditRequest( + key = title, value = value?.toString(), hint = "enter value", metaData = this + ) + + class Text(title: String, value: CharSequence?) : AttributeText(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + if (view is TextView) { + view.text = updatedValue + } + } + } + + class Hint(title: String, value: CharSequence?) : AttributeText(title, value) { + override fun handleEdit(view: View, updatedValue: String) { + if (view is TextView) { + view.hint = updatedValue + } + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeVisibility.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeVisibility.kt new file mode 100644 index 000000000..a9ef0a7f5 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/mutability/AttributeVisibility.kt @@ -0,0 +1,45 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.mutability + +import android.view.View +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.utilities.views.keyvalue.KeyValuePairEditInputType +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +internal class AttributeVisibility(title: String, value: Int) : Attribute(title, value), MutableAttribute { + override fun displayText(): CharSequence? = formatVisibility(value) + + override fun requestEdit(): KeyValuePairEditRequest = KeyValuePairEditRequest( + key = title, + value = formatVisibility(value), + metaData = this, + inputType = KeyValuePairEditInputType.Selection, + candidateOptions = listOf( + formatVisibility(View.VISIBLE), + formatVisibility(View.INVISIBLE), + formatVisibility(View.GONE) + ) + ) + + override fun handleEdit(view: View, updatedValue: String) { + view.visibility = when (updatedValue) { + LABEL_VISIBLE -> View.VISIBLE + LABEL_INVISIBLE -> View.INVISIBLE + LABEL_GONE -> View.GONE + else -> View.VISIBLE + } + } + + private fun formatVisibility(value: Int): String = when (value) { + View.VISIBLE -> LABEL_VISIBLE + View.INVISIBLE -> LABEL_INVISIBLE + View.GONE -> LABEL_GONE + else -> "NOT SET" + } + + private companion object { + const val LABEL_VISIBLE = "VISIBLE" + const val LABEL_INVISIBLE = "INVISIBLE" + const val LABEL_GONE = "GONE" + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/AttributeParser.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/AttributeParser.kt new file mode 100644 index 000000000..9283daece --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/AttributeParser.kt @@ -0,0 +1,33 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.parser + +import android.view.View +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.parser.types.ImageViewParser +import com.pluto.plugins.layoutinspector.internal.attributes.data.parser.types.TextViewParser +import com.pluto.plugins.layoutinspector.internal.attributes.data.parser.types.ViewGroupParser +import com.pluto.plugins.layoutinspector.internal.attributes.data.parser.types.ViewParser + +internal class AttributeParser { + private val parsers = arrayListOf>().apply { + add(ImageViewParser()) + add(TextViewParser()) + add(ViewGroupParser()) + add(ViewParser()) + } + + fun parse(view: View): List { + val attributes = arrayListOf() + parsers.forEach { parser -> + val attributeList = parser.getAttributes(view) + if (!attributeList.isNullOrEmpty()) { + attributes.add(ParsedAttribute(parser.parameterizedType, attributeList.sortedBy { it.title })) + } + } + return attributes + } + + data class ParsedAttribute( + val parameterizedType: String, + val attributes: List> + ) +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/IParser.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/IParser.kt new file mode 100644 index 000000000..c9abe07d9 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/IParser.kt @@ -0,0 +1,32 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.parser + +import android.view.View +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import java.lang.reflect.ParameterizedType + +internal abstract class IParser { + + private val parameterizedTypeClass = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class<*> + val parameterizedType: String = parameterizedTypeClass.toString().replace("class", "", true).trim() + + private fun isValidType(viewClazz: Class<*>): Boolean { + var clazz: Class<*> = viewClazz + do { + if (parameterizedTypeClass === clazz) { + return true + } + clazz = clazz.superclass + } while (clazz != Any::class.java) + return false + } + + @SuppressWarnings("TooGenericExceptionCaught") + fun getAttributes(view: View): List>? = try { + if (isValidType(view.javaClass)) getTypeAttributes(view) else null + } catch (t: Throwable) { + t.printStackTrace() + null + } + + protected abstract fun getTypeAttributes(view: View): List> +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/ImageViewParser.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/ImageViewParser.kt new file mode 100644 index 000000000..dec7e8cb7 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/ImageViewParser.kt @@ -0,0 +1,18 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.parser.types + +import android.view.View +import android.widget.ImageView +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.mutability.AttributeScaleType +import com.pluto.plugins.layoutinspector.internal.attributes.data.parser.IParser + +internal class ImageViewParser : IParser() { + + override fun getTypeAttributes(view: View): List> { + val attributes = arrayListOf>() + (view as ImageView).apply { + attributes.add(AttributeScaleType("scale_type", scaleType)) + } + return attributes + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/TextViewParser.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/TextViewParser.kt new file mode 100644 index 000000000..e570c4a88 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/TextViewParser.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.parser.types + +import android.view.View +import android.widget.TextView +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.mutability.AttributeColor +import com.pluto.plugins.layoutinspector.internal.attributes.data.mutability.AttributeDimenSP +import com.pluto.plugins.layoutinspector.internal.attributes.data.mutability.AttributeGravity +import com.pluto.plugins.layoutinspector.internal.attributes.data.mutability.AttributeText +import com.pluto.plugins.layoutinspector.internal.attributes.data.parser.IParser + +internal class TextViewParser : IParser() { + + override fun getTypeAttributes(view: View): List> { + val attributes = arrayListOf>() + (view as TextView).apply { + attributes.add(AttributeText.Text("text", text)) + attributes.add(AttributeColor.Text("text_color", currentTextColor)) + attributes.add(AttributeText.Hint("hint", hint)) + attributes.add(AttributeColor.Hint("text_hint_color", currentHintTextColor)) + attributes.add(AttributeDimenSP.TextSize("text_size", textSize)) + attributes.add(AttributeGravity("gravity", gravity)) + attributes.add(Attribute("line_count", lineCount)) + attributes.add(Attribute("line_height", lineHeight.toFloat())) + } + return attributes + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/ViewGroupParser.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/ViewGroupParser.kt new file mode 100644 index 000000000..f40e68430 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/ViewGroupParser.kt @@ -0,0 +1,18 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.parser.types + +import android.view.View +import android.view.ViewGroup +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.parser.IParser + +internal class ViewGroupParser : IParser() { + + override fun getTypeAttributes(view: View): List> { + val attributes = arrayListOf>() + (view as ViewGroup).apply { + attributes.add(Attribute("child_count", childCount)) + attributes.add(Attribute("will_not_draw", willNotDraw())) + } + return attributes + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/ViewParser.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/ViewParser.kt new file mode 100644 index 000000000..b1b2904de --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/data/parser/types/ViewParser.kt @@ -0,0 +1,52 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.data.parser.types + +import android.graphics.drawable.ColorDrawable +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.mutability.AttributeAlpha +import com.pluto.plugins.layoutinspector.internal.attributes.data.mutability.AttributeColor +import com.pluto.plugins.layoutinspector.internal.attributes.data.mutability.AttributeDimenDP +import com.pluto.plugins.layoutinspector.internal.attributes.data.mutability.AttributeLayoutParam +import com.pluto.plugins.layoutinspector.internal.attributes.data.mutability.AttributeVisibility +import com.pluto.plugins.layoutinspector.internal.attributes.data.parser.IParser + +internal class ViewParser : IParser() { + + override fun getTypeAttributes(view: View): List> { + val attributes = arrayListOf>() + val params: ViewGroup.LayoutParams = view.layoutParams + attributes.add(Attribute("layoutParams", params.javaClass.name)) + attributes.add(AttributeLayoutParam.Width("layout_width", AttributeLayoutParam.Data(params.width, view.width))) + attributes.add(AttributeLayoutParam.Height("layout_height", AttributeLayoutParam.Data(params.height, view.height))) + attributes.add(AttributeVisibility("visibility", view.visibility)) + attributes.add(AttributeDimenDP.PaddingStart("padding_start", view.paddingStart.toFloat())) + attributes.add(AttributeDimenDP.PaddingTop("padding_top", view.paddingTop.toFloat())) + attributes.add(AttributeDimenDP.PaddingEnd("padding_end", view.paddingEnd.toFloat())) + attributes.add(AttributeDimenDP.PaddingBottom("padding_bottom", view.paddingBottom.toFloat())) + if (view.layoutParams != null && view.layoutParams is MarginLayoutParams) { + val marginLayoutParams: MarginLayoutParams = view.layoutParams as MarginLayoutParams + attributes.add(AttributeDimenDP.MarginStart("margin_start", marginLayoutParams.marginStart.toFloat())) + attributes.add(AttributeDimenDP.MarginTop("margin_top", marginLayoutParams.topMargin.toFloat())) + attributes.add(AttributeDimenDP.MarginEnd("margin_end", marginLayoutParams.marginEnd.toFloat())) + attributes.add(AttributeDimenDP.MarginBottom("margin_bottom", marginLayoutParams.bottomMargin.toFloat())) + } + attributes.add(Attribute("translationX", view.translationX)) + attributes.add(Attribute("translationY", view.translationY)) + attributes.add( + when (view.background) { + is ColorDrawable -> AttributeColor.Background("background", (view.background as ColorDrawable).color) + else -> Attribute("background", view.background) + } + ) + attributes.add(AttributeAlpha("alpha", view.alpha)) + attributes.add(Attribute("tag", view.tag)) + attributes.add(Attribute("enabled", view.isEnabled)) + attributes.add(Attribute("clickable", view.isClickable)) + attributes.add(Attribute("long_clickable", view.isLongClickable)) + attributes.add(Attribute("focusable", view.isFocusable)) + attributes.add(Attribute("content_dscrptn", view.contentDescription)) + return attributes + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/list/AttributeAdapter.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/list/AttributeAdapter.kt new file mode 100644 index 000000000..a1bd85813 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/list/AttributeAdapter.kt @@ -0,0 +1,30 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.list + +import android.view.ViewGroup +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class AttributeAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is Attribute<*> -> ITEM_TYPE_ATTRIBUTE + is AttributeTitle -> ITEM_TYPE_TITLE + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_ATTRIBUTE -> AttributeItemHolder(parent, listener) + ITEM_TYPE_TITLE -> AttributeTitleItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_ATTRIBUTE = 1000 + const val ITEM_TYPE_TITLE = 1001 + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/list/AttributeItemHolder.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/list/AttributeItemHolder.kt new file mode 100644 index 000000000..fdf17b68f --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/list/AttributeItemHolder.kt @@ -0,0 +1,37 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.list + +import android.view.ViewGroup +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.databinding.PlutoLiItemViewAttrBinding +import com.pluto.plugins.layoutinspector.internal.attributes.data.Attribute +import com.pluto.plugins.layoutinspector.internal.attributes.data.MutableAttribute +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.views.keyvalue.KeyValuePairData + +internal class AttributeItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto_li___item_view_attr), actionListener) { + + private val binding = PlutoLiItemViewAttrBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is Attribute<*>) { + binding.content.set( + KeyValuePairData( + key = item.title, + value = item.displayText(), + showClickIndicator = item is MutableAttribute, + onClick = getAction(item) + ) + ) + } + } + + private fun getAction(item: Attribute<*>): (() -> Unit)? = if (item is MutableAttribute) { + { onAction("click") } + } else { + null + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/list/AttributeTitleItemHolder.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/list/AttributeTitleItemHolder.kt new file mode 100644 index 000000000..010c648cc --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/attributes/list/AttributeTitleItemHolder.kt @@ -0,0 +1,25 @@ +package com.pluto.plugins.layoutinspector.internal.attributes.list + +import android.view.ViewGroup +import androidx.annotation.Keep +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.databinding.PlutoLiItemViewAttrTitleBinding +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class AttributeTitleItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto_li___item_view_attr_title), actionListener) { + + private val binding = PlutoLiItemViewAttrTitleBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is AttributeTitle) { + binding.title.text = item.title + } + } +} + +@Keep +internal data class AttributeTitle(val title: String?) : ListItem() diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlCta.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlCta.kt new file mode 100644 index 000000000..21a3c3d80 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlCta.kt @@ -0,0 +1,10 @@ +package com.pluto.plugins.layoutinspector.internal.control + +import androidx.annotation.DrawableRes +import com.pluto.utilities.list.ListItem + +internal data class ControlCta( + val id: String, + @DrawableRes val icon: Int, + val hint: String +) : ListItem() diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlCtaAdapter.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlCtaAdapter.kt new file mode 100644 index 000000000..30d5c2ef5 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlCtaAdapter.kt @@ -0,0 +1,26 @@ +package com.pluto.plugins.layoutinspector.internal.control + +import android.view.ViewGroup +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class ControlCtaAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is ControlCta -> ITEM_TYPE_CONTROL_CTA + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_CONTROL_CTA -> ControlCtaItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_CONTROL_CTA = 1000 + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlCtaItemHolder.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlCtaItemHolder.kt new file mode 100644 index 000000000..0ab780ab7 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlCtaItemHolder.kt @@ -0,0 +1,30 @@ +package com.pluto.plugins.layoutinspector.internal.control + +import android.view.ViewGroup +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.databinding.PlutoLiItemControlCtaBinding +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class ControlCtaItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto_li___item_control_cta), actionListener) { + + private val binding = PlutoLiItemControlCtaBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is ControlCta) { + binding.icon.setImageResource(item.icon) + binding.root.setOnDebounceClickListener { + onAction(item.id) + } + binding.root.setOnLongClickListener { + context.toast(item.hint) + true + } + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlsWidget.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlsWidget.kt new file mode 100644 index 000000000..46646a43d --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/control/ControlsWidget.kt @@ -0,0 +1,51 @@ +package com.pluto.plugins.layoutinspector.internal.control + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DividerItemDecoration +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.databinding.PlutoLiControlsWidgetBinding +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class ControlsWidget : ConstraintLayout { + + private val binding = PlutoLiControlsWidgetBinding.inflate(LayoutInflater.from(context), this, true) + private val pluginAdapter: BaseAdapter by lazy { ControlCtaAdapter(onActionListener) } + private var mListener: OnClickListener? = null + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + fun initialise(ctas: List, listener: OnClickListener? = null) { + mListener = listener + binding.list.apply { + adapter = pluginAdapter + addItemDecoration( + DividerItemDecoration(context, LinearLayout.HORIZONTAL).apply { + setDrawable(ContextCompat.getDrawable(context, R.drawable.pluto_li___item_divider)!!) + } + ) + } + pluginAdapter.list = ctas + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is ControlCta) { + mListener?.onClick(data.id) + } + } + } + + interface OnClickListener { + fun onClick(id: String) + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/Hierarchy.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/Hierarchy.kt new file mode 100644 index 000000000..a9f28a57a --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/Hierarchy.kt @@ -0,0 +1,34 @@ +package com.pluto.plugins.layoutinspector.internal.hierarchy + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children +import com.pluto.plugins.layoutinspector.internal.inspect.verifyTargetTag +import com.pluto.utilities.list.ListItem + +class Hierarchy( + val view: View, + val layerCount: Int, + var isExpanded: Boolean = false +) : ListItem() { + override fun isEqual(other: Any): Boolean { + return other is Hierarchy && other.view.javaClass.name == view.javaClass.name && other.isExpanded == isExpanded + } + + val isTargetView: Boolean + get() = view.verifyTargetTag() + + fun assembleChildren(recursive: Boolean = false): List { + val result = arrayListOf() + if (view is ViewGroup) { + view.children.forEach { + val item = Hierarchy(it, layerCount + 1, isExpanded = recursive) + result.add(item) + if (recursive) { + result.addAll(item.assembleChildren(true)) + } + } + } + return result + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/NestedView.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/NestedView.kt new file mode 100644 index 000000000..281191463 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/NestedView.kt @@ -0,0 +1,58 @@ +package com.pluto.plugins.layoutinspector.internal.hierarchy + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import com.pluto.utilities.extensions.dp2px + +class NestedView : ConstraintLayout { + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + private var layerCount = 0 + + fun setLayerCount(layerCount: Int) { + this.layerCount = layerCount + setPadding(interval * layerCount + 2f.dp2px.toInt(), paddingTop, paddingRight, paddingBottom) + invalidate() + } + + private val interval: Int = 10f.dp2px.toInt() + private val paint: Paint = object : Paint() { + init { + color = Color.GRAY + style = Style.FILL + strokeWidth = 0.5f.dp2px + } + } + private val colorList: List = arrayListOf( + Color.BLACK, + Color.parseColor("#546E7A"), + Color.parseColor("#FF1744"), + Color.parseColor("#FFA000"), + Color.parseColor("#1DE9B6"), + Color.parseColor("#03A9F4"), + Color.parseColor("#00E5FF"), + Color.parseColor("#388E3C"), + Color.parseColor("#76FF03"), + Color.parseColor("#EEFF41") + ) + + init { + setWillNotDraw(false) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + for (i in 1..layerCount) { + paint.color = colorList[i % colorList.size] + paint.strokeWidth = 1f.dp2px + canvas.drawLine((i * interval).toFloat(), 0f, (i * interval).toFloat(), measuredHeight.toFloat(), paint) + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/ViewHierarchyFragment.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/ViewHierarchyFragment.kt new file mode 100644 index 000000000..a6aaf29bf --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/ViewHierarchyFragment.kt @@ -0,0 +1,144 @@ +package com.pluto.plugins.layoutinspector.internal.hierarchy + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Observer +import androidx.navigation.fragment.findNavController +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.databinding.PlutoLiFragmentViewHierarchyBinding +import com.pluto.plugins.layoutinspector.internal.ActivityLifecycle +import com.pluto.plugins.layoutinspector.internal.hierarchy.list.HierarchyAdapter +import com.pluto.plugins.layoutinspector.internal.hierarchy.list.HierarchyItemHolder.Companion.ACTION_ATTRIBUTE +import com.pluto.plugins.layoutinspector.internal.hierarchy.list.HierarchyItemHolder.Companion.ACTION_EXPAND_COLLAPSE +import com.pluto.plugins.layoutinspector.internal.hierarchy.list.HierarchyItemHolder.Companion.ACTION_INSPECT_VIEW +import com.pluto.plugins.layoutinspector.internal.inspect.InspectViewModel +import com.pluto.plugins.layoutinspector.internal.inspect.assignTargetTag +import com.pluto.plugins.layoutinspector.internal.inspect.getFrontView +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.onBackPressed +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +internal class ViewHierarchyFragment : DialogFragment() { + + private var rootView: View? = null + private val hierarchyAdapter: BaseAdapter by autoClearInitializer { + HierarchyAdapter(onActionListener) + } + private val viewModel: ViewHierarchyViewModel by viewModels() + private val inspectViewModel: InspectViewModel by activityViewModels() + private val binding by viewBinding(PlutoLiFragmentViewHierarchyBinding::bind) + private var hasAlreadyScrolled: Boolean = false + private val shouldScrollToInspectedView: Boolean + get() = arguments?.getBoolean(SCROLL_TO_TARGET) ?: false + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.pluto_li___fragment_view_hierarchy, container, false) + } + + override fun getTheme(): Int = R.style.PlutoLIFullScreenDialogStyle + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBackPressed { findNavController().navigateUp() } + binding.close.setOnDebounceClickListener { + findNavController().navigateUp() + } + + ActivityLifecycle.topActivity?.getFrontView()?.let { + rootView = it.findViewById(android.R.id.content) + } ?: run { + toast("root view not found, go back & try again") + } + + rootView?.let { root -> + binding.expandCta.setOnDebounceClickListener { + viewModel.expandAll(root) + } + binding.collapseCta.setOnDebounceClickListener { + viewModel.collapseAll(root) + } + binding.list.apply { + adapter = hierarchyAdapter + } + + viewModel.list.removeObserver(parsedAttrObserver) + viewModel.list.observe(viewLifecycleOwner, parsedAttrObserver) + viewModel.parseInit(root) + } + } + + private fun scrollToInspectedView(list: List) { + var inspectedViewIndex = list.indexOfFirst { it.isTargetView } + if (inspectedViewIndex > -1) { + if (inspectedViewIndex < list.size - 1) inspectedViewIndex++ + binding.list.smoothScrollToPosition(inspectedViewIndex) + } + } + + private val parsedAttrObserver = Observer> { + hierarchyAdapter.list = arrayListOf().apply { + addAll(it) + } + + if (shouldScrollToInspectedView && !hasAlreadyScrolled) { + scrollToInspectedView(it) + hasAlreadyScrolled = true + } + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is Hierarchy) { + when (action) { + ACTION_INSPECT_VIEW -> inspectView(data.view) + ACTION_ATTRIBUTE -> showViewAttribute(data.view) + ACTION_EXPAND_COLLAPSE -> collapseExpandView(data, holder) + } + } + } + } + + private fun inspectView(view: View) { + if (view.isVisible) { + inspectViewModel.select(view) + dismiss() + } else { + context?.toast("View is not visible") + } + } + + private fun showViewAttribute(view: View) { + if (view.isVisible) { + inspectViewModel.select(view) + rootView?.let { viewModel.parseInit(it) } + findNavController().navigate(R.id.openAttrView) + } else { + view.assignTargetTag() + findNavController().navigate(R.id.openAttrView) + } + } + + private fun collapseExpandView(data: Hierarchy, holder: DiffAwareHolder) { + if (data.isExpanded) { + viewModel.removeChildren(data, holder.layoutPosition) + } else { + viewModel.addChildren(data, holder.layoutPosition) + } + } + + companion object { + const val SCROLL_TO_TARGET = "scroll_to_target" + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/ViewHierarchyViewModel.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/ViewHierarchyViewModel.kt new file mode 100644 index 000000000..90c0515b4 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/ViewHierarchyViewModel.kt @@ -0,0 +1,81 @@ +package com.pluto.plugins.layoutinspector.internal.hierarchy + +import android.app.Application +import android.view.View +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch + +internal class ViewHierarchyViewModel(application: Application) : AndroidViewModel(application) { + + val list: LiveData> + get() = _list + private val _list = MutableLiveData>() + + fun parseInit(v: View) { + expandAll(v) + } + + fun expandAll(rootView: View) { + viewModelScope.launch { + val root = Hierarchy(rootView, 0, isExpanded = true) + val children = root.assembleChildren(true) + val list = arrayListOf().apply { + add(root) + addAll(children) + } + _list.postValue(list) + } + } + + fun collapseAll(rootView: View) { + viewModelScope.launch { + val list = arrayListOf( + Hierarchy(rootView, 0) + ) + _list.postValue(list) + } + } + + fun removeChildren(data: Hierarchy, layoutPosition: Int) { + viewModelScope.launch { + var isNotValidChild = false + val newList = (_list.value ?: arrayListOf()).filterIndexed { index, value -> + if (index <= layoutPosition) { + true + } else { + if (value.layerCount == data.layerCount) { + isNotValidChild = true + } + isNotValidChild || value.layerCount <= data.layerCount + } + } + val list = arrayListOf().apply { + addAll(newList) + } + list[layoutPosition] = Hierarchy( + view = data.view, + layerCount = data.layerCount, + isExpanded = false + ) + _list.postValue(list) + } + } + + fun addChildren(data: Hierarchy, layoutPosition: Int) { + viewModelScope.launch { + val children = data.assembleChildren(false) + val list = _list.value ?: arrayListOf() + + list[layoutPosition] = Hierarchy( + view = data.view, + layerCount = data.layerCount, + isExpanded = true + ) + list.addAll(layoutPosition + 1, children) + _list.postValue(list) + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/list/HierarchyAdapter.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/list/HierarchyAdapter.kt new file mode 100644 index 000000000..ea5ade630 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/list/HierarchyAdapter.kt @@ -0,0 +1,27 @@ +package com.pluto.plugins.layoutinspector.internal.hierarchy.list + +import android.view.ViewGroup +import com.pluto.plugins.layoutinspector.internal.hierarchy.Hierarchy +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class HierarchyAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is Hierarchy -> ITEM_TYPE_HIERARCHY + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_HIERARCHY -> HierarchyItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_HIERARCHY = 1000 + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/list/HierarchyItemHolder.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/list/HierarchyItemHolder.kt new file mode 100644 index 000000000..b2bc83e2b --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hierarchy/list/HierarchyItemHolder.kt @@ -0,0 +1,90 @@ +package com.pluto.plugins.layoutinspector.internal.hierarchy.list + +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.databinding.PlutoLiItemViewHierarchyBinding +import com.pluto.plugins.layoutinspector.internal.hierarchy.Hierarchy +import com.pluto.plugins.layoutinspector.internal.inspect.getIdString +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.extensions.showMoreOptions +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class HierarchyItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto_li___item_view_hierarchy), actionListener) { + + private val binding = PlutoLiItemViewHierarchyBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is Hierarchy) { + if (item.isTargetView) { + binding.contentWrapper.background = ContextCompat.getDrawable(context, R.drawable.pluto_li___ic_hierarchy_target_view_indicator) + } else { + binding.contentWrapper.background = null + } + binding.viewTitle.setSpan { + append(item.view.javaClass.simpleName) + if (item.view is ViewGroup) { + append(regular(fontColor(" (${item.view.childCount})", context.color(com.pluto.plugin.R.color.pluto___text_dark_40)))) + } + } + binding.viewSubtitle.setSpan { + item.view.getIdString()?.let { + append(it) + } ?: run { + append(regular(italic(fontColor("NO_ID", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))))) + } + append(" {(${item.view.left},${item.view.top}),(${item.view.right},${item.view.bottom})}") + } + binding.viewActionCta.setOnDebounceClickListener { + showActionOptions(it) + } + binding.contentWrapper.setOnLongClickListener { + showActionOptions(it) + true + } + binding.expandStateIndicator.setImageResource( + if (item.isExpanded) { + R.drawable.pluto_li___ic_hierarchy_show_less + } else { + R.drawable.pluto_li___ic_hierarchy_show_more + } + ) + binding.expandStateIndicator.visibility = if (item.view is ViewGroup && item.view.childCount > 0) VISIBLE else GONE + if (item.view is ViewGroup && item.view.childCount > 0) { + binding.contentWrapper.setOnDebounceClickListener { onAction(ACTION_EXPAND_COLLAPSE) } + } else { + binding.contentWrapper.setOnDebounceClickListener(action = null) + } + val layoutParams: ConstraintLayout.LayoutParams = binding.viewTitle.layoutParams as ConstraintLayout.LayoutParams + layoutParams.marginStart = if (item.view !is ViewGroup) 8f.dp.toInt() else 0 + binding.viewTitle.layoutParams = layoutParams + binding.root.setLayerCount(item.layerCount) + } + } + + private fun showActionOptions(view: View) { + context.showMoreOptions(view, R.menu.pluto_li___menu_hierarchy_options) { item -> + when (item.itemId) { + R.id.select -> onAction(ACTION_INSPECT_VIEW) + R.id.attribute -> onAction(ACTION_ATTRIBUTE) + } + } + } + + companion object { + const val ACTION_INSPECT_VIEW = "inspect" + const val ACTION_ATTRIBUTE = "attribute" + const val ACTION_EXPAND_COLLAPSE = "expand_collapse" + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintAdapter.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintAdapter.kt new file mode 100644 index 000000000..d77b09a57 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintAdapter.kt @@ -0,0 +1,30 @@ +package com.pluto.plugins.layoutinspector.internal.hint + +import android.view.ViewGroup +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class HintAdapter : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is HintItem -> ITEM_TYPE_HINT + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_HINT -> HintItemHolder(parent) + else -> null + } + } + + companion object { + const val ITEM_TYPE_HINT = 1000 + } +} + +internal data class HintItem( + val text: String +) : ListItem() diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintFragment.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintFragment.kt new file mode 100644 index 000000000..810aa143a --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintFragment.kt @@ -0,0 +1,42 @@ +package com.pluto.plugins.layoutinspector.internal.hint + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.databinding.PlutoLiHintFragmentBinding +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.viewBinding + +internal class HintFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoLiHintFragmentBinding::bind) + private val settingsAdapter: BaseAdapter by autoClearInitializer { HintAdapter() } + private val viewModel: HintViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_li___hint_fragment, container, false) + + override fun getTheme(): Int = com.pluto.plugin.R.style.PlutoBottomSheetDialogTheme + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.list.apply { + adapter = settingsAdapter + addItemDecoration(CustomItemDecorator(context, 16f.dp.toInt())) + } + viewModel.list.removeObserver(settingsObserver) + viewModel.list.observe(viewLifecycleOwner, settingsObserver) + } + + private val settingsObserver = Observer> { + settingsAdapter.list = it + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintItemHolder.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintItemHolder.kt new file mode 100644 index 000000000..46b6d1436 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintItemHolder.kt @@ -0,0 +1,21 @@ +package com.pluto.plugins.layoutinspector.internal.hint + +import android.view.ViewGroup +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.databinding.PlutoLiItemHintBinding +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class HintItemHolder(parent: ViewGroup, listener: DiffAwareAdapter.OnActionListener? = null) : + DiffAwareHolder(parent.inflate(R.layout.pluto_li___item_hint), listener) { + + private val binding = PlutoLiItemHintBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is HintItem) { + binding.text.text = "${layoutPosition + 1}.\t${item.text}" + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintViewModel.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintViewModel.kt new file mode 100644 index 000000000..27753014d --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/hint/HintViewModel.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.layoutinspector.internal.hint + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.pluto.plugins.layoutinspector.R + +internal class HintViewModel(application: Application) : AndroidViewModel(application) { + + val list: LiveData> + get() = _list + private val _list = MutableLiveData>() + + init { + generate(getApplication()) + } + + private fun generate(context: Context?) { + context?.apply { + val list = arrayListOf() + list.add(HintItem(getString(R.string.pluto_li___hint_select_view))) + list.add(HintItem(getString(R.string.pluto_li___hint_move_view))) + _list.postValue(list) + } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/InspectOverlay.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/InspectOverlay.kt new file mode 100644 index 000000000..31e524f30 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/InspectOverlay.kt @@ -0,0 +1,255 @@ +package com.pluto.plugins.layoutinspector.internal.inspect + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.app.Activity +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import android.util.Log +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.ViewGroup +import androidx.core.view.children +import com.pluto.plugins.layoutinspector.internal.inspect.canvas.CaptureCanvas +import com.pluto.plugins.layoutinspector.internal.inspect.canvas.DimensionCanvas +import com.pluto.plugins.layoutinspector.internal.inspect.canvas.GridCanvas +import com.pluto.utilities.extensions.dp2px + +internal class InspectOverlay : View { + + private var gridAnimator: ValueAnimator? = null + private var touchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop + private var longPressTimeout: Int = ViewConfiguration.getLongPressTimeout() + + private var gridCanvas: GridCanvas = GridCanvas(this) + private var dimenCanvas: DimensionCanvas = DimensionCanvas(this) + private var captureCanvas: CaptureCanvas = CaptureCanvas(this) + + private var prevCoordinate = CoordinatePair() + private var downCoordinate = CoordinatePair() + + private var state: State = State.Idle + + private val inspectedViews = arrayListOf() + private var targetInspectedView: InspectedView? = null + + private var clickListener: OnClickListener? = null + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + private val boundaryPaint: Paint = object : Paint(ANTI_ALIAS_FLAG) { + init { + color = Color.YELLOW + strokeWidth = 2f.dp2px + style = Style.STROKE + } + } + private val longPressCheck = Runnable { + state = State.Dragging + alpha = 1f + gridAnimator = ObjectAnimator.ofFloat(0f, 1f).setDuration(longPressTimeout.toLong()) + gridAnimator?.addUpdateListener { animation -> + alpha = animation.animatedValue as Float + invalidate() + } + gridAnimator?.start() + } + + fun tryGetFrontView(targetActivity: Activity) { + traverse(targetActivity.getFrontView()) + } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + when (event!!.action) { + MotionEvent.ACTION_DOWN -> { + handleActionDown(event) + return true + } + + MotionEvent.ACTION_MOVE -> handleActionMove(event) + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> handleActionCancel(event) + } + return super.onTouchEvent(event) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawRect(0f, 0f, measuredWidth.toFloat(), measuredHeight.toFloat(), boundaryPaint) + when (state) { + is State.Dragging -> gridCanvas.draw(canvas) + else -> {} + } + captureCanvas.draw(canvas, targetInspectedView) + dimenCanvas.draw(canvas, targetInspectedView) + } + + private fun handleActionCancel(event: MotionEvent) { + cancelCheckTask() + when (state) { + is State.Idle -> handleClick(event.x, event.y) + is State.Dragging -> resetAll() + else -> {} + } + state = State.Idle + invalidate() + } + + private fun handleActionMove(event: MotionEvent) { + when (state) { + is State.Dragging -> targetInspectedView?.let { + val dx: Float = event.x - prevCoordinate.x + val dy: Float = event.y - prevCoordinate.y + it.offset(dx, dy) + targetInspectedView?.reset() + invalidate() + } + + is State.Touching -> { + } + + else -> { + val dx: Float = event.x - downCoordinate.x + val dy: Float = event.y - downCoordinate.y + if (dx * dx + dy * dy > touchSlop * touchSlop) { + state = State.Touching + cancelCheckTask() + invalidate() + } + } + } + prevCoordinate = CoordinatePair(event.x, event.y) + } + + private fun handleActionDown(event: MotionEvent) { + prevCoordinate = CoordinatePair(event.x, event.y) + downCoordinate = CoordinatePair(event.x, event.y) + tryStartCheckTask() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + inspectedViews.clear() + cancelCheckTask() + targetInspectedView = null + } + + private fun cancelCheckTask() { + removeCallbacks(longPressCheck) + gridAnimator?.cancel() + gridAnimator = null + } + + private fun tryStartCheckTask() { + cancelCheckTask() + targetInspectedView?.let { + postDelayed(longPressCheck, longPressTimeout.toLong()) + } + } + + private fun handleClick(x: Float, y: Float) { + getInspectedView(x, y)?.let { processViewInspection(it, true) } + } + + fun handleClick(v: View, cancelIfSelected: Boolean): Boolean { + return getInspectedView(v)?.let { + processViewInspection(it, cancelIfSelected) + invalidate() + true + } ?: run { + false + } + } + + @SuppressWarnings("LoopWithTooManyJumpStatements") + fun getInspectedView(x: Float, y: Float): InspectedView? { + var target: InspectedView? = null + for (i in inspectedViews.indices.reversed()) { + val inspectedView = inspectedViews[i] + if (inspectedView.rect.contains(x.toInt(), y.toInt())) { + if (isParentNotVisible(inspectedView.parent)) { + continue + } + target = inspectedView + break + } + } + if (target == null) { + Log.w(TAG, "getInspectedView: not find") + } + return target + } + + private fun getInspectedView(v: View): InspectedView? { + var target: InspectedView? = null + for (i in inspectedViews.indices.reversed()) { + val inspectedView = inspectedViews[i] + if (inspectedView.view === v) { + target = inspectedView + break + } + } + if (target == null) { + Log.w(TAG, "getInspectedView: not find") + } + return target + } + + private fun isParentNotVisible(parent: InspectedView?): Boolean { + if (parent == null) { + return false + } + return if (parent.rect.left >= measuredWidth || parent.rect.top >= measuredHeight) { + true + } else { + isParentNotVisible(parent.parent) + } + } + + private fun processViewInspection(inspectedView: InspectedView, cancelIfSelected: Boolean) { + targetInspectedView = if (targetInspectedView == inspectedView && cancelIfSelected) { + null + } else { + inspectedView + } + dimenCanvas.inspectedView = targetInspectedView + clickListener?.onClick(inspectedView.view) + } + + fun isSelectedEmpty(): Boolean = targetInspectedView == null + + override fun setOnClickListener(l: OnClickListener?) { + clickListener = l + } + + companion object { + private const val TAG = "InspectOverlay" + } + + private data class CoordinatePair(val x: Float = 0f, val y: Float = 0f) + + private sealed class State { + object Idle : State() + object Touching : State() // trigger move before dragging + object Dragging : State() // since long press + } + + private fun traverse(view: View) { + if (view.alpha == 0f || view.visibility != VISIBLE) return + inspectedViews.add(InspectedView(view)) + if (view is ViewGroup) { + view.children.forEach { + traverse(it) + } + } + } + + private fun resetAll() { + inspectedViews.forEach { it.reset() } + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/InspectViewModel.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/InspectViewModel.kt new file mode 100644 index 000000000..d9b468fc4 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/InspectViewModel.kt @@ -0,0 +1,18 @@ +package com.pluto.plugins.layoutinspector.internal.inspect + +import android.app.Application +import android.view.View +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import com.pluto.utilities.SingleLiveEvent + +internal class InspectViewModel(application: Application) : AndroidViewModel(application) { + + val view: LiveData + get() = _view + private val _view = SingleLiveEvent() + + fun select(view: View) { + _view.postValue(view) + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/InspectedView.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/InspectedView.kt new file mode 100644 index 000000000..4cb54733b --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/InspectedView.kt @@ -0,0 +1,47 @@ +package com.pluto.plugins.layoutinspector.internal.inspect + +import android.graphics.Rect +import android.view.View + +internal class InspectedView(val view: View) { + + private val originRect: Rect = Rect() + val rect: Rect = Rect() + private val location = IntArray(2) + val parent: InspectedView? + get() { + val parentView: Any = view.parent + return if (parentView is View) { + InspectedView(parentView) + } else { + null + } + } + + init { + reset() + originRect.set(rect.left, rect.top, rect.right, rect.bottom) + } + + fun reset() { + view.getLocationOnScreen(location) + val left = location[0] + val right = left + view.width + val top = location[1] + val bottom = top + view.height + rect.set(left, top, right, bottom) + } + + fun offset(dx: Float, dy: Float) { + view.translationX = view.translationX + dx + view.translationY = view.translationY + dy + } + + override fun equals(other: Any?): Boolean { +// if (this === other) return true +// if (other == null || javaClass != other.javaClass) return false + return other is InspectedView && view == other.view + } + + override fun hashCode(): Int = view.hashCode() +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/ViewUtilsKtx.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/ViewUtilsKtx.kt new file mode 100644 index 000000000..1664a1ffd --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/ViewUtilsKtx.kt @@ -0,0 +1,119 @@ +package com.pluto.plugins.layoutinspector.internal.inspect + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ContextWrapper +import android.content.res.Resources +import android.os.Build +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.core.view.children +import com.pluto.plugins.layoutinspector.R +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.getIdInfo +import com.pluto.utilities.spannable.createSpan +import java.lang.reflect.Field + +@SuppressLint("SoonBlockedPrivateApi", "DiscouragedPrivateApi", "PrivateApi") +@SuppressWarnings("TooGenericExceptionCaught", "NestedBlockDepth") +internal fun Activity.getFrontView(): View { + try { + val windowManagerImplClazz: Class<*> = Class.forName("android.view.WindowManagerImpl") + val windowManagerGlobalClazz: Class<*> = Class.forName("android.view.WindowManagerGlobal") + val viewRootImplClazz: Class<*> = Class.forName("android.view.ViewRootImpl") + + val globalField: Field = windowManagerImplClazz.getDeclaredField("mGlobal") + globalField.isAccessible = true + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { + val viewField = windowManagerGlobalClazz.getDeclaredField("mViews") + viewField.isAccessible = true + (viewField[globalField[windowManager]] as List).reversed().forEach { view -> + getDecorView(view)?.let { + return it + } + } + } else { + val rootsField: Field = windowManagerGlobalClazz.getDeclaredField("mRoots") + rootsField.isAccessible = true + val viewRootImplList: List<*> = rootsField[globalField[windowManager]] as List<*> + viewRootImplList.reversed().forEach { rootImpl -> + val windowAttributesField: Field = viewRootImplClazz.getDeclaredField("mWindowAttributes") + windowAttributesField.isAccessible = true + val viewField: Field = viewRootImplClazz.getDeclaredField("mView") + viewField.isAccessible = true + val decorView = viewField[rootImpl] as View + val layoutParams = windowAttributesField[rootImpl] as WindowManager.LayoutParams + if (layoutParams.title.toString().contains(javaClass.name) || getDecorView(decorView) != null) { + return decorView + } + } + } + } catch (e: Throwable) { + e.printStackTrace() + } + return window.peekDecorView() +} + +@SuppressWarnings("LoopWithTooManyJumpStatements") +private fun Activity.getDecorView(decorView: View): View? { + var targetView: View? = null + var context = decorView.context + if (context === this) { + targetView = decorView + } else { + while (context is ContextWrapper && context !is Activity) { + val baseContext = context.baseContext ?: break + if (baseContext === this) { + targetView = decorView + break + } + context = baseContext + } + } + return targetView +} + +internal fun View.getIdString(): CharSequence? = try { + getIdInfo()?.let { + context?.createSpan { + append(semiBold(fontColor(it.packageName, context.color(com.pluto.plugin.R.color.pluto___text_dark_60)))) + append(semiBold(fontColor(":", context.color(com.pluto.plugin.R.color.pluto___text_dark_60)))) + append(semiBold(fontColor(it.typeName, context.color(com.pluto.plugin.R.color.pluto___text_dark_60)))) + append(semiBold(fontColor("/", context.color(com.pluto.plugin.R.color.pluto___text_dark_60)))) + append(semiBold(fontColor(it.entryName, context.color(com.pluto.plugin.R.color.pluto___text_dark_80)))) + } ?: run { null } + } ?: run { null } +} catch (e: Resources.NotFoundException) { + e.printStackTrace() + Integer.toHexString(id) +} + +internal fun View.findViewByTargetTag(): View? { + if (verifyTargetTag()) { + return this + } + if (this is ViewGroup) { + children.forEach { child -> + child.findViewByTargetTag()?.let { + return it + } + } + } + return null +} + +internal fun View.clearTargetTag() { + setTag(TARGET_VIEW_TAG_LABEL, null) +} + +internal fun View.assignTargetTag() { + setTag(TARGET_VIEW_TAG_LABEL, Any()) +} + +internal fun View.verifyTargetTag(): Boolean { + return getTag(TARGET_VIEW_TAG_LABEL) != null +} + +private val TARGET_VIEW_TAG_LABEL = R.id.pluto_li___unique_view_tag diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/canvas/CaptureCanvas.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/canvas/CaptureCanvas.kt new file mode 100644 index 000000000..42b380445 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/canvas/CaptureCanvas.kt @@ -0,0 +1,83 @@ +package com.pluto.plugins.layoutinspector.internal.inspect.canvas + +import android.graphics.Canvas +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.Rect +import android.view.View +import com.pluto.plugin.settings.SettingsPreferences +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.internal.inspect.InspectedView +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp2px + +internal class CaptureCanvas(private val container: View) { + + private val cornerCirclePaint: Paint = object : Paint() { + init { + isAntiAlias = true + strokeWidth = 1f.dp2px + } + } + private val captureBoxPaint: Paint = object : Paint() { + init { + isAntiAlias = true + color = container.context.color(com.pluto.plugin.R.color.pluto___blue) + style = Style.STROKE + strokeWidth = 1f.dp2px + } + } + private val dashLinePaint: Paint = object : Paint() { + init { + isAntiAlias = true + color = container.context.color(com.pluto.plugin.R.color.pluto___emerald) + style = Style.STROKE + strokeWidth = 1f + pathEffect = DashPathEffect(floatArrayOf(3f.dp2px, 3f.dp2px), 0f) + } + } + private val cornerRadius: Float = 1f.dp2px + + private val measuredHeight: Float + get() = container.measuredHeight.toFloat() + + private val measuredWidth: Float + get() = container.measuredWidth.toFloat() + + init { + container.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + } + + fun draw(canvas: Canvas, inspectedView: InspectedView?) { + canvas.save() + inspectedView?.let { drawSelected(canvas, it) } + canvas.restore() + } + + private fun drawSelected(canvas: Canvas, inspectedView: InspectedView) { + val rect: Rect = inspectedView.rect + canvas.drawLine(0f, rect.top.toFloat(), measuredWidth, rect.top.toFloat(), dashLinePaint) + canvas.drawLine(0f, rect.bottom.toFloat(), measuredWidth, rect.bottom.toFloat(), dashLinePaint) + canvas.drawLine(rect.left.toFloat(), 0f, rect.left.toFloat(), measuredHeight, dashLinePaint) + canvas.drawLine(rect.right.toFloat(), 0f, rect.right.toFloat(), measuredHeight, dashLinePaint) + canvas.drawRect(rect, captureBoxPaint) + cornerCirclePaint.color = container.context.color(com.pluto.plugin.R.color.pluto___transparent) + cornerCirclePaint.style = Paint.Style.FILL + canvas.drawCircle(rect.left.toFloat(), rect.top.toFloat(), cornerRadius, cornerCirclePaint) + canvas.drawCircle(rect.right.toFloat(), rect.top.toFloat(), cornerRadius, cornerCirclePaint) + canvas.drawCircle(rect.left.toFloat(), rect.bottom.toFloat(), cornerRadius, cornerCirclePaint) + canvas.drawCircle(rect.right.toFloat(), rect.bottom.toFloat(), cornerRadius, cornerCirclePaint) + cornerCirclePaint.color = container.context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___red + } else { + com.pluto.plugin.R.color.pluto___orange + } + ) + cornerCirclePaint.style = Paint.Style.STROKE + canvas.drawCircle(rect.left.toFloat(), rect.top.toFloat(), cornerRadius, cornerCirclePaint) + canvas.drawCircle(rect.right.toFloat(), rect.top.toFloat(), cornerRadius, cornerCirclePaint) + canvas.drawCircle(rect.left.toFloat(), rect.bottom.toFloat(), cornerRadius, cornerCirclePaint) + canvas.drawCircle(rect.right.toFloat(), rect.bottom.toFloat(), cornerRadius, cornerCirclePaint) + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/canvas/DimensionCanvas.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/canvas/DimensionCanvas.kt new file mode 100644 index 000000000..2b5a52e71 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/canvas/DimensionCanvas.kt @@ -0,0 +1,101 @@ +package com.pluto.plugins.layoutinspector.internal.inspect.canvas + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.view.View +import androidx.core.content.res.ResourcesCompat +import com.pluto.plugins.layoutinspector.R +import com.pluto.plugins.layoutinspector.internal.inspect.InspectedView +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp2px +import com.pluto.utilities.extensions.px2dp + +internal class DimensionCanvas(private val container: View) { + + private val cornerRadius = 3f.dp2px + private val textBgTopPadding = 4f.dp2px + private val textBgBottomPadding = 1f.dp2px + private val textBgHorizontalPadding = 4f.dp2px + private val distanceBtwTextAndBorder = 7f.dp2px + + private val textPaint: Paint = object : Paint() { + init { + color = container.context.color(com.pluto.plugin.R.color.pluto___red_dark) + style = Style.FILL + strokeWidth = 1f.dp2px + textSize = 12f.dp2px + typeface = ResourcesCompat.getFont(container.context, com.pluto.plugin.R.font.muli_semibold) + flags = FAKE_BOLD_TEXT_FLAG + } + } + private val textBgPaint: Paint = object : Paint() { + init { + isAntiAlias = true + strokeWidth = 1f.dp2px + color = container.context.color(com.pluto.plugin.R.color.pluto___white) + style = Style.FILL + } + } + + var inspectedView: InspectedView? = null + + fun draw(canvas: Canvas, inspectedView: InspectedView?) { + canvas.save() + inspectedView?.let { + this.inspectedView?.let { + val rect: Rect = it.rect + val widthText = "${rect.width().toFloat().px2dp.toInt()} dp" + drawText(canvas, widthText, rect.centerX() - getTextWidth(textPaint, widthText) / 2, rect.top - distanceBtwTextAndBorder + 3f.dp2px) + val heightText = "${rect.height().toFloat().px2dp.toInt()} dp" + drawText(canvas, heightText, rect.right + distanceBtwTextAndBorder, rect.centerY() + getTextHeight(textPaint, heightText) / 2) + } ?: run { + container.invalidate() + return + } + } + canvas.restore() + } + + private fun drawText(canvas: Canvas, text: String, x: Float, y: Float) { + var left = x - textBgHorizontalPadding + var top: Float = y - getTextHeight(textPaint, text) - textBgTopPadding + var right: Float = x + getTextWidth(textPaint, text) + textBgHorizontalPadding + var bottom = y + textBgBottomPadding + // ensure text in screen bound + if (left < 0) { + right -= left + left = 0f + } + if (top < 0) { + bottom -= top + top = 0f + } + if (bottom > canvas.height) { + val diff = top - bottom + bottom = canvas.height.toFloat() + top = bottom + diff + } + if (right > canvas.width) { + val diff = left - right + right = canvas.width.toFloat() + left = right + diff + } + val tmpRectF = RectF().apply { + set(left, top, right, bottom) + } + canvas.drawRoundRect(tmpRectF, cornerRadius, cornerRadius, textBgPaint) + canvas.drawText(text, left + textBgHorizontalPadding, bottom - textBgTopPadding, textPaint) + } + + private fun getTextHeight(paint: Paint, text: String): Float { + val rect = Rect() + paint.getTextBounds(text, 0, text.length, rect) + return rect.height().toFloat() + } + + private fun getTextWidth(paint: Paint, text: String?): Float { + return paint.measureText(text) + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/canvas/GridCanvas.kt b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/canvas/GridCanvas.kt new file mode 100644 index 000000000..71f3f385e --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/java/com/pluto/plugins/layoutinspector/internal/inspect/canvas/GridCanvas.kt @@ -0,0 +1,40 @@ +package com.pluto.plugins.layoutinspector.internal.inspect.canvas + +import android.graphics.Canvas +import android.graphics.Paint +import android.view.View +import com.pluto.plugin.settings.SettingsPreferences +import com.pluto.plugins.layoutinspector.R +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp2px + +internal class GridCanvas(private val container: View) { + private val gridPaint: Paint = object : Paint() { + init { + color = container.context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___red_40 + } else { + com.pluto.plugin.R.color.pluto___orange_40 + } + ) + style = Style.FILL + strokeWidth = 1f.dp2px + } + } + + fun draw(canvas: Canvas) { + canvas.save() + var startX = 0 + while (startX < container.measuredWidth) { + canvas.drawLine(startX.toFloat().dp2px, 0f, startX.toFloat().dp2px, container.measuredHeight.toFloat(), gridPaint) + startX += SettingsPreferences.gridSize + } + var startY = 0 + while (startY < container.measuredHeight) { + canvas.drawLine(0f, startY.toFloat().dp2px, container.measuredWidth.toFloat(), startY.toFloat().dp2px, gridPaint) + startY += SettingsPreferences.gridSize + } + canvas.restore() + } +} diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_arrow_back.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_arrow_back.xml new file mode 100644 index 000000000..bd6a5a9cb --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_close.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_close.xml new file mode 100644 index 000000000..23ef1c507 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_close.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_close.xml new file mode 100644 index 000000000..ae90a27e1 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_hint.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_hint.xml new file mode 100644 index 000000000..333d22a71 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_hint.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_move_left.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_move_left.xml new file mode 100644 index 000000000..a9750778e --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_move_left.xml @@ -0,0 +1,14 @@ + + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_move_right.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_move_right.xml new file mode 100644 index 000000000..07d93aca2 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_control_move_right.xml @@ -0,0 +1,14 @@ + + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_attr.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_attr.xml new file mode 100644 index 000000000..71c5d2b5c --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_attr.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_collapse.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_collapse.xml new file mode 100644 index 000000000..4a9433b8f --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_collapse.xml @@ -0,0 +1,12 @@ + + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_expand.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_expand.xml new file mode 100644 index 000000000..9aa5f4f45 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_expand.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_separator.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_separator.xml new file mode 100644 index 000000000..c69a68321 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_separator.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_show_less.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_show_less.xml new file mode 100644 index 000000000..6bdbb481e --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_show_less.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_show_more.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_show_more.xml new file mode 100644 index 000000000..5aeb81e1b --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_show_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_target_view_indicator.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_target_view_indicator.xml new file mode 100644 index 000000000..cf80092f5 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_hierarchy_target_view_indicator.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_plugin_logo.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_plugin_logo.xml new file mode 100644 index 000000000..b0e3adf6e --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_plugin_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_share.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_share.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_share.xml rename to pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_share.xml diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_view_hierarchy.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_view_hierarchy.xml new file mode 100644 index 000000000..ee90a145a --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___ic_view_hierarchy.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___item_divider.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___item_divider.xml new file mode 100644 index 000000000..8e82856f1 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/drawable/pluto_li___item_divider.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___bs_container_preview_panel.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___bs_container_preview_panel.xml new file mode 100644 index 000000000..9b9af5b67 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___bs_container_preview_panel.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___controls_widget.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___controls_widget.xml new file mode 100644 index 000000000..1c42ca22c --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___controls_widget.xml @@ -0,0 +1,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_base.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_base.xml new file mode 100644 index 000000000..a70b98a87 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_base.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_view_attr.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_view_attr.xml new file mode 100644 index 000000000..3473ab9fa --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_view_attr.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_view_hierarchy.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_view_hierarchy.xml new file mode 100644 index 000000000..7b04363e8 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_view_hierarchy.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_view_info.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_view_info.xml new file mode 100644 index 000000000..c13e703ef --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___fragment_view_info.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___hint_fragment.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___hint_fragment.xml new file mode 100644 index 000000000..39b7c3d03 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___hint_fragment.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_control_cta.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_control_cta.xml new file mode 100644 index 000000000..5956fd8fc --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_control_cta.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_hint.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_hint.xml new file mode 100644 index 000000000..f7c1c7bd0 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_hint.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_view_attr.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_view_attr.xml new file mode 100644 index 000000000..e001265cf --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_view_attr.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_view_attr_title.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_view_attr_title.xml new file mode 100644 index 000000000..268bf9239 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_view_attr_title.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_view_hierarchy.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_view_hierarchy.xml new file mode 100644 index 000000000..84a28b18b --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___item_view_hierarchy.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___params_preview_panel.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___params_preview_panel.xml new file mode 100644 index 000000000..a8360deb5 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/layout/pluto_li___params_preview_panel.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/menu/pluto_li___menu_hierarchy_options.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/menu/pluto_li___menu_hierarchy_options.xml new file mode 100644 index 000000000..d3844535a --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/menu/pluto_li___menu_hierarchy_options.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/navigation/pluto_li___navigation.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/navigation/pluto_li___navigation.xml new file mode 100644 index 000000000..0bac4c985 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/navigation/pluto_li___navigation.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/values/ids.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/values/ids.xml new file mode 100644 index 000000000..3a0cf1ffb --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/values/strings.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/values/strings.xml new file mode 100644 index 000000000..43e8a44c7 --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/values/strings.xml @@ -0,0 +1,17 @@ + + + Layout Inspector + How to use + Click on view to select, click again to cancel selection. + Select a view, then long press anywhere to start moving the selected view + View Attributes + See in View Hierarchy + View Hierarchy + Close + See Hint + Open Screen Hierarchy + Move Controls to Right + Move Controls to Left + Inspect View + Show Attributes + \ No newline at end of file diff --git a/pluto-plugins/plugins/layout-inspector/lib/src/main/res/values/styles.xml b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/values/styles.xml new file mode 100644 index 000000000..0b7c0acfc --- /dev/null +++ b/pluto-plugins/plugins/layout-inspector/lib/src/main/res/values/styles.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/README.md b/pluto-plugins/plugins/logger/README.md new file mode 100644 index 000000000..da8ac4500 --- /dev/null +++ b/pluto-plugins/plugins/logger/README.md @@ -0,0 +1,70 @@ +## Integrate Logger Plugin in your application + +[Logger Demo.webm](https://github.com/user-attachments/assets/81b74c84-393c-4cda-a95f-2070d2cb4853) + +### Add Gradle Dependencies +Pluto Logger is distributed through [***mavenCentral***](https://central.sonatype.com/artifact/com.androidpluto.plugins/logger). To use it, you need to add the following Gradle dependency to your build.gradle file of you android app module. + +> Note: add the `no-op` variant to isolate the plugin from release builds. +```groovy +dependencies { + debugImplementation "com.androidpluto.plugins:logger:$plutoVersion" + releaseImplementation "com.androidpluto.plugins:logger-no-op:$plutoVersion" +} +``` +
+ +### Install plugin to Pluto + +Now to start using the plugin, add it to Pluto +```kotlin +Pluto.Installer(this) + .addPlugin(PlutoLoggerPlugin()) + .install() +``` +
+ +### Add Pluto Logs + +Pluto allows you to log and persist the user journey through the app, and help debug them without any need to connect to Logcat. + +- **with PlutoLog** +```kotlin +PlutoLog.event("analytics", eventName, HashMap(attributes)) +PlutoLog.d("debug_log", "button clicked") +PlutoLog.e("error_log", "api call failed with http_status 400") +PlutoLog.w("warning_log", "warning log") +PlutoLog.i("info_log", "api call completed") +``` + +- **with Timber** +```kotlin +Timber.tag("analytics").event(eventName, HashMap(attributes)) +Timber.tag("debug_log").d("button clicked") +Timber.tag("error_log").e(NullPointerException("demo"), "api call failed with http_status 400") +Timber.tag("warning_log").w(NullPointerException("demo"), "warning log") +Timber.i("api call completed") +``` +Install Pluto as a Tree for Timber: +``` +Timber.plant(PlutoTimberTree()); +``` + +But if you are connected to Logcat, PlutoLogs behave similar to Log class, with an improvement to tag the method and file name also. In Logcat, PlutoLogs will look like the following. +``` +D/onClick(MainActivity.kt:40) | debug_log: button clicked +E/onFailure(NetworkManager.kt:17) | error_log: api call falied with http_status 400 +``` +
+ +πŸŽ‰  You are all done! + +Now re-build and run your app and open Pluto, you will see the Logger plugin installed. + +
+ +### Open Plugin view programmatically +To open Logger plugin screen via code, use this +```kotlin +Pluto.open(PlutoLoggerPlugin.ID) +``` diff --git a/pluto-plugins/plugins/logger/lib-no-op/.gitignore b/pluto-plugins/plugins/logger/lib-no-op/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib-no-op/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/lib-no-op/build.gradle.kts b/pluto-plugins/plugins/logger/lib-no-op/build.gradle.kts new file mode 100644 index 000000000..b94d835f9 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib-no-op/build.gradle.kts @@ -0,0 +1,93 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.logger" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "logger-no-op" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Logger Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to monitor logs in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + api(libs.timber) +} diff --git a/pluto-plugins/plugins/logger/lib-no-op/src/main/AndroidManifest.xml b/pluto-plugins/plugins/logger/lib-no-op/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib-no-op/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/lib-no-op/src/main/java/com/pluto/plugins/logger/PlutoLog.kt b/pluto-plugins/plugins/logger/lib-no-op/src/main/java/com/pluto/plugins/logger/PlutoLog.kt new file mode 100644 index 000000000..106999052 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib-no-op/src/main/java/com/pluto/plugins/logger/PlutoLog.kt @@ -0,0 +1,32 @@ +package com.pluto.plugins.logger + +import androidx.annotation.Keep + +@Keep +@SuppressWarnings("UnusedPrivateMember", "EmptyFunctionBlock") +class PlutoLog private constructor() { + + companion object { + + @JvmStatic + fun v(tag: String, message: String, tr: Throwable? = null) {} + + @JvmStatic + fun d(tag: String, message: String, tr: Throwable? = null) {} + + @JvmStatic + fun i(tag: String, message: String, tr: Throwable? = null) {} + + @JvmStatic + fun w(tag: String, message: String, tr: Throwable? = null) {} + + @JvmStatic + fun e(tag: String, message: String, tr: Throwable? = null) {} + + @JvmStatic + fun wtf(tag: String, message: String, tr: Throwable? = null) {} + + @JvmStatic + fun event(tag: String, event: String, attributes: HashMap? = null) {} + } +} diff --git a/pluto-plugins/plugins/logger/lib-no-op/src/main/java/com/pluto/plugins/logger/PlutoLoggerPlugin.kt b/pluto-plugins/plugins/logger/lib-no-op/src/main/java/com/pluto/plugins/logger/PlutoLoggerPlugin.kt new file mode 100644 index 000000000..5f47ee964 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib-no-op/src/main/java/com/pluto/plugins/logger/PlutoLoggerPlugin.kt @@ -0,0 +1,8 @@ +package com.pluto.plugins.logger + +@SuppressWarnings("UnusedPrivateMember") +class PlutoLoggerPlugin @JvmOverloads constructor(identifier: String = ID) { + companion object { + const val ID = "logger" + } +} diff --git a/pluto-plugins/plugins/logger/lib-no-op/src/main/java/com/pluto/plugins/logger/PlutoTimberTree.kt b/pluto-plugins/plugins/logger/lib-no-op/src/main/java/com/pluto/plugins/logger/PlutoTimberTree.kt new file mode 100644 index 000000000..49957456b --- /dev/null +++ b/pluto-plugins/plugins/logger/lib-no-op/src/main/java/com/pluto/plugins/logger/PlutoTimberTree.kt @@ -0,0 +1,11 @@ +package com.pluto.plugins.logger + +import timber.log.Timber + +class PlutoTimberTree : Timber.Tree() { + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {} +} + +@SuppressWarnings("UnusedPrivateMember", "EmptyFunctionBlock") +fun Timber.Tree.event(event: String, attr: HashMap? = null) {} diff --git a/pluto-plugins/plugins/logger/lib/.gitignore b/pluto-plugins/plugins/logger/lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/lib/build.gradle.kts b/pluto-plugins/plugins/logger/lib/build.gradle.kts new file mode 100644 index 000000000..a1d09d092 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/build.gradle.kts @@ -0,0 +1,107 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.ksp) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + resourcePrefix = "pluto_logger___" + namespace = "com.pluto.plugins.logger" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"$verPublish\"") + buildConfigField("long", "VERSION_CODE", "$verCode") + buildConfigField("String", "GIT_SHA", "\"$verGitSHA\"") + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "logger" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Logger Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to monitor logs in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(project(":pluto-plugins:base:lib")) + implementation(libs.androidx.navigation.ui) + api(libs.timber) + implementation(libs.moshi) + ksp(libs.moshi.codegen) + + implementation(libs.room) + ksp(libs.room.compiler) +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/AndroidManifest.xml b/pluto-plugins/plugins/logger/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/FilterViewModel.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/FilterViewModel.kt new file mode 100644 index 000000000..b503db8f9 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/FilterViewModel.kt @@ -0,0 +1,119 @@ +package com.pluto.plugins.logger + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import com.pluto.plugins.logger.internal.LogTimeStamp +import com.pluto.plugins.logger.internal.LogType +import com.pluto.plugins.logger.internal.Session +import com.pluto.utilities.selector.SelectorOption + +internal class FilterViewModel(application: Application) : AndroidViewModel(application) { + val selectedFiltersList: LiveData> + get() = _selectedFiltersList + private val _selectedFiltersList = MutableLiveData>() + + val selectedTimeStamp: LiveData + get() = _selectedTimeStamp + private val _selectedTimeStamp = MutableLiveData() + + val searchTextLogger: LiveData + get() = _searchTextLogger + private val _searchTextLogger = MutableLiveData() + + private val preferences = Preferences(application) + private val logTypes = listOf( + LogType("debug"), + LogType("verbose"), + LogType("error"), + LogType("info"), + LogType("event") + ) + + private val timeStamps = listOf( + LogTimeStamp(1), + LogTimeStamp(5), + LogTimeStamp(10), + LogTimeStamp(Integer.MIN_VALUE, true) + ) + + val isTriggerSearch: LiveData + get() = _isTriggerSearch + private val _isTriggerSearch = MediatorLiveData() + val isFilterApplied: LiveData + get() = _isFilterApplied + private val _isFilterApplied = MediatorLiveData() + + val isFilterVisible: LiveData + get() = _isFilterVisible + private val _isFilterVisible = MediatorLiveData() + + init { + + _isTriggerSearch.addSource(_selectedFiltersList) { _isTriggerSearch.postValue(true) } + _isTriggerSearch.addSource(_searchTextLogger) { _isTriggerSearch.postValue(true) } + _isTriggerSearch.addSource(_selectedTimeStamp) { _isTriggerSearch.postValue(true) } + _searchTextLogger.postValue(Session.loggerSearchText) + _selectedFiltersList.postValue(preferences.selectedFilterLogType) + _selectedTimeStamp.postValue(preferences.selectedFilterTime) + _isFilterApplied.addSource(_selectedFiltersList) { + if (it.isNotEmpty() || getSelectedTimeStamp().timeStamp != 0) { + _isFilterApplied.postValue(true) + } else { + _isFilterApplied.postValue(false) + } + } + _isFilterApplied.addSource(_selectedTimeStamp) { + if (it.timeStamp != 0 || getSelectedFilters().isNotEmpty()) { + _isFilterApplied.postValue(true) + } else { + _isFilterApplied.postValue(false) + } + } + _isFilterVisible.postValue(false) + } + + fun getLogTypes(): List { + return logTypes + } + + fun getTimeStamps(): List { + return timeStamps + } + + fun getSelectedFilters(): List { + return selectedFiltersList.value ?: emptyList() + } + + fun getSelectedTimeStamp(): LogTimeStamp { + return selectedTimeStamp.value ?: LogTimeStamp(0, false) + } + + fun updateSearchText(searchText: String) { + _searchTextLogger.postValue(searchText) + Session.loggerSearchText = searchText + } + + fun setSelectedFiltersLogType(logTypeList: ArrayList) { + _selectedFiltersList.postValue(logTypeList) + preferences.selectedFilterLogType = logTypeList + } + fun setSelectedFilterTimeStamp(logTimeStamp: LogTimeStamp) { + _selectedTimeStamp.postValue(logTimeStamp) + preferences.selectedFilterTime = logTimeStamp + } + fun getSearchText(): String { + return searchTextLogger.value ?: "" + } + + fun toggleFilterViewVisibility() { + _isFilterVisible.postValue(_isFilterVisible.value?.not()) + } + fun clearFilters() { + preferences.clearFilters() + _selectedFiltersList.postValue(emptyList()) + _selectedTimeStamp.postValue(LogTimeStamp(0, false)) + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/PlutoLog.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/PlutoLog.kt new file mode 100644 index 000000000..e6d104e66 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/PlutoLog.kt @@ -0,0 +1,64 @@ +package com.pluto.plugins.logger + +import android.util.Log +import androidx.annotation.Keep +import com.pluto.plugins.logger.internal.LogsProcessor.Companion.process +import com.pluto.plugins.logger.internal.LogsProcessor.Companion.processEvent +import com.pluto.plugins.logger.internal.LogsProcessor.Companion.stackTraceElement + +@Keep +class PlutoLog private constructor() { + + companion object { + + @JvmStatic + @JvmOverloads + fun v(tag: String, message: String, tr: Throwable? = null) { + process(Log.VERBOSE, tag, message, tr, Thread.currentThread().getStackTraceElement()) + } + + @JvmStatic + @JvmOverloads + fun d(tag: String, message: String, tr: Throwable? = null) { + process(Log.DEBUG, tag, message, tr, Thread.currentThread().getStackTraceElement()) + } + + @JvmStatic + @JvmOverloads + fun i(tag: String, message: String, tr: Throwable? = null) { + process(Log.INFO, tag, message, tr, Thread.currentThread().getStackTraceElement()) + } + + @JvmStatic + @JvmOverloads + fun w(tag: String, message: String, tr: Throwable? = null) { + process(Log.WARN, tag, message, tr, Thread.currentThread().getStackTraceElement()) + } + + @JvmStatic + @JvmOverloads + fun e(tag: String, message: String, tr: Throwable? = null) { + process(Log.ERROR, tag, message, tr, Thread.currentThread().getStackTraceElement()) + } + + @JvmStatic + @JvmOverloads + fun wtf(tag: String, message: String, tr: Throwable? = null) { + process(Log.ASSERT, tag, message, tr, Thread.currentThread().getStackTraceElement()) + } + + @JvmStatic + @JvmOverloads + fun event(tag: String, event: String, attributes: HashMap? = null) { + processEvent(tag, event, attributes, Thread.currentThread().getStackTraceElement()) + } + + private fun Thread.getStackTraceElement(): StackTraceElement { + val index = if (name == "main") MAIN_THREAD_INDEX else DAEMON_THREAD_INDEX + return stackTraceElement(index) + } + + private const val MAIN_THREAD_INDEX = 6 + private const val DAEMON_THREAD_INDEX = 5 + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/PlutoLoggerPlugin.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/PlutoLoggerPlugin.kt new file mode 100644 index 000000000..69f8f257d --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/PlutoLoggerPlugin.kt @@ -0,0 +1,46 @@ +package com.pluto.plugins.logger + +import androidx.fragment.app.Fragment +import com.pluto.plugin.DeveloperDetails +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginConfiguration +import com.pluto.plugins.logger.internal.LogsFragment +import com.pluto.plugins.logger.internal.persistence.LogDBHandler + +class PlutoLoggerPlugin() : Plugin(ID) { + + @SuppressWarnings("UnusedPrivateMember") + @Deprecated("Use the default constructor PlutoLoggerPlugin() instead.") + constructor(identifier: String) : this() + + private val settingsPreferences by lazy { Preferences(application) } + + override fun getConfig(): PluginConfiguration = PluginConfiguration( + name = context.getString(R.string.pluto_logger___plugin_name), + icon = R.drawable.pluto_logger___ic_logger_icon, + version = BuildConfig.VERSION_NAME + ) + + override fun getView(): Fragment = LogsFragment() + + override fun getDeveloperDetails(): DeveloperDetails { + return DeveloperDetails( + website = "https://androidpluto.com", + vcsLink = "https://github.com/androidPluto/pluto", + twitter = "https://twitter.com/android_pluto" + ) + } + + override fun onPluginInstalled() { + LogDBHandler.initialize(context) + } + + override fun onPluginDataCleared() { + LogDBHandler.flush() + settingsPreferences.clearFilters() + } + + companion object { + const val ID = "logger" + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/PlutoTimberTree.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/PlutoTimberTree.kt new file mode 100644 index 000000000..a15628eec --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/PlutoTimberTree.kt @@ -0,0 +1,77 @@ +package com.pluto.plugins.logger + +import com.pluto.plugins.logger.internal.LogsProcessor +import com.pluto.plugins.logger.internal.LogsProcessor.Companion.LOG_EVENT_PRIORITY +import com.pluto.plugins.logger.internal.LogsProcessor.Companion.stackTraceElement +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.math.BigDecimal +import timber.log.Timber + +class PlutoTimberTree : Timber.Tree() { + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + if (priority == LOG_EVENT_PRIORITY) { + val eventData = eventExtractor(message) + LogsProcessor.processEvent(tag ?: "pluto_timber", eventData.first, eventData.second, Thread.currentThread().getStackTraceElement()) + } else { + LogsProcessor.process(priority, tag ?: "pluto_timber", messageExtractor(message), t, Thread.currentThread().getStackTraceElement()) + } + } + + @SuppressWarnings("NestedBlockDepth") + private fun eventExtractor(message: String): Pair?> { + val length = message.length + var newline = message.indexOf('\t', 0) + newline = if (newline != -1) newline else length + val end = newline.coerceAtMost(MAX_LOG_LENGTH) + + val event = message.substring(0, end) + val attrString = if (end < length) { + val moshi = Moshi.Builder().build() + val moshiAdapter: JsonAdapter?> = moshi.adapter(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)) + val map = moshiAdapter.fromJson(message.substring(end + 1, length)) + val hashMap = HashMap() + if (!map.isNullOrEmpty()) { + map.entries.forEach { + hashMap[it.key] = when (it.value) { + is Double -> BigDecimal(it.value.toString()).longValueExact() + else -> it.value + } + } + hashMap + } else { + null + } + } else { + null + } + return Pair(event, attrString) + } + + private fun messageExtractor(message: String): String { + val length = message.length + var newline = message.indexOf('\n', 0) + newline = if (newline != -1) newline else length + val end = newline.coerceAtMost(MAX_LOG_LENGTH) + return message.substring(0, end) + } + + private fun Thread.getStackTraceElement(): StackTraceElement { + val index = if (name == "main") MAIN_THREAD_INDEX else DAEMON_THREAD_INDEX + return stackTraceElement(index) + } + + companion object { + private const val MAX_LOG_LENGTH = 4000 + private const val MAIN_THREAD_INDEX = 9 + private const val DAEMON_THREAD_INDEX = 8 + } +} + +fun Timber.Tree.event(event: String, attributes: HashMap? = null) { + val moshi = Moshi.Builder().build() + val moshiAdapter: JsonAdapter?> = moshi.adapter(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)) + log(LOG_EVENT_PRIORITY, "$event\t${moshiAdapter.toJson(attributes)}") +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/Preferences.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/Preferences.kt new file mode 100644 index 000000000..730c87860 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/Preferences.kt @@ -0,0 +1,45 @@ +package com.pluto.plugins.logger + +import android.content.Context +import com.pluto.plugins.logger.internal.LogTimeStamp +import com.pluto.plugins.logger.internal.LogType +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types + +internal class Preferences(context: Context) { + + private val settingsPrefs by lazy { context.preferences("_pluto_log_filter_settings") } + private val moshi: Moshi = Moshi.Builder().build() + private val moshiAdapter: JsonAdapter> = + moshi.adapter(Types.newParameterizedType(List::class.java, String::class.java)) + + private var timeStampAdapter: JsonAdapter = + moshi.adapter(LogTimeStamp::class.java) + + internal var selectedFilterLogType: List + get() = settingsPrefs.getString(SELECTED_FILTER_LOG_TYPE, null)?.let { + moshiAdapter.fromJson(it)?.map { type -> LogType(type) } + } ?: run { emptyList() } + set(value) = settingsPrefs.edit() + .putString(SELECTED_FILTER_LOG_TYPE, moshiAdapter.toJson(value.map { it.type })).apply() + + internal var selectedFilterTime: LogTimeStamp + get() = settingsPrefs.getString(SELECTED_FILTER_TIMESTAMP, null)?.let { + timeStampAdapter.fromJson(it) + } ?: run { LogTimeStamp(0, false) } + set(value) = settingsPrefs.edit() + .putString(SELECTED_FILTER_TIMESTAMP, timeStampAdapter.toJson(value)).apply() + + companion object { + private const val SELECTED_FILTER_LOG_TYPE = "selected_filter_logtype" + private const val SELECTED_FILTER_TIMESTAMP = "selected_filter_timestamp" + } + + fun clearFilters() { + settingsPrefs.edit().clear().apply() + } +} + +private fun Context.preferences(name: String, mode: Int = Context.MODE_PRIVATE) = + getSharedPreferences(name, mode) diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/DataModel.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/DataModel.kt new file mode 100644 index 000000000..5bcc72b79 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/DataModel.kt @@ -0,0 +1,81 @@ +package com.pluto.plugins.logger.internal + +import androidx.annotation.DrawableRes +import androidx.annotation.Keep +import com.pluto.plugins.logger.R +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.selector.SelectorOption +import com.squareup.moshi.JsonClass + +@Keep +@JsonClass(generateAdapter = true) +internal open class Level( + val label: String, + val color: Int = com.pluto.plugin.R.color.pluto___transparent, + val textColor: Int = com.pluto.plugin.R.color.pluto___text_dark_60, + @DrawableRes val iconRes: Int = 0 +) { + object Verbose : Level("verbose") + object Debug : Level("debug") + object Info : Level("info") + object Warning : Level("warning") + object WTF : Level("wtf") + object Error : Level("error", com.pluto.plugin.R.color.pluto___red_05, com.pluto.plugin.R.color.pluto___red_80) + object Event : Level( + label = "event", + iconRes = R.drawable.pluto_logger___ic_analytics, + textColor = com.pluto.plugin.R.color.pluto___blue + ) +} + +@Keep +@JsonClass(generateAdapter = true) +internal data class LogData( + val level: Level, + val tag: String, + val message: String, + val tr: ExceptionData? = null, + val stackTrace: StackTrace, + val eventAttributes: Map? = null, + val timeStamp: Long = System.currentTimeMillis() +) : ListItem() + +@Keep +internal data class LogPreviousSessionHeader( + val label: String = "header" +) : ListItem() + +@Keep +@JsonClass(generateAdapter = true) +internal data class StackTrace( + val methodName: String, + val fileName: String, + val lineNumber: Int, +) + +@Keep +@JsonClass(generateAdapter = true) +internal data class LogType( + val type: String +) : SelectorOption() { + override fun displayText(): CharSequence { + return type + } +} + +@Keep +@JsonClass(generateAdapter = true) +internal data class LogTimeStamp( + val timeStamp: Int = 0, + val isSessionFilter: Boolean = false +) : SelectorOption() { + override fun displayText(): CharSequence { + if (isSessionFilter) { + return "Current session only" + } + if (timeStamp == 1) { + return "< $timeStamp minute" + } + return "< $timeStamp minutes" + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/LogsFragment.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/LogsFragment.kt new file mode 100644 index 000000000..ad3b59be5 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/LogsFragment.kt @@ -0,0 +1,6 @@ +package com.pluto.plugins.logger.internal + +import androidx.fragment.app.Fragment +import com.pluto.plugins.logger.R + +internal class LogsFragment : Fragment(R.layout.pluto_logger___fragment_logs) diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/LogsProcessor.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/LogsProcessor.kt new file mode 100644 index 000000000..6b5afe719 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/LogsProcessor.kt @@ -0,0 +1,78 @@ +package com.pluto.plugins.logger.internal + +import android.util.Log +import androidx.annotation.Keep +import com.pluto.plugins.logger.BuildConfig +import com.pluto.plugins.logger.internal.persistence.LogDBHandler +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types + +internal class LogsProcessor private constructor() { + + companion object { + + fun process(priority: Int, tag: String, message: String, tr: Throwable?, stackTraceElement: StackTraceElement) { + val stackTrace = stackTraceElement.stackTrace() + LogDBHandler.persistLog(priority2Level(priority), tag, message, tr, stackTrace) + consolePrint(priority2Level(priority), tag, message, tr, stackTrace) + } + + fun processEvent(tag: String, event: String, attr: HashMap?, stackTraceElement: StackTraceElement) { + val moshi = Moshi.Builder().build() + val moshiAdapter: JsonAdapter?> = moshi.adapter(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)) + val stackTrace = stackTraceElement.stackTrace() + LogDBHandler.persistEvent(Level.Event, tag, event, attr, stackTrace) + consolePrint(Level.Event, tag, "$event => ${moshiAdapter.toJson(attr)}", null, stackTrace) + } + + @SuppressWarnings("ComplexCondition") + fun Thread.stackTraceElement(index: Int): StackTraceElement { + stackTrace.forEach { + if (!it.className.startsWith(BuildConfig.LIBRARY_PACKAGE_NAME) && + !it.className.startsWith("java.lang.") && + !it.className.startsWith("dalvik.system.") && + it.fileName?.startsWith("Timber.kt") != true + ) { + return it + } + } + return stackTrace[index] + } + + private fun StackTraceElement.stackTrace(): StackTrace { + return StackTrace(this.methodName, this.fileName ?: "Unknown Source", this.lineNumber) + } + + private fun priority2Level(priority: Int): Level { + return when (priority) { + Log.DEBUG -> Level.Debug + Log.ERROR -> Level.Error + Log.INFO -> Level.Info + Log.VERBOSE -> Level.Verbose + Log.WARN -> Level.Warning + Log.ASSERT -> Level.WTF + LOG_EVENT_PRIORITY -> Level.Event + else -> Level.Debug + } + } + + private fun consolePrint(level: Level, tag: String, message: String, tr: Throwable?, trace: StackTrace) { + val newMessage = StringBuilder().append(message).append("\n").append("[logged from: ${trace.formattedStack()}]").toString() + when (level) { + is Level.Debug -> Log.v(tag, newMessage, tr) + is Level.Error -> Log.e(tag, newMessage, tr) + is Level.Info -> Log.i(tag, newMessage, tr) + is Level.Warning -> Log.w(tag, newMessage, tr) + is Level.Verbose -> Log.v(tag, newMessage, tr) + is Level.WTF -> Log.wtf(tag, newMessage, tr) + is Level.Event -> Log.d(tag, newMessage) + } + } + + @Keep + fun StackTrace.formattedStack(): String = "$methodName($fileName:$lineNumber)" + + const val LOG_EVENT_PRIORITY = 101 + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/LogsViewModel.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/LogsViewModel.kt new file mode 100644 index 000000000..48d0f108e --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/LogsViewModel.kt @@ -0,0 +1,135 @@ +package com.pluto.plugins.logger.internal + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.pluto.plugins.logger.internal.persistence.LogDBHandler +import com.pluto.plugins.logger.internal.persistence.LogEntity +import com.pluto.utilities.extensions.asFormattedDate +import com.pluto.utilities.list.ListItem +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal class LogsViewModel(application: Application) : AndroidViewModel(application) { + + private var rawLogs: List? = null + val logs: LiveData> + get() = _logs + private val _logs = MutableLiveData>() + + val current: LiveData + get() = _current + private val _current = MutableLiveData() + + val serializedLogs: LiveData + get() = _serializedLogs + private val _serializedLogs = MutableLiveData() + + fun searchAndFilter( + search: String = "", + logType: List = emptyList(), + logTimeStamp: LogTimeStamp = LogTimeStamp(0, false) + ) { + viewModelScope.launch(Dispatchers.IO) { + if (rawLogs == null) { + rawLogs = LogDBHandler.fetchAll() + } + val currentSessionLogs = + (rawLogs ?: arrayListOf()) + .filter { it.sessionId == Session.id } + .applyUserFilter(search, logType, logTimeStamp) + + val previousSessionLogs = if (!logTimeStamp.isSessionFilter) { + (rawLogs ?: arrayListOf()) + .filter { it.sessionId != Session.id } + .applyUserFilter(search, logType, logTimeStamp) + } else { + emptyList() + } + + val list = arrayListOf() + list.addAll(currentSessionLogs) + if (previousSessionLogs.isNotEmpty()) { + list.add(LogPreviousSessionHeader()) + list.addAll(previousSessionLogs) + } + _logs.postValue(list) + } + } + + fun deleteAll() { + viewModelScope.launch(Dispatchers.IO) { + LogDBHandler.flush() + _logs.postValue(arrayListOf()) + } + } + + internal fun updateCurrentLog(data: LogData) { + _current.postValue(data) + } + + internal fun serializeLogs() { + viewModelScope.launch { + val text = StringBuilder() + text.append("Pluto Log Trace") + logs.value?.forEach { + if (it is LogData) { + text.append("\n----------\n") + text.append("${it.timeStamp.asFormattedDate(DATE_FORMAT)} ${it.level.label.uppercase()} | ${it.tag}: ${it.message}") + it.tr?.let { tr -> + text.append("\n\tException: ${tr}\n") + tr.stackTrace.take(MAX_STACK_TRACE_LINES).forEach { trace -> + text.append("\t\t at $trace\n") + } + if (tr.stackTrace.size - MAX_STACK_TRACE_LINES > 0) { + text.append("\t\t + ${tr.stackTrace.size - MAX_STACK_TRACE_LINES} more lines") + } + } + it.eventAttributes?.let { attr -> + if (attr.isNotEmpty()) { + text.append("\n\tEvent Attributes (${attr.size}):") + attr.forEach { entry -> + text.append("\n\t\t${entry.key}: ${entry.value}") + } + } + } + } + } + _serializedLogs.postValue(text.toString()) + } + } + + companion object { + const val MAX_STACK_TRACE_LINES = 15 + const val DATE_FORMAT = "yyyy-MM-dd HH:mm:ss.SSS" + } +} + +private fun List.applyUserFilter( + search: String, + logType: List, + logTimeStamp: LogTimeStamp +): List { + return this.asSequence() + .filter { pastTimeFilter(it.timestamp, logTimeStamp) } + .filter { logType.isEmpty() || logType.any { type -> type.type == it.data.tag } } + .filter { it.data.isValidSearch(search) } + .map { it.data } + .toList() +} + +private fun pastTimeFilter(logTime: Long, log: LogTimeStamp): Boolean { + if (log.timeStamp == 0 || log.isSessionFilter) { + return true + } + return logTime >= System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(log.timeStamp.toLong()) +} + +private fun LogData.isValidSearch(search: String): Boolean { + return search.isEmpty() || + message.contains(search, true) || + stackTrace.fileName.contains(search, true) +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/Session.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/Session.kt new file mode 100644 index 000000000..25e11468e --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/Session.kt @@ -0,0 +1,8 @@ +package com.pluto.plugins.logger.internal + +import java.util.UUID + +internal object Session { + val id: String = UUID.randomUUID().toString() + var loggerSearchText: String? = null +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ThrowableKtx.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ThrowableKtx.kt new file mode 100644 index 000000000..4ef28b5e8 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ThrowableKtx.kt @@ -0,0 +1,58 @@ +package com.pluto.plugins.logger.internal + +import android.content.Context +import androidx.annotation.Keep +import com.pluto.plugins.logger.R +import com.pluto.utilities.extensions.color +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.spannable.createSpan +import com.squareup.moshi.JsonClass + +internal fun Throwable.asExceptionData(isANR: Boolean = false): ExceptionData { + return ExceptionData( + name = this.toString().replace(": $message", "", true), + message = message, + stackTrace = stackTrace.asStringArray(), + file = stackTrace.getOrNull(0)?.fileName, + lineNumber = stackTrace.getOrNull(0)?.lineNumber ?: Int.MIN_VALUE, + isANRException = isANR + ) +} + +@Keep +@JsonClass(generateAdapter = true) +internal data class ExceptionData( + val message: String?, + val name: String?, + val file: String?, + val lineNumber: Int, + val stackTrace: List, + val timeStamp: Long = System.currentTimeMillis(), + val isANRException: Boolean = false +) : ListItem() + +internal fun Array.asStringArray(): ArrayList { + val array = arrayListOf() + forEach { + if (it.isNativeMethod) { + array.add("${it.className}.${it.methodName}(Native Method)") + } else { + array.add("${it.className}.${it.methodName}(${it.fileName}:${it.lineNumber})") + } + } + return array +} + +internal fun Context?.beautifyAttributes(data: Map): CharSequence? { + return this?.createSpan { + data.forEach { + append("${it.key} : ") + if (it.value != null) { + append(fontColor(semiBold("${it.value}"), context.color(com.pluto.plugin.R.color.pluto___text_dark_80))) + } else { + append(fontColor(light(italic("null")), context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + append("\n") + } + }?.trim() +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/EntityConverters.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/EntityConverters.kt new file mode 100644 index 000000000..106e8d3dc --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/EntityConverters.kt @@ -0,0 +1,23 @@ +package com.pluto.plugins.logger.internal.persistence + +import androidx.room.TypeConverter +import com.pluto.plugins.logger.internal.LogData +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi + +internal class EntityConverters { + + private var moshi = Moshi.Builder().build() + private var logMoshiAdapter: JsonAdapter = moshi.adapter(LogData::class.java) + + @TypeConverter + fun stringToLog(data: String?): LogData? { + data?.let { return logMoshiAdapter.fromJson(data) } + return null + } + + @TypeConverter + fun logToString(data: LogData): String { + return logMoshiAdapter.toJson(data) + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/LogDBHandler.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/LogDBHandler.kt new file mode 100644 index 000000000..1b499ca62 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/LogDBHandler.kt @@ -0,0 +1,63 @@ +package com.pluto.plugins.logger.internal.persistence + +import android.content.Context +import com.pluto.plugins.logger.internal.Level +import com.pluto.plugins.logger.internal.LogData +import com.pluto.plugins.logger.internal.StackTrace +import com.pluto.plugins.logger.internal.asExceptionData +import com.pluto.plugins.logger.internal.persistence.database.DatabaseManager +import java.util.HashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal object LogDBHandler { + + private var logDao: LogDao? = null + private val coroutineScope = CoroutineScope(Dispatchers.IO) + + /** + * Store log temporarily till the LogDBHandler is not initialised. + */ + private val tempList = arrayListOf() + + fun initialize(context: Context) { + logDao = DatabaseManager(context).db.exceptionDao() + processTempList() + } + + fun persistLog(level: Level, tag: String, message: String?, tr: Throwable?, ele: StackTrace) { + coroutineScope.launch { + val logEntity = LogEntity(data = LogData(level, tag, message ?: "", tr?.asExceptionData(), ele)) + logDao?.save(logEntity) ?: run { pushToTempList(logEntity) } + } + } + + fun persistEvent(level: Level, tag: String, event: String, attr: HashMap?, ele: StackTrace) { + coroutineScope.launch { + val logEntity = LogEntity(data = LogData(level, tag, event, null, ele, attr)) + logDao?.save(logEntity) ?: run { pushToTempList(logEntity) } + } + } + + suspend fun fetchAll(): List? { + return logDao?.fetchAll() + } + + fun flush() { + coroutineScope.launch { + logDao?.deleteAll() + } + } + + private fun pushToTempList(logEntity: LogEntity) { + tempList.add(logEntity) + } + + private fun processTempList() { + coroutineScope.launch { + logDao?.saveAll(tempList) + tempList.clear() + } + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/LogDao.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/LogDao.kt new file mode 100644 index 000000000..eea128a2d --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/LogDao.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.logger.internal.persistence + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +internal interface LogDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(entity: LogEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun saveAll(entities: List) + + @Query("SELECT * FROM logs where id is :id") + suspend fun fetch(id: Int): LogEntity? + + @Query("SELECT * FROM logs order by timestamp DESC") + suspend fun fetchAll(): List? + + @Query("DELETE FROM logs where id is :id") + suspend fun delete(id: Int) + + @Query("DELETE FROM logs") + suspend fun deleteAll() +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/LogEntity.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/LogEntity.kt new file mode 100644 index 000000000..d1b64a87a --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/LogEntity.kt @@ -0,0 +1,26 @@ +package com.pluto.plugins.logger.internal.persistence + +import androidx.annotation.Keep +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.pluto.plugins.logger.internal.LogData +import com.pluto.plugins.logger.internal.Session +import com.squareup.moshi.JsonClass + +@Keep +@JsonClass(generateAdapter = true) +@TypeConverters(EntityConverters::class) +@Entity(tableName = "logs") +internal data class LogEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Int? = null, + @ColumnInfo(name = "session_id") + val sessionId: String = Session.id, + @ColumnInfo(name = "timestamp") + val timestamp: Long = System.currentTimeMillis(), + @ColumnInfo(name = "data") + val data: LogData, +) diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/database/DatabaseManager.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/database/DatabaseManager.kt new file mode 100644 index 000000000..4fa188a7b --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/database/DatabaseManager.kt @@ -0,0 +1,18 @@ +package com.pluto.plugins.logger.internal.persistence.database + +import android.content.Context +import androidx.room.Room + +internal class DatabaseManager(context: Context) { + + val db by lazy { + Room.databaseBuilder(context, PlutoDatabase::class.java, DATABASE_NAME) + .addMigrations() + .fallbackToDestructiveMigration() + .build() + } + + companion object { + private const val DATABASE_NAME = "_pluto_log_database" + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/database/PlutoDatabase.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/database/PlutoDatabase.kt new file mode 100644 index 000000000..e5ea18c63 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/persistence/database/PlutoDatabase.kt @@ -0,0 +1,16 @@ +package com.pluto.plugins.logger.internal.persistence.database + +import androidx.room.RoomDatabase +import com.pluto.plugins.logger.internal.persistence.LogDao +import com.pluto.plugins.logger.internal.persistence.LogEntity + +@androidx.room.Database( + entities = [ + LogEntity::class, + ], + version = 2, + exportSchema = false +) +internal abstract class PlutoDatabase : RoomDatabase() { + abstract fun exceptionDao(): LogDao +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/DetailsFragment.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/DetailsFragment.kt new file mode 100644 index 000000000..c9d811962 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/DetailsFragment.kt @@ -0,0 +1,124 @@ +package com.pluto.plugins.logger.internal.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.navigation.fragment.findNavController +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.logger.R +import com.pluto.plugins.logger.databinding.PlutoLoggerFragmentDetailsBinding +import com.pluto.plugins.logger.internal.LogData +import com.pluto.plugins.logger.internal.LogsViewModel +import com.pluto.plugins.logger.internal.beautifyAttributes +import com.pluto.plugins.logger.internal.ui.DetailsFragment.Companion.MAX_STACK_TRACE_LINES +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.onBackPressed +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import com.pluto.utilities.viewBinding + +internal class DetailsFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoLoggerFragmentDetailsBinding::bind) + private val viewModel: LogsViewModel by activityViewModels() + private val contentSharer by lazyContentSharer() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_logger___fragment_details, container, false) + + override fun getTheme(): Int = R.style.PlutoLoggerBottomSheetDialog + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBackPressed { findNavController().navigateUp() } + + viewModel.current.removeObserver(detailsObserver) + viewModel.current.observe(viewLifecycleOwner, detailsObserver) + } + + private val detailsObserver = Observer { data -> + binding.title.setSpan { + append("${context.getString(R.string.pluto_logger___log_details)} ") + append(italic(fontColor(data.level.label.uppercase(), context.color(data.level.textColor)))) + } + + binding.cta.setOnDebounceClickListener { + context?.let { + contentSharer.share(Shareable(title = "Share Log details", content = data.toShareText(it))) + } + } + + binding.tag.text = data.tag + binding.filename.setSpan { + append(fontColor("called from\n", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + append(data.stackTrace.methodName) + append(fontColor(" (", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + append(data.stackTrace.fileName) + append(fontColor(", line:", context.color(com.pluto.plugin.R.color.pluto___text_dark_60))) + append(fontColor("${data.stackTrace.lineNumber}", context.color(com.pluto.plugin.R.color.pluto___text_dark_80))) + append(fontColor(")", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + binding.message.text = data.message + binding.stackTraceContainer.visibility = View.GONE + data.tr?.let { + binding.stackTraceContainer.visibility = View.VISIBLE + binding.stackTrace.setSpan { + append(fontColor("${it.name}: ${it.message}", context.color(com.pluto.plugin.R.color.pluto___text_dark_80))) + it.stackTrace.take(MAX_STACK_TRACE_LINES).forEach { + append("\n\t\t\t") + append(fontColor(" at ", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + append(it) + } + val extraTrace = it.stackTrace.size - MAX_STACK_TRACE_LINES + if (extraTrace > 0) { + append( + fontColor( + "\n\t\t\t + $extraTrace more lines", context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + } + } + } + if (!data.eventAttributes.isNullOrEmpty()) { + binding.stackTraceContainer.visibility = View.VISIBLE + binding.stackTraceTitle.setSpan { + append(context.getString(R.string.pluto_logger___event_attributes)) + append(fontColor(" (${data.eventAttributes.size})", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + binding.stackTrace.text = context.beautifyAttributes(data.eventAttributes) + } + } + + companion object { + const val MAX_STACK_TRACE_LINES = 15 + } +} + +private fun LogData.toShareText(context: Context): String { + val text = StringBuilder() + text.append("$tag : $message\n") + + tr?.let { + text.append("\n${it.name}: ${it.message}\n") + it.stackTrace.take(MAX_STACK_TRACE_LINES).forEach { trace -> + text.append("\t at $trace\n") + } + if (it.stackTrace.size - MAX_STACK_TRACE_LINES > 0) { + text.append("\t + ${it.stackTrace.size - MAX_STACK_TRACE_LINES} more lines\n\n") + } + } + + if (!eventAttributes.isNullOrEmpty()) { + text.append("\n${context.getString(R.string.pluto_logger___event_attributes).lowercase()} - ") + eventAttributes.entries.forEach { + text.append("\n\t ${it.key} : ${it.value}") + } + } + return text.toString() +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/ListFragment.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/ListFragment.kt new file mode 100644 index 000000000..364c50470 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/ListFragment.kt @@ -0,0 +1,188 @@ +package com.pluto.plugins.logger.internal.ui + +import android.os.Bundle +import android.view.View +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.logger.FilterViewModel +import com.pluto.plugins.logger.R +import com.pluto.plugins.logger.databinding.PlutoLoggerFragmentListBinding +import com.pluto.plugins.logger.internal.LogData +import com.pluto.plugins.logger.internal.LogTimeStamp +import com.pluto.plugins.logger.internal.LogType +import com.pluto.plugins.logger.internal.LogsViewModel +import com.pluto.plugins.logger.internal.ui.list.LogsAdapter +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.hideKeyboard +import com.pluto.utilities.extensions.linearLayoutManager +import com.pluto.utilities.extensions.showMoreOptions +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.selector.lazyDataSelector +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +internal class ListFragment : Fragment(R.layout.pluto_logger___fragment_list) { + + private val binding by viewBinding(PlutoLoggerFragmentListBinding::bind) + private val viewModel: LogsViewModel by activityViewModels() + private val filterViewModel: FilterViewModel by activityViewModels() + private val logsAdapter: BaseAdapter by autoClearInitializer { LogsAdapter(onActionListener) } + private val contentSharer by lazyContentSharer() + private val dataSelector by lazyDataSelector() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.logList.apply { + adapter = logsAdapter + addItemDecoration(CustomItemDecorator(requireContext())) + } + binding.search.doOnTextChanged { text, _, _, _ -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + text?.toString()?.let { + filterViewModel.updateSearchText(it) + // viewModel.searchAndFilter(it, filterViewModel.getSelectedFilters()) + if (it.isEmpty()) { + binding.logList.linearLayoutManager()?.scrollToPositionWithOffset(0, 0) + } + } + } + } + binding.search.setText(filterViewModel.getSearchText()) + viewModel.logs.removeObserver(logsObserver) + viewModel.logs.observe(viewLifecycleOwner, logsObserver) + + filterViewModel.isTriggerSearch.observe(viewLifecycleOwner) { + viewModel.searchAndFilter( + filterViewModel.getSearchText(), + filterViewModel.getSelectedFilters(), + filterViewModel.getSelectedTimeStamp() + ) + } + + viewModel.serializedLogs.removeObserver(serializedLogsObserver) + viewModel.serializedLogs.observe(viewLifecycleOwner, serializedLogsObserver) + + binding.filter.setOnClickListener { + filterViewModel.toggleFilterViewVisibility() + } + binding.close.setOnDebounceClickListener { + activity?.finish() + } + + binding.logType.setOnDebounceClickListener { + openLogTypeFilterView() + } + + binding.logTimeStamp.setOnDebounceClickListener { + openTimeStampFilterView() + } + binding.tvClearFilter.setOnDebounceClickListener { + filterViewModel.clearFilters() + } + + binding.options.setOnDebounceClickListener { + context?.showMoreOptions(it, R.menu.pluto_logger___menu_more_options) { item -> + when (item.itemId) { + R.id.clear -> viewModel.deleteAll() + R.id.shareAll -> + if (!viewModel.logs.value.isNullOrEmpty()) { + viewModel.serializeLogs() + } else { + context?.toast("No logs to share") + } + } + } + } + handleFilterStates() + } + + private fun handleFilterStates() { + filterViewModel.selectedFiltersList.observe(this) { + binding.ivLogTypeSelected.visibility = if (it.isNotEmpty()) View.VISIBLE else View.GONE + } + + filterViewModel.isFilterApplied.observe(this) { + binding.ivFilterApplied.visibility = if (it) View.VISIBLE else View.GONE + } + + filterViewModel.selectedTimeStamp.observe(this) { + binding.ivLogTimeStamp.visibility = if (it.timeStamp != 0) View.VISIBLE else View.GONE + } + + filterViewModel.isFilterVisible.observe(this) { + binding.filterView.visibility = if (it) View.VISIBLE else View.GONE + } + } + + private val logsObserver = Observer> { + logsAdapter.list = it + binding.noItemText.text = getString( + if (binding.search.text.toString().isNotEmpty()) { + R.string.pluto_logger___no_search_result + } else { + R.string.pluto_logger___no_logs_text + } + ) + binding.noItemText.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE + } + + private val serializedLogsObserver = Observer { + contentSharer.share( + Shareable( + title = "Share all logs", + content = it, + fileName = "Log Trace from Pluto" + ) + ) + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is LogData) { + activity?.let { + it.hideKeyboard(viewLifecycleOwner.lifecycleScope) { + viewModel.updateCurrentLog(data) + findNavController().navigate(R.id.openDetails) + } + } + } + } + } + + private fun openLogTypeFilterView() { + dataSelector.selectMultiple( + title = getString(R.string.pluto_logger___select_log_type), + list = filterViewModel.getLogTypes(), + preSelected = filterViewModel.getSelectedFilters() + ).observe(viewLifecycleOwner) { selectorOptions -> + val logTypeList = arrayListOf() + selectorOptions.forEach { option -> + if (option is LogType) { + logTypeList.add(option) + } + } + filterViewModel.setSelectedFiltersLogType(logTypeList) + } + } + + private fun openTimeStampFilterView() { + dataSelector.selectSingle( + title = getString(R.string.pluto_logger___select_log_Time), + list = filterViewModel.getTimeStamps(), + preSelected = filterViewModel.getSelectedTimeStamp() + ).observe(viewLifecycleOwner) { selectorOptions -> + filterViewModel.setSelectedFilterTimeStamp(selectorOptions as LogTimeStamp) + } + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/list/LogHeaderHolder.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/list/LogHeaderHolder.kt new file mode 100644 index 000000000..779dafd93 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/list/LogHeaderHolder.kt @@ -0,0 +1,20 @@ +package com.pluto.plugins.logger.internal.ui.list + +import android.view.ViewGroup +import com.pluto.plugins.logger.R +import com.pluto.plugins.logger.databinding.PlutoLoggerListHeaderBinding +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class LogHeaderHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto_logger___list_header), actionListener) { + + private val binding = PlutoLoggerListHeaderBinding.bind(itemView) + private val logTag = binding.title + + override fun onBind(item: ListItem) { + logTag.text = context.getString(R.string.pluto_logger___previous_log_header) + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/list/LogItemHolder.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/list/LogItemHolder.kt new file mode 100644 index 000000000..022aaddf7 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/list/LogItemHolder.kt @@ -0,0 +1,56 @@ +package com.pluto.plugins.logger.internal.ui.list + +import android.view.ViewGroup +import com.pluto.plugins.logger.R +import com.pluto.plugins.logger.databinding.PlutoLoggerListItemBinding +import com.pluto.plugins.logger.internal.LogData +import com.pluto.utilities.extensions.asTimeElapsed +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class LogItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto_logger___list_item), actionListener) { + + private val binding = PlutoLoggerListItemBinding.bind(itemView) + private val logTag = binding.logtag + private val message = binding.message + private val timestamp = binding.timestamp + + override fun onBind(item: ListItem) { + if (item is LogData) { + logTag.setSpan { + append(fontColor(semiBold(item.tag.trim()), context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + append( + fontColor( + " | ${item.stackTrace.fileName}:${item.stackTrace.lineNumber}", context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + } + logTag.setCompoundDrawablesWithIntrinsicBounds(item.level.iconRes, 0, 0, 0) + logTag.compoundDrawablePadding = DRAWABLE_PADDING + message.setSpan { + append(semiBold(item.message.trim())) + item.eventAttributes?.let { + append( + regular( + fontColor(" (${it.size} attributes)", context.color(com.pluto.plugin.R.color.pluto___text_dark_60)) + ) + ) + } + } + timestamp.text = item.timeStamp.asTimeElapsed() + itemView.setBackgroundColor(itemView.context.color(item.level.color)) + itemView.setOnDebounceClickListener { onAction("click") } + } + } + + private companion object { + val DRAWABLE_PADDING = 4f.dp.toInt() + } +} diff --git a/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/list/LogsAdapter.kt b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/list/LogsAdapter.kt new file mode 100644 index 000000000..4e9cb1191 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/java/com/pluto/plugins/logger/internal/ui/list/LogsAdapter.kt @@ -0,0 +1,31 @@ +package com.pluto.plugins.logger.internal.ui.list + +import android.view.ViewGroup +import com.pluto.plugins.logger.internal.LogData +import com.pluto.plugins.logger.internal.LogPreviousSessionHeader +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class LogsAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is LogData -> ITEM_TYPE_LOG + is LogPreviousSessionHeader -> ITEM_TYPE_HEADER + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_LOG -> LogItemHolder(parent, listener) + ITEM_TYPE_HEADER -> LogHeaderHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_LOG = 1000 + const val ITEM_TYPE_HEADER = 1001 + } +} diff --git a/pluto/src/main/res/drawable/pluto___bg_bottom_sheet.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___bg_bottom_sheet.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___bg_bottom_sheet.xml rename to pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___bg_bottom_sheet.xml diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___bg_bottom_sheet_content.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___bg_bottom_sheet_content.xml new file mode 100644 index 000000000..387102e17 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___bg_bottom_sheet_content.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/src/main/res/drawable/pluto___ic_analytics.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_analytics.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_analytics.xml rename to pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_analytics.xml diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_circle.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_circle.xml new file mode 100644 index 000000000..4b0df3738 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_circle.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_clear_all.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_clear_all.xml new file mode 100644 index 000000000..bb96fcb2d --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_clear_all.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_close.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_close.xml new file mode 100644 index 000000000..82eaa9f97 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_filter_dark.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_filter_dark.xml new file mode 100644 index 000000000..325a8949f --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_filter_dark.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_label.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_label.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_label.xml rename to pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_label.xml diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_logger_icon.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_logger_icon.xml new file mode 100644 index 000000000..28a01cab1 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_logger_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_more.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_more.xml new file mode 100644 index 000000000..e5db6edf0 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_share.xml b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_share.xml new file mode 100644 index 000000000..66dadbd26 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/drawable/pluto_logger___ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___fragment_details.xml b/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___fragment_details.xml new file mode 100644 index 000000000..0a0ffcf07 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___fragment_details.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___fragment_list.xml b/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___fragment_list.xml new file mode 100644 index 000000000..73bae8674 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___fragment_list.xml @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___fragment_logs.xml b/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___fragment_logs.xml new file mode 100644 index 000000000..f98a29de5 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___fragment_logs.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___list_header.xml b/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___list_header.xml new file mode 100644 index 000000000..92b5b0454 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___list_header.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___list_item.xml b/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___list_item.xml new file mode 100644 index 000000000..d9d34656e --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/layout/pluto_logger___list_item.xml @@ -0,0 +1,52 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/menu/pluto_logger___menu_more_options.xml b/pluto-plugins/plugins/logger/lib/src/main/res/menu/pluto_logger___menu_more_options.xml new file mode 100644 index 000000000..e0827cb97 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/menu/pluto_logger___menu_more_options.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/navigation/pluto_logger___navigation.xml b/pluto-plugins/plugins/logger/lib/src/main/res/navigation/pluto_logger___navigation.xml new file mode 100644 index 000000000..ed1293b4a --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/navigation/pluto_logger___navigation.xml @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/values/colors.xml b/pluto-plugins/plugins/logger/lib/src/main/res/values/colors.xml new file mode 100644 index 000000000..0559471c3 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0511141c + diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/values/strings.xml b/pluto-plugins/plugins/logger/lib/src/main/res/values/strings.xml new file mode 100644 index 000000000..b70af0702 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + Logger + No Logs printed. + Search logs + open logs menu + Exit logs debugger + Logger + Show more options + No search result found. + Log Details + Share + Exception + Event Attributes + Clear logs + Close Logger + Share all logs + Logs from previous sessions + Select log type + Select log timeframe + Clear + Duration + diff --git a/pluto-plugins/plugins/logger/lib/src/main/res/values/styles.xml b/pluto-plugins/plugins/logger/lib/src/main/res/values/styles.xml new file mode 100644 index 000000000..ab6b49383 --- /dev/null +++ b/pluto-plugins/plugins/logger/lib/src/main/res/values/styles.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/README.md b/pluto-plugins/plugins/network/README.md new file mode 100644 index 000000000..51ac12447 --- /dev/null +++ b/pluto-plugins/plugins/network/README.md @@ -0,0 +1,81 @@ +## Integrate Network Plugin in your application + +[Network & API calls Demo.webm](https://github.com/user-attachments/assets/c95f8aec-ad96-4e31-b1c6-ddcd763260de) + +### Add Gradle Dependencies +Pluto Network is distributed through [***mavenCentral***](https://central.sonatype.com/artifact/com.androidpluto.plugins/network). To use it, you need to add the following Gradle dependency to your build.gradle file of you android app module. + +> Note: add the `no-op` variant to isolate the plugin from release builds. +```groovy +dependencies { + debugImplementation "com.androidpluto.plugins:network:$plutoVersion" + releaseImplementation "com.androidpluto.plugins:network-no-op:$plutoVersion" +} +``` +
+ +### Install plugin to Pluto + +Now to start using the plugin, add it to Pluto +```kotlin +Pluto.Installer(this) + .addPlugin(PlutoNetworkPlugin()) + .install() +``` +
+
+ +### Add interceptors + +#### 1. Okhttp Interceptor +Add interceptor in your OkHttp Client Builder +```kotlin +val client = OkHttpClient.Builder() + .addInterceptor(PlutoOkhttpInterceptor) + .build() +``` +
+ +#### 2. Ktor Interceptor +Add interceptor in your HttpClient +```kotlin +val client = HttpClient { + install(PlutoKtorInterceptor) +} +``` +
+ +#### 3. Custom Interceptor +If you wish to use any interceptor, other than Okhttp or Ktor, Pluto provides way to capture network logs. +```kotlin +/* create interceptor */ +val networkInterceptor = NetworkInterceptor.intercept( + NetworkData.Request(....), + NetworkInterceptor.Option() +) + +/** + * wait for the network call to complete +**/ + +/* if error */ +networkInterceptor.onError(exception) + +/* if success */ +networkInterceptor.onResponse( + NetworkData.Response(....) +) +``` + +πŸŽ‰  You are all done! + +Now re-build and run your app and open Pluto, you will see the Network plugin installed. + +
+ +### Open Plugin view programmatically +To open Network plugin screen via code, use this +```kotlin +Pluto.open(PlutoNetworkPlugin.ID) +``` +
diff --git a/pluto-plugins/plugins/network/lib-no-op/.gitignore b/pluto-plugins/plugins/network/lib-no-op/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/network/lib-no-op/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib-no-op/build.gradle.kts b/pluto-plugins/plugins/network/lib-no-op/build.gradle.kts new file mode 100644 index 000000000..2f3f4be85 --- /dev/null +++ b/pluto-plugins/plugins/network/lib-no-op/build.gradle.kts @@ -0,0 +1,93 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.network" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "network-no-op" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Network Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to monitor network calls in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(libs.okhttp) + implementation(libs.ktor.client.core.jvm) +} diff --git a/pluto-plugins/plugins/network/lib-no-op/src/main/AndroidManifest.xml b/pluto-plugins/plugins/network/lib-no-op/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/network/lib-no-op/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/PlutoNetworkPlugin.kt b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/PlutoNetworkPlugin.kt new file mode 100644 index 000000000..b66043421 --- /dev/null +++ b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/PlutoNetworkPlugin.kt @@ -0,0 +1,8 @@ +package com.pluto.plugins.network + +@SuppressWarnings("UnusedPrivateMember") +class PlutoNetworkPlugin @JvmOverloads constructor(identifier: String = ID) { + companion object { + const val ID = "network" + } +} diff --git a/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt new file mode 100644 index 000000000..be3bb14ab --- /dev/null +++ b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt @@ -0,0 +1,32 @@ +package com.pluto.plugins.network.intercept + +class NetworkData { + + data class Request( + val url: String, + val method: String, + val body: Body?, + val headers: Map, + val sentTimestamp: Long + ) + + data class Response( + val statusCode: Int, + val body: Body?, + val headers: Map, + val sentTimestamp: Long, + val receiveTimestamp: Long, + val protocol: String = "", + val fromDiskCache: Boolean = false + ) { + val isSuccessful: Boolean + get() = statusCode in 200..299 + } + + data class Body( + val body: CharSequence, + val contentType: String + ) { + val sizeInBytes: Long = body.length.toLong() + } +} diff --git a/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/intercept/NetworkInterceptor.kt b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/intercept/NetworkInterceptor.kt new file mode 100644 index 000000000..8d1cdcd51 --- /dev/null +++ b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/intercept/NetworkInterceptor.kt @@ -0,0 +1,26 @@ +package com.pluto.plugins.network.intercept + +import java.io.IOException + +@SuppressWarnings("EmptyFunctionBlock", "UnusedPrivateMember") +class NetworkInterceptor private constructor(private val request: NetworkData.Request, option: Option) { + + companion object { + + @JvmOverloads + fun intercept(request: NetworkData.Request, option: Option = Option()): NetworkInterceptor { + return NetworkInterceptor(request, option) + } + } + + fun onError(e: IOException) { + } + + fun onResponse(response: NetworkData.Response) { + } + + data class Option( + val name: String = "Custom", + val metadata: HashMap? = null + ) +} diff --git a/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/interceptors/ktor/PlutoKtorInterceptor.kt b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/interceptors/ktor/PlutoKtorInterceptor.kt new file mode 100644 index 000000000..b3cab7c94 --- /dev/null +++ b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/interceptors/ktor/PlutoKtorInterceptor.kt @@ -0,0 +1,25 @@ +package com.pluto.plugins.network.interceptors.ktor + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpClientPlugin +import io.ktor.util.AttributeKey + +@Deprecated("install the PlutoKtorInterceptor plugin instead") +@SuppressWarnings("EmptyFunctionBlock") +fun HttpClient.addPlutoKtorInterceptor() { +} + +class PlutoKtorInterceptor private constructor() { + companion object : HttpClientPlugin { + override val key: AttributeKey + get() = AttributeKey("PlutoKtorInterceptor") + + override fun prepare(block: Unit.() -> Unit): PlutoKtorInterceptor { + return PlutoKtorInterceptor() + } + + override fun install(plugin: PlutoKtorInterceptor, scope: HttpClient) { + // no-op + } + } +} diff --git a/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpHelper.kt b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpHelper.kt new file mode 100644 index 000000000..4c4c67106 --- /dev/null +++ b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpHelper.kt @@ -0,0 +1,7 @@ +package com.pluto.plugins.network.interceptors.okhttp + +import okhttp3.OkHttpClient + +fun OkHttpClient.Builder.addPlutoOkhttpInterceptor(): OkHttpClient.Builder { + return this +} diff --git a/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpInterceptor.kt b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpInterceptor.kt new file mode 100644 index 000000000..c9bb14cc1 --- /dev/null +++ b/pluto-plugins/plugins/network/lib-no-op/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpInterceptor.kt @@ -0,0 +1,17 @@ +package com.pluto.plugins.network.interceptors.okhttp + +import androidx.annotation.Keep +import okhttp3.Interceptor +import okhttp3.Response + +@SuppressWarnings("UtilityClassWithPublicConstructor") +@Keep +class PlutoOkhttpInterceptor { + companion object : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + return chain.proceed(request) + } + } +} diff --git a/pluto-plugins/plugins/network/lib/.gitignore b/pluto-plugins/plugins/network/lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/build.gradle.kts b/pluto-plugins/plugins/network/lib/build.gradle.kts new file mode 100644 index 000000000..6ad41503c --- /dev/null +++ b/pluto-plugins/plugins/network/lib/build.gradle.kts @@ -0,0 +1,116 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.ksp) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + resourcePrefix = "pluto_network___" + namespace = "com.pluto.plugins.network" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"$verPublish\"") + buildConfigField("long", "VERSION_CODE", "$verCode") + buildConfigField("String", "GIT_SHA", "\"$verGitSHA\"") + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "network" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Network Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to monitor network calls in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(project(":pluto-plugins:base:lib")) + implementation(libs.androidx.core) + + implementation(libs.okio) + implementation(libs.ktor.client.core.jvm) + + implementation(libs.room) + ksp(libs.room.compiler) + + implementation(libs.moshi) + ksp(libs.moshi.codegen) + + implementation(libs.androidx.browser) + testImplementation(libs.junit) + + implementation(libs.okhttp) + implementation(libs.ktor.client.core.jvm) +} diff --git a/pluto-plugins/plugins/network/lib/src/main/AndroidManifest.xml b/pluto-plugins/plugins/network/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/PlutoNetworkPlugin.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/PlutoNetworkPlugin.kt new file mode 100644 index 000000000..256bb0be5 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/PlutoNetworkPlugin.kt @@ -0,0 +1,45 @@ +package com.pluto.plugins.network + +import androidx.fragment.app.Fragment +import com.pluto.plugin.DeveloperDetails +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginConfiguration +import com.pluto.plugins.network.internal.NetworkFragment +import com.pluto.plugins.network.internal.interceptor.logic.NetworkCallsRepo +import com.pluto.plugins.network.internal.mock.logic.MockSettingsRepo + +class PlutoNetworkPlugin() : Plugin(ID) { + + @SuppressWarnings("UnusedPrivateMember") + @Deprecated("Use the default constructor PlutoNetworkPlugin() instead.") + constructor(identifier: String) : this() + + override fun getConfig(): PluginConfiguration { + return PluginConfiguration( + name = context.getString(R.string.pluto_network___plugin_name), + icon = R.drawable.pluto_network___ic_plugin_logo, + version = BuildConfig.VERSION_NAME + ) + } + + override fun getDeveloperDetails(): DeveloperDetails { + return DeveloperDetails( + website = "https://androidpluto.com", + vcsLink = "https://github.com/androidpluto/pluto", + twitter = "https://twitter.com/android_pluto" + ) + } + + override fun getView(): Fragment = NetworkFragment() + override fun onPluginDataCleared() { + NetworkCallsRepo.deleteAll() + } + + override fun onPluginInstalled() { + MockSettingsRepo.init(context) + } + + companion object { + const val ID = "network" + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt new file mode 100644 index 000000000..236ba76c9 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt @@ -0,0 +1,79 @@ +package com.pluto.plugins.network.intercept + +import com.pluto.plugins.network.internal.Status +import com.pluto.plugins.network.internal.interceptor.logic.mapCode2Message +import io.ktor.http.ContentType +import org.json.JSONObject + +class NetworkData { + + data class Request( + val url: String, + val method: String, + val body: Body?, + val headers: Map, + val sentTimestamp: Long, + ) { + data class GraphqlData( + val queryType: String, + val queryName: String, + val variables: JSONObject, + ) + + val graphqlData: GraphqlData? = parseGraphqlData() + + private fun parseGraphqlData(): GraphqlData? { + if (method != "POST" || + body == null || + !body.isJson + ) return null + val json = runCatching { JSONObject(body!!.body.toString()) }.getOrNull() ?: return null + val query = json.optString("query") ?: return null + val variables = json.optJSONObject("variables") ?: JSONObject() + val match = graqphlQueryRegex.find(query)?.groupValues ?: return null + return GraphqlData( + queryType = match[1], + queryName = match[2], + variables = variables, + ) + } + + internal val isGzipped: Boolean + get() = headers["Content-Encoding"].equals("gzip", ignoreCase = true) + } + + data class Response( + private val statusCode: Int, + val body: Body?, + val headers: Map, + val sentTimestamp: Long, + val receiveTimestamp: Long, + val protocol: String = "", + val fromDiskCache: Boolean = false + ) { + internal val status: Status + get() = Status(statusCode, mapCode2Message(statusCode)) + val isSuccessful: Boolean + get() = statusCode in 200..299 + internal val isGzipped: Boolean + get() = headers["Content-Encoding"].equals("gzip", ignoreCase = true) + } + + data class Body( + val body: CharSequence, + val contentType: String, + ) { + private val contentTypeInternal: ContentType = ContentType.parse(contentType) + private val mediaType: String = contentTypeInternal.contentType + internal val mediaSubtype: String = contentTypeInternal.contentSubtype + internal val isBinary: Boolean = BINARY_MEDIA_TYPES.contains(mediaType) + val sizeInBytes: Long = body.length.toLong() + internal val mediaTypeFull: String = "$mediaType/$mediaSubtype" + val isJson get() = mediaTypeFull == "application/json" + } + + companion object { + internal val BINARY_MEDIA_TYPES = listOf("audio", "video", "image", "font") + private val graqphlQueryRegex = Regex("""\b(query|mutation)\s+(\w+)""") + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/intercept/NetworkInterceptor.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/intercept/NetworkInterceptor.kt new file mode 100644 index 000000000..12934c23d --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/intercept/NetworkInterceptor.kt @@ -0,0 +1,58 @@ +package com.pluto.plugins.network.intercept + +import com.pluto.plugins.network.internal.ApiCallData +import com.pluto.plugins.network.internal.MockConfig +import com.pluto.plugins.network.internal.interceptor.logic.NetworkCallsRepo +import com.pluto.plugins.network.internal.interceptor.logic.asExceptionData +import com.pluto.plugins.network.internal.mock.logic.MockSettingsRepo +import com.pluto.utilities.DebugLog +import java.io.IOException +import java.util.UUID + +class NetworkInterceptor private constructor(private val request: NetworkData.Request, option: Option) { + private val getRequestId: String = UUID.nameUUIDFromBytes("${System.currentTimeMillis()}::${request.url}".toByteArray()).toString() + private val apiCallData = ApiCallData(id = getRequestId, interceptorOption = option, request = request) + + /** + * Returns updated request url + * + * if Mock setting is configured, returns mock url + * + * else returns actual request url + */ + val actualOrMockRequestUrl: String = MockSettingsRepo.get(request.url, request.method)?.let { + apiCallData.mock = MockConfig(it) + NetworkCallsRepo.set(apiCallData) + it + } ?: run { + request.url + } + + companion object { + + @JvmOverloads + fun intercept(request: NetworkData.Request, option: Option = Option()): NetworkInterceptor { + return NetworkInterceptor(request, option) + } + } + + init { + NetworkCallsRepo.set(apiCallData) + } + + fun onError(e: IOException) { + DebugLog.e("pluto.network", "error occurred", e) + apiCallData.exception = e.asExceptionData() + NetworkCallsRepo.set(apiCallData) + } + + fun onResponse(response: NetworkData.Response) { + apiCallData.response = response + NetworkCallsRepo.set(apiCallData) + } + + data class Option( + val name: String = "Custom", + val metadata: HashMap? = null + ) +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/PlutoKtorHelper.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/PlutoKtorHelper.kt new file mode 100644 index 000000000..2080878c0 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/PlutoKtorHelper.kt @@ -0,0 +1,62 @@ +package com.pluto.plugins.network.interceptors.ktor + +import androidx.annotation.Keep +import com.pluto.plugins.network.intercept.NetworkInterceptor +import com.pluto.plugins.network.interceptors.ktor.internal.KtorRequestConverter.convert +import com.pluto.plugins.network.interceptors.ktor.internal.KtorResponseConverter.convert +import io.ktor.client.HttpClient +import io.ktor.client.call.save +import io.ktor.client.plugins.HttpClientPlugin +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.plugin +import io.ktor.client.request.url +import io.ktor.util.AttributeKey +import io.ktor.utils.io.errors.IOException + +private val saveAttributeKey = AttributeKey("ResponseBodySaved") + +@Deprecated("install the PlutoKtorInterceptor plugin instead") +fun HttpClient.addPlutoKtorInterceptor() { + plugin(HttpSend).intercept { requestUnBuilt -> + val request = requestUnBuilt.build() + val networkInterceptor = NetworkInterceptor.intercept(request.convert(), NetworkInterceptor.Option(NAME)) + val callResult = try { + val actualOrMockRequestUrl = networkInterceptor.actualOrMockRequestUrl + if (requestUnBuilt.url.buildString() != actualOrMockRequestUrl) { + requestUnBuilt.url(actualOrMockRequestUrl) + } + execute(requestUnBuilt) + } catch (e: IOException) { + networkInterceptor.onError(e) + throw e + } + val res = if (callResult.attributes.contains(saveAttributeKey)) { + callResult + } else { + val newCall = callResult.save() + newCall.attributes.put(saveAttributeKey, Unit) + newCall + } + networkInterceptor.onResponse(res.response.convert()) + res + } +} + +@Keep +class PlutoKtorInterceptor private constructor() { + companion object : HttpClientPlugin { + + override val key: AttributeKey + get() = AttributeKey("PlutoKtorInterceptor") + + override fun prepare(block: Unit.() -> Unit): PlutoKtorInterceptor { + return PlutoKtorInterceptor() + } + + override fun install(plugin: PlutoKtorInterceptor, scope: HttpClient) { + scope.addPlutoKtorInterceptor() + } + } +} + +private const val NAME = "Ktor" diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/KtorRequestConverter.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/KtorRequestConverter.kt new file mode 100644 index 000000000..80ab3639e --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/KtorRequestConverter.kt @@ -0,0 +1,53 @@ +package com.pluto.plugins.network.interceptors.ktor.internal + +import com.pluto.plugins.network.intercept.NetworkData.Body +import com.pluto.plugins.network.intercept.NetworkData.Request +import io.ktor.client.request.HttpRequestData +import io.ktor.http.Headers +import io.ktor.http.content.OutgoingContent + +internal object KtorRequestConverter : RequestConverter { + override fun HttpRequestData.convert(): Request { + return Request( + url.toString(), + method.value, + processBody(body, isGzipped), + headerMap(headers), + System.currentTimeMillis() + ) + } + + private fun headerMap(headers: Headers): Map { + return headers.entries().associate { + it.key to it.value.joinToString(",") + } + } + + private val HttpRequestData.isGzipped: Boolean + get() = headers.contains("content-Encoding", "gzip") + + private fun processBody(body: OutgoingContent, isGzipped: Boolean): Body? { + return body.contentType?.let { + Body( + body = extractBody(body, isGzipped), + contentType = it.toString() + ) + } ?: run { + null + } + } + + // TODO handle gzip + @SuppressWarnings("UnusedPrivateMember") + private fun extractBody(body: OutgoingContent, isGzipped: Boolean): CharSequence { + return body.run { + when (this) { + is OutgoingContent.ByteArrayContent -> this.bytes().decodeToString() + is OutgoingContent.NoContent -> "" + is OutgoingContent.ProtocolUpgrade -> "" + is OutgoingContent.ReadChannelContent -> "Binary_Body" + is OutgoingContent.WriteChannelContent -> "" + } + } + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/KtorResponseConverter.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/KtorResponseConverter.kt new file mode 100644 index 000000000..078460e35 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/KtorResponseConverter.kt @@ -0,0 +1,55 @@ +package com.pluto.plugins.network.interceptors.ktor.internal + +import com.pluto.plugins.network.intercept.NetworkData.Body +import com.pluto.plugins.network.intercept.NetworkData.Response +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.contentType + +internal object KtorResponseConverter : ResponseConverter { + override suspend fun HttpResponse.convert(): Response { + return Response( + statusCode = status.value, + body = extractBody(), + protocol = version.name, + fromDiskCache = false, + headers = headersMap(headers), + sentTimestamp = requestTime.timestamp, + receiveTimestamp = responseTime.timestamp + ) + } + +// private fun HttpResponse.statusCodeMessage(): String { +// return status.description +// } + + private fun headersMap(headers: Headers): Map { + return headers.entries().associate { + it.key to it.value.joinToString(",") + } + } + + // TODO handle gzip + private suspend fun HttpResponse.extractBody() = Body( + body = bodyAsText(), + contentType = contentType()?.toString() ?: ContentType.Any.toString() + ) + +// private fun HttpResponse.isTextType(): Boolean { +// return contentType()?.run { +// return when { +// match(ContentType.Application.Json) -> true +// match(ContentType.Application.Xml) -> true +// match(ContentType.Application.FormUrlEncoded) -> true +// match(ContentType.Text.Any) -> true +// else -> false +// } +// } ?: false +// +// } +// +// private val HttpResponse.isGzipped: Boolean +// get() = headers.contains("Content-Encoding", "gzip") +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/RequestConverter.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/RequestConverter.kt new file mode 100644 index 000000000..2c8b4ff2f --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/RequestConverter.kt @@ -0,0 +1,7 @@ +package com.pluto.plugins.network.interceptors.ktor.internal + +import com.pluto.plugins.network.intercept.NetworkData.Request + +internal interface RequestConverter { + fun T.convert(): Request +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/ResponseConverter.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/ResponseConverter.kt new file mode 100644 index 000000000..a6ac34587 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/ktor/internal/ResponseConverter.kt @@ -0,0 +1,7 @@ +package com.pluto.plugins.network.interceptors.ktor.internal + +import com.pluto.plugins.network.intercept.NetworkData.Response + +internal interface ResponseConverter { + suspend fun T.convert(): Response +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpHelper.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpHelper.kt new file mode 100644 index 000000000..4c4c53a17 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpHelper.kt @@ -0,0 +1,12 @@ +package com.pluto.plugins.network.interceptors.okhttp + +import javax.net.SocketFactory +import okhttp3.OkHttpClient + +@Deprecated("add PlutoHttpInterceptor directly to OkHttpClient instead") +fun OkHttpClient.Builder.addPlutoOkhttpInterceptor(): OkHttpClient.Builder { + // todo add okhttp settings block here + socketFactory(SocketFactory.getDefault()) + addInterceptor(PlutoOkhttpInterceptor) + return this +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpInterceptor.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpInterceptor.kt new file mode 100644 index 000000000..2e6bd7974 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/PlutoOkhttpInterceptor.kt @@ -0,0 +1,63 @@ +package com.pluto.plugins.network.interceptors.okhttp + +import androidx.annotation.Keep +import com.pluto.plugin.libinterface.PlutoInterface +import com.pluto.plugins.network.intercept.NetworkData +import com.pluto.plugins.network.intercept.NetworkInterceptor +import com.pluto.plugins.network.interceptors.okhttp.internal.ResponseReportingSinkCallback +import com.pluto.plugins.network.interceptors.okhttp.internal.convert +import com.pluto.plugins.network.interceptors.okhttp.internal.hasBody +import com.pluto.plugins.network.interceptors.okhttp.internal.utilities.DepletingSource +import com.pluto.plugins.network.interceptors.okhttp.internal.utilities.ReportingSink +import com.pluto.plugins.network.interceptors.okhttp.internal.utilities.TeeSource +import java.io.IOException +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.ResponseBody +import okio.BufferedSource +import okio.buffer + +@Keep +class PlutoOkhttpInterceptor private constructor() { + companion object : Interceptor { + private const val NAME = "Okhttp" + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val networkInterceptor = NetworkInterceptor.intercept(request.convert(), NetworkInterceptor.Option(NAME)) + val response: Response = try { + val builder = request.newBuilder().url(networkInterceptor.actualOrMockRequestUrl) + chain.proceed(builder.build()) + } catch (e: IOException) { + networkInterceptor.onError(e) + throw e + } + return response.processBody { networkInterceptor.onResponse(it) } + } + } +} + +private fun Response.processBody(onComplete: (NetworkData.Response) -> Unit): Response { + if (!hasBody()) { + onComplete.invoke(convert(null)) + return this + } + val responseBody: ResponseBody = body as ResponseBody + val sideStream = ReportingSink(PlutoInterface.files.createFile(), ResponseReportingSinkCallback(this, onComplete)) + val processedResponseBody: ResponseBody = DepletingSource(TeeSource(responseBody.source(), sideStream)) + .buffer() + .asResponseBody(responseBody) + + return newBuilder() + .body(processedResponseBody) + .build() +} + +/** Returns a new response body that transmits this source. */ +private fun BufferedSource.asResponseBody(referenceBody: ResponseBody) = object : ResponseBody() { + override fun contentType() = referenceBody.contentType() + + override fun contentLength() = referenceBody.contentLength() + + override fun source() = this@asResponseBody +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/ContentProcessor.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/ContentProcessor.kt new file mode 100644 index 000000000..63c011e88 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/ContentProcessor.kt @@ -0,0 +1,62 @@ +package com.pluto.plugins.network.interceptors.okhttp.internal + +import com.pluto.plugins.network.intercept.NetworkData.Body +import com.pluto.plugins.network.internal.interceptor.logic.UTF8 +import com.pluto.utilities.DebugLog +import okhttp3.HttpUrl +import okhttp3.MediaType +import okhttp3.RequestBody +import okhttp3.ResponseBody +import okio.Buffer +import okio.IOException + +internal fun RequestBody.processBody(gzipped: Boolean): Body? { + return contentType()?.let { + DebugLog.e(LOG_TAG, "request : ${it.type}, ${it.subtype}, ${it.charset()}") + Body( + body = if (it.isText()) extractBody(gzipped) else BINARY_BODY, + contentType = it.toString() + ) + } +} + +internal fun ResponseBody?.processBody(buffer: Buffer): Body? { + return this?.contentType()?.let { + DebugLog.e(LOG_TAG, "response : ${it.type}, ${it.subtype}, ${it.charset()}") + Body( + body = if (it.isText()) buffer.readString(it.charset(UTF8) ?: UTF8) else BINARY_BODY, + contentType = it.toString() + ) + } +} + +private fun RequestBody.extractBody(gzipped: Boolean): CharSequence { + return try { + val buffer = Buffer() + writeTo(buffer) + if (gzipped) { + buffer.readByteArray().unzipToString() + } else { + buffer.readUtf8() + } + } catch (e: IOException) { + DebugLog.e(LOG_TAG, "request body parsing failed", e) + "" + } +} + +internal fun MediaType.isText(): Boolean = (type == "application" || type == "text") && + (subtype.endsWith("json") || subtype == "plain" || subtype == "xml" || subtype == "html" || subtype == "x-www-form-urlencoded") + +internal fun HttpUrl.hostUrl(): String = StringBuilder().apply { + append("$scheme://$host") + if (port != HTTP_PORT && port != HTTPS_PORT) { + append(":$port") + } +}.toString() + +internal const val BODY_INDENTATION = 2 +private const val BINARY_BODY = "~ Binary Data" +private const val HTTP_PORT = 80 +private const val HTTPS_PORT = 443 +private const val LOG_TAG = "content-processor" diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/DataConvertor.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/DataConvertor.kt new file mode 100644 index 000000000..646ba15f9 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/DataConvertor.kt @@ -0,0 +1,62 @@ +package com.pluto.plugins.network.interceptors.okhttp.internal + +import com.pluto.plugins.network.intercept.NetworkData +import okhttp3.Request +import okhttp3.Response + +internal fun Request.convert(): NetworkData.Request { + val body = this.body?.processBody(this.isGzipped) + return NetworkData.Request( + url = this.url.toString(), + method = this.method, + body = body, + headers = this.headerMap(body?.sizeInBytes ?: 0L), + sentTimestamp = System.currentTimeMillis() + ) +} + +internal fun Request.headerMap(contentLength: Long): Map { + val headerNames = arrayListOf() + headerNames.addAll(headers.names()) + headerNames.add("content-type") + headerNames.add("content-length") + headerNames.sortBy { it } + + val map = mutableMapOf() + headerNames.forEach { + val key = it.lowercase().trim() + when (it) { + "content-type" -> body?.contentType()?.toString()?.let { value -> + map[key] = value.trim() + } + "content-length" -> map[key] = headers[it]?.trim() ?: run { contentLength.toString() } + else -> map[key] = headers[it]?.trim() + } + } + return map +} + +internal fun Response.convert(body: NetworkData.Body?): NetworkData.Response { + return NetworkData.Response( + statusCode = code, + body = body, + protocol = protocol.name, + fromDiskCache = false, + headers = headersMap(), + sentTimestamp = sentRequestAtMillis, + receiveTimestamp = receivedResponseAtMillis + ) +} + +private fun Response.headersMap(): Map { + val headerNames = arrayListOf() + headerNames.addAll(headers.names()) + headerNames.sortBy { it } + + val map = mutableMapOf() + headerNames.forEach { + map[it.lowercase().trim()] = headers[it]?.trim() + } + + return map +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/OkHttpKtx.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/OkHttpKtx.kt new file mode 100644 index 000000000..d98908a64 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/OkHttpKtx.kt @@ -0,0 +1,51 @@ +package com.pluto.plugins.network.interceptors.okhttp.internal + +import java.net.HttpURLConnection +import okhttp3.Request +import okhttp3.Response + +private const val HTTP_CONTINUE = 100 + +internal fun Response.hasBody(): Boolean { + if (request.method == "HEAD") { + return false + } + body?.let { + val responseCode = code + val isSuccessResponse = responseCode < HTTP_CONTINUE || responseCode >= HttpURLConnection.HTTP_OK + if (isSuccessResponse && + responseCode != HttpURLConnection.HTTP_NO_CONTENT && + responseCode != HttpURLConnection.HTTP_NOT_MODIFIED + ) { + return true + } + + return contentLength > 0 || isChunked + } + return false +} + +internal val Response.contentLength: Long + get() { + return this.header("Content-Length")?.toLongOrNull() ?: 0 + } + +internal val Response.isChunked: Boolean + get() { + return this.header("Transfer-Encoding").equals("chunked", ignoreCase = true) + } + +internal val Response.contentType: String? + get() { + return this.header("Content-Type") + } + +internal val Response.isGzipped: Boolean + get() { + return this.header("Content-Encoding").equals("gzip", ignoreCase = true) + } + +internal val Request.isGzipped: Boolean + get() { + return this.header("Content-Encoding").equals("gzip", ignoreCase = true) + } diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/ResponseReportingSinkCallback.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/ResponseReportingSinkCallback.kt new file mode 100644 index 000000000..3e1b04f4b --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/ResponseReportingSinkCallback.kt @@ -0,0 +1,39 @@ +package com.pluto.plugins.network.interceptors.okhttp.internal + +import com.pluto.plugins.network.intercept.NetworkData +import com.pluto.plugins.network.interceptors.okhttp.internal.utilities.ReportingSink +import java.io.File +import java.io.IOException +import okhttp3.Response +import okio.Buffer +import okio.GzipSource +import okio.buffer +import okio.source + +class ResponseReportingSinkCallback( + private val response: Response, + private val onComplete: (NetworkData.Response) -> Unit +) : ReportingSink.Callback { + + override fun onSuccess(file: File?, sourceByteCount: Long) { + val buffer = file?.let { readResponseBuffer(it, response.isGzipped) } + val body = buffer?.let { response.body.processBody(it) } + file?.delete() + onComplete.invoke(response.convert(body)) + } + + override fun onFailure(file: File?, exception: IOException) = exception.printStackTrace() + + private fun readResponseBuffer(responseBody: File, isGzipped: Boolean) = try { + val bufferedSource = responseBody.source().buffer() + val source = if (isGzipped) { + GzipSource(bufferedSource) + } else { + bufferedSource + } + Buffer().apply { source.use { writeAll(it) } } + } catch (e: IOException) { + IOException("Response payload couldn't be processed by Pluto", e).printStackTrace() + null + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/StreamKtx.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/StreamKtx.kt new file mode 100644 index 000000000..d3ab204a8 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/StreamKtx.kt @@ -0,0 +1,65 @@ +package com.pluto.plugins.network.interceptors.okhttp.internal + +import com.pluto.utilities.DebugLog +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.nio.charset.Charset +import java.util.zip.GZIPInputStream +import okio.IOException + +@Suppress("TooGenericExceptionCaught") +internal fun ByteArray.unzipToString(): String { + return try { + String(this.unzip()!!, Charset.defaultCharset()) + } catch (e: Throwable) { + DebugLog.e(LOG_TAG, "ByteArray.unzipToString", e) + "" + } +} + +private fun InputStream?.unzip(): ByteArray? { + if (this !is ByteArrayInputStream) { + return try { + this?.readBytes().unzip() + } catch (e: IOException) { + DebugLog.e(LOG_TAG, "doUnZip 1", e) + null + } + } + var bos: ByteArrayOutputStream? = null + var gzipStream: InputStream? = null + var bytes: ByteArray? = null + try { + bos = ByteArrayOutputStream() + gzipStream = GZIPInputStream(this) + gzipStream.copyTo(bos) + bytes = bos.toByteArray() + } catch (e: IOException) { + DebugLog.e(LOG_TAG, "error while unzip", e) + } finally { + try { + gzipStream?.close() + bos?.close() + } catch (e: IOException) { + DebugLog.e(LOG_TAG, "error while closing stream", e) + } + } + return bytes +} + +private fun ByteArray?.unzip(): ByteArray? { + var stream: ByteArrayInputStream? = null + return try { + stream = ByteArrayInputStream(this) + stream.unzip() + } finally { + try { + stream?.close() + } catch (e: IOException) { + DebugLog.e(LOG_TAG, "error while closing zippedMessage stream", e) + } + } +} + +private const val LOG_TAG = "stream" diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/DepletingSource.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/utilities/DepletingSource.kt similarity index 86% rename from pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/DepletingSource.kt rename to pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/utilities/DepletingSource.kt index 869e40973..b3b881255 100644 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/DepletingSource.kt +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/utilities/DepletingSource.kt @@ -1,7 +1,8 @@ /** * Source: chucker - https://github.com/ChuckerTeam/chucker.git + * License: https://github.com/ChuckerTeam/chucker/blob/develop/LICENSE.txt */ -package com.mocklets.pluto.modules.network.interceptor +package com.pluto.plugins.network.interceptors.okhttp.internal.utilities import java.io.IOException import okio.Buffer diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/ReportingSink.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/utilities/ReportingSink.kt similarity index 90% rename from pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/ReportingSink.kt rename to pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/utilities/ReportingSink.kt index 09ca59bad..8ab48c7b3 100644 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/ReportingSink.kt +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/utilities/ReportingSink.kt @@ -1,9 +1,10 @@ /** - * Source: chucker - https://github.com/ChuckerTeam/chucker/blob/develop/library/src/main/java/com/chuckerteam/chucker/internal/support/ReportingSink.kt + * Source: chucker - https://github.com/ChuckerTeam/chucker.git * License: https://github.com/ChuckerTeam/chucker/blob/develop/LICENSE.txt */ -package com.mocklets.pluto.modules.network.interceptor +package com.pluto.plugins.network.interceptors.okhttp.internal.utilities +import com.pluto.plugins.network.interceptors.okhttp.internal.ResponseReportingSinkCallback import java.io.File import java.io.IOException import okio.Buffer @@ -21,8 +22,8 @@ import okio.sink */ internal class ReportingSink( private val downstreamFile: File?, - private val callback: Callback, - private val writeByteLimit: Long = Long.MAX_VALUE + private val callback: ResponseReportingSinkCallback, + private val writeByteLimit: Long = 300_000L ) : Sink { private var totalByteCount = 0L private var isFailure = false diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/TeeSource.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/utilities/TeeSource.kt similarity index 88% rename from pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/TeeSource.kt rename to pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/utilities/TeeSource.kt index 48a69c705..4fe2b8a45 100644 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/TeeSource.kt +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/interceptors/okhttp/internal/utilities/TeeSource.kt @@ -1,8 +1,8 @@ /** - * Source: chucker - https://github.com/ChuckerTeam/chucker/blob/develop/library/src/main/java/com/chuckerteam/chucker/internal/support/TeeSource.kt + * Source: chucker - https://github.com/ChuckerTeam/chucker.git * License: https://github.com/ChuckerTeam/chucker/blob/develop/LICENSE.txt */ -package com.mocklets.pluto.modules.network.interceptor +package com.pluto.plugins.network.interceptors.okhttp.internal.utilities import java.io.IOException import okio.Buffer diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/CustomTabKtx.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/CustomTabKtx.kt similarity index 78% rename from pluto/src/main/java/com/mocklets/pluto/core/extensions/CustomTabKtx.kt rename to pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/CustomTabKtx.kt index 1e6312621..1175440e6 100644 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/CustomTabKtx.kt +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/CustomTabKtx.kt @@ -1,4 +1,4 @@ -package com.mocklets.pluto.core.extensions +package com.pluto.plugins.network.internal import android.app.Activity import android.content.ActivityNotFoundException @@ -11,18 +11,19 @@ import androidx.browser.customtabs.CustomTabsClient import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_OFF import androidx.browser.customtabs.CustomTabsServiceConnection -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.R -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.modules.network.proxy.ui.NetworkProxySettingsFragment.Companion.IN_APP_BROWSER_RESULT_CODE +import com.pluto.plugins.network.R +import com.pluto.plugins.network.internal.mock.ui.MockSettingsFragment.Companion.IN_APP_BROWSER_RESULT_CODE +import com.pluto.utilities.DebugLog +import com.pluto.utilities.extensions.checkAndOpenSupportedApp +import com.pluto.utilities.extensions.color internal const val CHROME_PACKAGE_NAME = "com.android.chrome" internal fun Activity.customTab(uri: Uri) { val params = CustomTabColorSchemeParams.Builder() - .setNavigationBarColor(color(R.color.pluto___mocklets_title)) - .setToolbarColor(color(R.color.pluto___mocklets_title)) - .setSecondaryToolbarColor(color(R.color.pluto___mocklets_title)) + .setNavigationBarColor(color(R.color.pluto_network___mocklets_title)) + .setToolbarColor(color(R.color.pluto_network___mocklets_title)) + .setSecondaryToolbarColor(color(R.color.pluto_network___mocklets_title)) .build() val customIntentBuilder: CustomTabsIntent.Builder = CustomTabsIntent.Builder() @@ -36,7 +37,7 @@ internal fun Activity.customTab(uri: Uri) { try { customTabIntent.intent.data = uri - Pluto.activity.customTabOpened() +// Pluto.activity.customTabOpened() this.startActivityForResult(customTabIntent.intent, IN_APP_BROWSER_RESULT_CODE) } catch (e: ActivityNotFoundException) { DebugLog.e("chrome-tab", "not able to handle request", e) diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/DataModel.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/DataModel.kt new file mode 100644 index 000000000..8f9c13312 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/DataModel.kt @@ -0,0 +1,37 @@ +package com.pluto.plugins.network.internal + +import com.pluto.plugins.network.intercept.NetworkData +import com.pluto.plugins.network.intercept.NetworkInterceptor +import com.pluto.plugins.network.internal.interceptor.logic.ExceptionData +import com.pluto.plugins.network.internal.share.getCurl +import com.pluto.utilities.list.ListItem + +internal data class MockConfig( + val url: String, +) + +internal data class Status( + val code: Int, + val message: String, +) + +internal class ApiCallData( + val id: String, + val interceptorOption: NetworkInterceptor.Option, + val request: NetworkData.Request, + var response: NetworkData.Response? = null, + var exception: ExceptionData? = null, + var mock: MockConfig? = null +) : ListItem() { + val curl: String + get() = request.getCurl() + val responseTime + get() = exception?.timeStamp ?: response?.receiveTimestamp + + override fun isEqual(other: Any): Boolean { + if (other is ApiCallData) { + id == other.id && response == other.response && exception == other.exception + } + return false + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/NetworkFragment.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/NetworkFragment.kt new file mode 100644 index 000000000..40ff7cdfc --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/NetworkFragment.kt @@ -0,0 +1,6 @@ +package com.pluto.plugins.network.internal + +import androidx.fragment.app.Fragment +import com.pluto.plugins.network.R + +internal class NetworkFragment : Fragment(R.layout.pluto_network___fragment_network) diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/Session.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/Session.kt new file mode 100644 index 000000000..c351d9845 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/Session.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.network.internal + +internal object Session { + var lastSearchText: String? = null +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/database/DatabaseManager.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/database/DatabaseManager.kt new file mode 100644 index 000000000..b361bbc6d --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/database/DatabaseManager.kt @@ -0,0 +1,18 @@ +package com.pluto.plugins.network.internal.database + +import android.content.Context +import androidx.room.Room + +internal class DatabaseManager(context: Context) { + + val db by lazy { + Room.databaseBuilder(context, PlutoNetworkDatabase::class.java, DATABASE_NAME) + .addMigrations() + .fallbackToDestructiveMigration() + .build() + } + + companion object { + private const val DATABASE_NAME = "_pluto_network_database" + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/database/PlutoNetworkDatabase.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/database/PlutoNetworkDatabase.kt new file mode 100644 index 000000000..82091dde0 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/database/PlutoNetworkDatabase.kt @@ -0,0 +1,16 @@ +package com.pluto.plugins.network.internal.database + +import androidx.room.RoomDatabase +import com.pluto.plugins.network.internal.mock.logic.dao.MockSettingsDao +import com.pluto.plugins.network.internal.mock.logic.dao.MockSettingsEntity + +@androidx.room.Database( + entities = [ + MockSettingsEntity::class + ], + version = 1, + exportSchema = false +) +internal abstract class PlutoNetworkDatabase : RoomDatabase() { + abstract fun mockSettingsDao(): MockSettingsDao +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/ContentProcessor.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/ContentProcessor.kt new file mode 100644 index 000000000..27cd6c75d --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/ContentProcessor.kt @@ -0,0 +1,88 @@ +package com.pluto.plugins.network.internal.interceptor.logic + +import android.content.Context +import com.pluto.plugins.network.R +import com.pluto.plugins.network.intercept.NetworkData.Body +import com.pluto.plugins.network.internal.interceptor.logic.transformers.FormEncodedTransformer +import com.pluto.plugins.network.internal.interceptor.logic.transformers.JsonTransformer +import com.pluto.plugins.network.internal.interceptor.logic.transformers.XmlTransformer +import com.pluto.utilities.extensions.color +import com.pluto.utilities.spannable.createSpan +import io.ktor.http.Url +import io.ktor.util.flattenForEach +import java.math.BigDecimal +import java.nio.charset.Charset + +internal fun Context.beautifyHeaders(data: Map): CharSequence { + return createSpan { + data.forEach { + append(fontColor("${it.key} : ", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + if (it.value != null) { + append(fontColor("${it.value}", context.color(com.pluto.plugin.R.color.pluto___text_dark_80))) + } else { + append( + fontColor( + light(italic("null")), + context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + } + append("\n") + } + }.trim() +} + +internal fun Context.beautifyQueryParams(url: String): CharSequence { + val url = Url(url) + return createSpan { + url.parameters.flattenForEach { key, value -> + append(fontColor("$key :", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + append(fontColor(value, context.color(com.pluto.plugin.R.color.pluto___text_dark_80))) + append("\n") + } + }.trim() +} + +// internal fun ProcessedBody.flatten(): String { +// return body.toString().let { body -> +// when { +// mediaType == "binary" -> body +// mediaSubtype == "json" -> JsonTransformer().flatten(body) +// mediaSubtype == "xml" || mediaSubtype == "html" -> XmlTransformer().flatten(body) +// mediaSubtype == "x-www-form-urlencoded" -> FormEncodedTransformer().flatten(body) +// else -> body +// } +// } +// } + +internal fun Body.beautify(): CharSequence { + return when { + mediaSubtype.endsWith("json") -> JsonTransformer().beautify(body) + mediaSubtype == "xml" || mediaSubtype == "html" -> XmlTransformer().beautify(body) + mediaSubtype == "x-www-form-urlencoded" -> FormEncodedTransformer().beautify(body) + else -> body + } +} + +internal fun String.pruneQueryParams(): String = split("?")[0] + +internal fun formatSizeAsBytes(origin: Long): String { + var size = BigDecimal(origin.toString()) + return if (size < KILO_BYTES) { + "$size bytes" + } else { + size = size.divide(KILO_BYTES) + if (size > KILO_BYTES) { + "${size.divide(KILO_BYTES, 2, BigDecimal.ROUND_DOWN)} MB" + } else { + "${size.setScale(2, BigDecimal.ROUND_DOWN)} KB" + } + } +} + +internal const val LOGTAG = "pluto_network" + +val UTF8: Charset = Charset.forName("UTF-8") +private val KILO_BYTES = BigDecimal("1024") +internal const val MAX_BLOB_LENGTH = 25_000 +internal val RESPONSE_ERROR_STATUS_RANGE = 400..499 diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/NetworkCallsRepo.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/NetworkCallsRepo.kt similarity index 90% rename from pluto/src/main/java/com/mocklets/pluto/modules/network/NetworkCallsRepo.kt rename to pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/NetworkCallsRepo.kt index 91c5dada2..cd7b0eb2c 100644 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/NetworkCallsRepo.kt +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/NetworkCallsRepo.kt @@ -1,7 +1,8 @@ -package com.mocklets.pluto.modules.network +package com.pluto.plugins.network.internal.interceptor.logic import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import com.pluto.plugins.network.internal.ApiCallData import java.util.Collections internal object NetworkCallsRepo { diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/ResponseCodeMessageMapper.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/ResponseCodeMessageMapper.kt new file mode 100644 index 000000000..248d5cc3b --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/ResponseCodeMessageMapper.kt @@ -0,0 +1,51 @@ +package com.pluto.plugins.network.internal.interceptor.logic + +@SuppressWarnings("MagicNumber", "ComplexMethod") +internal fun mapCode2Message(code: Int): String { + return when (code) { + 100 -> "Continue" + 101 -> "Switching Protocols" + 103 -> "Checkpoint" + 200 -> "OK" + 201 -> "Created" + 202 -> "Accepted" + 203 -> "Non-Authoritative Information" + 204 -> "No Content" + 205 -> "Reset Content" + 206 -> "Partial Content" + 300 -> "Multiple Choices" + 301 -> "Moved Permanently" + 302 -> "Found" + 303 -> "See Other" + 304 -> "Not Modified" + 306 -> "Switch Proxy" + 307 -> "Temporary Redirect" + 308 -> "Resume Incomplete" + 400 -> "Bad Request" + 401 -> "Unauthorized" + 402 -> "Payment Required" + 403 -> "Forbidden" + 404 -> "Not Found" + 405 -> "Method Not Allowed" + 406 -> "Not Acceptable" + 407 -> "Proxy Authentication Required" + 408 -> "Request Timeout" + 409 -> "Conflict" + 410 -> "Gone" + 411 -> "Length Required" + 412 -> "Precondition Failed" + 413 -> "Request Entity Too Large" + 414 -> "Request-URI Too Long" + 415 -> "Unsupported Media Type" + 416 -> "Requested Range Not Satisfiable" + 417 -> "Expectation Failed" + 500 -> "Internal Server Error" + 501 -> "Not Implemented" + 502 -> "Bad Gateway" + 503 -> "Service Unavailable" + 504 -> "Gateway Timeout" + 505 -> "HTTP Version Not Supported" + 511 -> "Network Authentication Required" + else -> "~" + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/ThrowableKtx.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/ThrowableKtx.kt new file mode 100644 index 000000000..3253833ba --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/ThrowableKtx.kt @@ -0,0 +1,54 @@ +package com.pluto.plugins.network.internal.interceptor.logic + +import android.content.Context +import androidx.annotation.Keep +import com.pluto.plugins.network.R +import com.pluto.utilities.extensions.color +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.spannable.createSpan + +internal fun Throwable.asExceptionData(): ExceptionData { + return ExceptionData( + name = this.toString().replace(": $message", "", true), + message = message, + stackTrace = stackTrace.asStringArray(), + file = stackTrace.getOrNull(0)?.fileName, + lineNumber = stackTrace.getOrNull(0)?.lineNumber ?: Int.MIN_VALUE + ) +} + +@Keep +internal data class ExceptionData( + val message: String?, + val name: String?, + val file: String?, + val lineNumber: Int, + val stackTrace: ArrayList, + val timeStamp: Long = System.currentTimeMillis() +) : ListItem() + +internal fun Array.asStringArray(): ArrayList { + val array = arrayListOf() + forEach { + if (it.isNativeMethod) { + array.add("${it.className}.${it.methodName}(Native Method)") + } else { + array.add("${it.className}.${it.methodName}(${it.fileName}:${it.lineNumber})") + } + } + return array +} + +internal fun Context?.beautifyAttributes(data: Map): CharSequence? { + return this?.createSpan { + data.forEach { + append("${it.key} : ") + if (it.value != null) { + append(fontColor(semiBold("${it.value}"), context.color(com.pluto.plugin.R.color.pluto___text_dark_80))) + } else { + append(fontColor(light(italic("null")), context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + append("\n") + } + }?.trim() +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/AnyJsonAdapter.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/AnyJsonAdapter.kt new file mode 100644 index 000000000..fb300aba1 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/AnyJsonAdapter.kt @@ -0,0 +1,113 @@ +package com.pluto.plugins.network.internal.interceptor.logic.transformers + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.ToJson +import com.squareup.moshi.internal.Util +import java.io.IOException + +internal class AnyJsonAdapter( + private val moshi: Moshi = Moshi.Builder().build() +) : JsonAdapter() { + + @Throws(IOException::class) + @FromJson + override fun fromJson(reader: JsonReader): Any? { + return reader.readJsonValueNew() + } + @Throws(IOException::class) + @ToJson + override fun toJson(writer: JsonWriter, value: Any?) { + val valueClass: Class<*> = value!!.javaClass + if (valueClass == Any::class.java) { + // Don't recurse infinitely when the runtime type is also Object.class. + writer.beginObject() + writer.endObject() + } else { + moshi.adapter(toJsonType(valueClass), Util.NO_ANNOTATIONS).toJson(writer, value) + } + } + + private fun JsonReader.nextNumber(): Number { + try { + return nextInt() + } catch (ignored: Throwable) { + ignored.printStackTrace() + } + + try { + return nextLong() + } catch (ignored: Throwable) { + ignored.printStackTrace() + } + + return nextDouble() + } + + /** + * Returns the type to look up a type adapter for when writing `value` to JSON. Without + * this, attempts to emit standard types like `LinkedHashMap` would fail because Moshi doesn't + * provide built-in adapters for implementation types. It knows how to **write** + * those types, but lacks a mechanism to read them because it doesn't know how to find the + * appropriate constructor. + */ + private fun toJsonType(valueClass: Class<*>): Class<*> { + if (MutableMap::class.java.isAssignableFrom(valueClass)) return MutableMap::class.java + return if (Collection::class.java.isAssignableFrom(valueClass)) Collection::class.java else valueClass + } + + /** + * copied from JsonReader.readJsonValue() + * updated JsonReader.Token.NUMBER case to try parsing as int, long, and double sequentially + */ + @Throws(IOException::class) + fun JsonReader.readJsonValueNew(): Any? { + when (peek()) { + JsonReader.Token.BEGIN_ARRAY -> { + val list: MutableList = ArrayList() + beginArray() + while (hasNext()) { + list.add(readJsonValueNew()) + } + endArray() + return list + } + + JsonReader.Token.BEGIN_OBJECT -> { + val map: MutableMap = HashMap() + beginObject() + while (hasNext()) { + val name: String = nextName() + val value = readJsonValueNew() + val replaced = map.put(name, value) + if (replaced != null) { + throw JsonDataException( + "Map key '" + + name + + "' has multiple values at path " + + getPath() + + ": " + + replaced + + " and " + + value + ) + } + } + endObject() + return map + } + + JsonReader.Token.STRING -> return nextString() + JsonReader.Token.NUMBER -> return nextNumber() + JsonReader.Token.BOOLEAN -> return nextBoolean() + JsonReader.Token.NULL -> return nextNull() + else -> throw java.lang.IllegalStateException( + "Expected a value but was " + peek() + " at path " + getPath() + ) + } + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/BaseTransformer.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/BaseTransformer.kt new file mode 100644 index 000000000..02990f66d --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/BaseTransformer.kt @@ -0,0 +1,6 @@ +package com.pluto.plugins.network.internal.interceptor.logic.transformers + +internal interface BaseTransformer { + fun beautify(plain: CharSequence): CharSequence + fun flatten(plain: CharSequence): String +} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/FormEncodedTransformer.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/FormEncodedTransformer.kt similarity index 83% rename from pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/FormEncodedTransformer.kt rename to pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/FormEncodedTransformer.kt index 5a414f228..9dbde0e01 100644 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/FormEncodedTransformer.kt +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/FormEncodedTransformer.kt @@ -1,15 +1,15 @@ -package com.mocklets.pluto.modules.network.transformers +package com.pluto.plugins.network.internal.interceptor.logic.transformers -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.modules.network.LOGTAG -import com.mocklets.pluto.modules.network.UTF8 +import com.pluto.plugins.network.internal.interceptor.logic.LOGTAG +import com.pluto.plugins.network.internal.interceptor.logic.UTF8 +import com.pluto.utilities.DebugLog import java.net.URLDecoder import java.net.URLEncoder internal class FormEncodedTransformer : BaseTransformer { @Suppress("TooGenericExceptionCaught") - override fun beautify(plain: CharSequence, indent: Int): CharSequence? { + override fun beautify(plain: CharSequence): CharSequence { return try { val items = plain.split("&") val stringBuilder = StringBuilder() diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/JsonTransformer.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/JsonTransformer.kt new file mode 100644 index 000000000..2468ad874 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/JsonTransformer.kt @@ -0,0 +1,31 @@ +package com.pluto.plugins.network.internal.interceptor.logic.transformers + +import com.pluto.plugins.network.internal.interceptor.logic.LOGTAG +import com.pluto.utilities.DebugLog +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi + +internal class JsonTransformer : BaseTransformer { + + private val moshi: Moshi = Moshi.Builder().add(AnyJsonAdapter()).build() + private val moshiAnyAdapter: JsonAdapter = moshi.adapter(Any::class.java) + + @SuppressWarnings("TooGenericExceptionCaught") + override fun beautify(plain: CharSequence): CharSequence { + return try { + val jsonObject = moshiAnyAdapter.fromJson(plain.toString()) + moshiAnyAdapter.indent(INDENTATION_TEXT).toJson(jsonObject) + } catch (e: Exception) { + DebugLog.e(LOGTAG, "json parsing failed", e) + plain + } + } + + override fun flatten(plain: CharSequence): String { + return plain.toString().replace("\n", "").replace("\\s+".toRegex(), "") + } + + companion object { + private const val INDENTATION_TEXT = "\t\t" + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/XmlTransformer.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/XmlTransformer.kt new file mode 100644 index 000000000..414217db2 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/XmlTransformer.kt @@ -0,0 +1,39 @@ +package com.pluto.plugins.network.internal.interceptor.logic.transformers + +import com.pluto.plugins.network.internal.interceptor.logic.LOGTAG +import com.pluto.utilities.DebugLog +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.xml.transform.OutputKeys +import javax.xml.transform.Transformer +import javax.xml.transform.sax.SAXSource +import javax.xml.transform.sax.SAXTransformerFactory +import javax.xml.transform.stream.StreamResult +import org.xml.sax.InputSource + +internal class XmlTransformer : BaseTransformer { + + @Suppress("TooGenericExceptionCaught") + override fun beautify(plain: CharSequence): CharSequence { + return try { + val serializer: Transformer = SAXTransformerFactory.newInstance().newTransformer() + serializer.setOutputProperty(OutputKeys.INDENT, "yes") + serializer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", INDENTATION_TEXT) + val xmlSource = SAXSource(InputSource(ByteArrayInputStream(plain.toString().toByteArray()))) + val res = StreamResult(ByteArrayOutputStream()) + serializer.transform(xmlSource, res) + String((res.outputStream as ByteArrayOutputStream).toByteArray()) + } catch (e: Exception) { + DebugLog.e(LOGTAG, "xml parsing failed : $plain", e) + plain + } + } + + override fun flatten(plain: CharSequence): String { + return plain.toString().replace("\n", "").replace("\\s+".toRegex(), "") + } + + companion object { + private const val INDENTATION_TEXT = "\t\t" + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/ContentFragment.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/ContentFragment.kt new file mode 100644 index 000000000..745e23cb3 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/ContentFragment.kt @@ -0,0 +1,163 @@ +package com.pluto.plugins.network.internal.interceptor.ui + +import android.os.Bundle +import android.os.Parcelable +import android.text.Layout +import android.view.View +import android.view.View.VISIBLE +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkFragmentContentBinding +import com.pluto.utilities.extensions.hideKeyboard +import com.pluto.utilities.extensions.onBackPressed +import com.pluto.utilities.extensions.showKeyboard +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import com.pluto.utilities.viewBinding +import kotlinx.parcelize.Parcelize + +internal class ContentFragment : Fragment(R.layout.pluto_network___fragment_content) { + + private val binding by viewBinding(PlutoNetworkFragmentContentBinding::bind) + private val contentSharer by lazyContentSharer() + private val argumentData: ContentFormatterData? + get() = arguments?.getParcelable(DATA) + + private var currentHighlightIndex = 0 + private var occurrences = emptyList() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBackPressed { handleBackPress() } + binding.close.setOnDebounceClickListener { requireActivity().onBackPressed() } + binding.search.setOnDebounceClickListener { binding.searchView.visibility = VISIBLE } + binding.search.setOnDebounceClickListener { + binding.searchView.visibility = VISIBLE + binding.searchView.requestFocus() + } + binding.closeSearch.setOnDebounceClickListener { exitSearch() } + binding.clearSearch.setOnDebounceClickListener { binding.editSearch.text = null } + binding.editSearch.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + v.showKeyboard() + } else { + v.hideKeyboard() + } + } + binding.editSearch.doOnTextChanged { text, _, _, _ -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + text?.toString()?.let { search -> + processSearch(search) + } + } + } + + binding.previousHighlight.setOnDebounceClickListener { + if (occurrences.isNotEmpty()) { + currentHighlightIndex = (currentHighlightIndex - 1 + occurrences.size) % occurrences.size + scrollToText(occurrences[currentHighlightIndex], binding.editSearch.text.toString()) + } + } + + binding.nextHighlight.setOnDebounceClickListener { + if (occurrences.isNotEmpty()) { + currentHighlightIndex = (currentHighlightIndex + 1) % occurrences.size + scrollToText(occurrences[currentHighlightIndex], binding.editSearch.text.toString()) + } + } + + binding.share.setOnDebounceClickListener { + argumentData?.let { + contentSharer.share( + Shareable( + title = "Share content", + content = it.content.toString() + ) + ) + } + } + argumentData?.let { + binding.title.text = it.title + binding.typeFilter.text = it.typeText + binding.contentSize.text = it.sizeText + binding.editSearch.setText("") + } + } + + private fun exitSearch() { + binding.editSearch.text = null + binding.searchView.visibility = View.GONE + binding.editSearch.clearFocus() + } + + private fun handleBackPress() { + if (binding.searchView.isVisible) { + exitSearch() + } else { + findNavController().navigateUp() + } + } + + private fun processSearch(search: String) { + currentHighlightIndex = 0 + argumentData?.let { + binding.content.setSpan { + occurrences = occurrences(it.content, search.trim()) + append(highlight(it.content, search.trim(), occurrences)) + append("\n") + binding.searchCount.visibility = if (search.isEmpty()) View.GONE else VISIBLE + binding.searchCount.text = occurrences.size.toString() + val highlightsVisibility = if (occurrences.size < 2) View.GONE else VISIBLE + binding.previousHighlight.visibility = highlightsVisibility + binding.nextHighlight.visibility = highlightsVisibility + } + } + + scrollToText(currentHighlightIndex, search.trim()) + } + + /** + * helps to auto scroll to target search + */ + private fun scrollToText(startIndex: Int, targetText: String) { + if (targetText.isEmpty()) { + return + } + + val contentText = binding.content.getText().toString().lowercase() + val index = contentText.indexOf(targetText.lowercase(), startIndex) + + if (index != -1) { + binding.content.post { + val layout: Layout? = binding.content.layout + if (layout != null) { + val lineNumber = layout.getLineForOffset(index) + val x = layout.getPrimaryHorizontal(index.plus(targetText.length)).toInt() + val y = layout.getLineTop(lineNumber) + + binding.horizontalScroll.smoothScrollTo(x / 2, 0) + binding.contentNestedScrollView.smoothScrollTo(0, y) + } + } + } + } + + companion object { + internal const val DATA = "data" + } +} + +@Parcelize +internal data class ContentFormatterData( + val title: String, + val content: CharSequence, + val typeText: String? = null, + val sizeText: String, + val isTreeViewAllowed: Boolean = false +) : Parcelable diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/DetailsFragment.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/DetailsFragment.kt new file mode 100644 index 000000000..bdf3c4374 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/DetailsFragment.kt @@ -0,0 +1,281 @@ +package com.pluto.plugins.network.internal.interceptor.ui + +import android.os.Bundle +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.navigation.fragment.findNavController +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkFragmentDetailsBinding +import com.pluto.plugins.network.intercept.NetworkData +import com.pluto.plugins.network.internal.ApiCallData +import com.pluto.plugins.network.internal.interceptor.logic.RESPONSE_ERROR_STATUS_RANGE +import com.pluto.plugins.network.internal.interceptor.logic.beautify +import com.pluto.plugins.network.internal.interceptor.logic.beautifyHeaders +import com.pluto.plugins.network.internal.interceptor.logic.beautifyQueryParams +import com.pluto.plugins.network.internal.interceptor.logic.formatSizeAsBytes +import com.pluto.plugins.network.internal.interceptor.ui.ContentFragment.Companion.DATA +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.onBackPressed +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import com.pluto.utilities.viewBinding +import io.ktor.http.Url + +internal class DetailsFragment : Fragment(R.layout.pluto_network___fragment_details) { + + private val binding by viewBinding(PlutoNetworkFragmentDetailsBinding::bind) + private val viewModel: NetworkViewModel by activityViewModels() + private val contentSharer by lazyContentSharer() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBackPressed { findNavController().navigateUp() } + setupControls() + + viewModel.detailContentLiveData.removeObserver(detailsObserver) + viewModel.detailContentLiveData.observe(viewLifecycleOwner, detailsObserver) + + viewModel.apiCalls.removeObserver(listUpdateObserver) + viewModel.apiCalls.observe(viewLifecycleOwner, listUpdateObserver) + } + + private fun setupControls() { + binding.close.setOnDebounceClickListener { + requireActivity().onBackPressed() + } + binding.share.setOnDebounceClickListener { + findNavController().navigate(R.id.openShareView) + } + } + + private fun handleUserAction(action: String, api: ApiCallData) { + when (action) { + ACTION_SHARE_CURL -> contentSharer.share( + Shareable( + title = "Share Request cURL", + content = api.curl, + fileName = "cURL Request from Pluto" + ) + ) + + ACTION_OPEN_MOCK_SETTINGS -> findNavController().navigate( + R.id.openMockSettingsEdit, + bundleOf("url" to api.request.url.toString(), "method" to api.request.method) + ) + + ACTION_OPEN_REQ_HEADERS -> openContentView( + title = getString(R.string.pluto_network___content_request_headers), + content = requireContext().beautifyHeaders(api.request.headers), + sizeText = "${api.request.headers.size} items" + ) + + ACTION_OPEN_REQ_PARAMS -> openContentView( + title = getString(R.string.pluto_network___content_request_query_param), + content = requireContext().beautifyQueryParams(api.request.url), + sizeText = "${Url(api.request.url).parameters.names().count()} items" + ) + + ACTION_OPEN_REQ_BODY -> api.request.body?.let { + openContentView( + title = getString(R.string.pluto_network___content_request_body), + content = it.beautify(), + typeText = it.mediaTypeFull, + sizeText = formatSizeAsBytes(it.sizeInBytes), + isTreeViewAllowed = true + ) + } + + ACTION_OPEN_RES_HEADERS -> api.response?.headers?.let { + openContentView( + title = getString(R.string.pluto_network___content_response_headers), + content = requireContext().beautifyHeaders(it), + sizeText = "${it.size} items" + ) + } + + ACTION_OPEN_RES_BODY -> api.response?.body?.let { + openContentView( + title = getString(R.string.pluto_network___content_response_body), + content = it.beautify(), + typeText = it.mediaTypeFull, + sizeText = formatSizeAsBytes(it.sizeInBytes), + isTreeViewAllowed = true + ) + } + + ACTION_CUSTOM_TRACE_INFO -> findNavController().navigate(R.id.openCustomTraceInfo) + } + } + + private fun openContentView( + title: String, + content: CharSequence, + typeText: String? = null, + sizeText: String, + isTreeViewAllowed: Boolean = false + ) { + findNavController().navigate( + R.id.openContentFormatter, + bundleOf( + DATA to ContentFormatterData( + title = title, + content = content, + typeText = typeText, + sizeText = sizeText, + isTreeViewAllowed = isTreeViewAllowed + ) + ) + ) + } + + private val detailsObserver = Observer { + setupStatusView(it.api) + val graphqlData = it.api.request.graphqlData + binding.graphqlIcon.isVisible = graphqlData != null + if (graphqlData != null) { + binding.method.text = "${graphqlData.queryType.uppercase()} ${graphqlData.queryName}" + binding.url.text = graphqlData.variables.toString() + } else { + binding.method.text = it.api.request.method.uppercase() + binding.url.text = Url(it.api.request.url).toString() + } + + binding.overview.apply { + visibility = VISIBLE + set(it.api) + } + handleCtas { action -> handleUserAction(action, it.api) } + binding.request.apply { + visibility = VISIBLE + set(it.api.request) { action -> handleUserAction(action, it.api) } + } + binding.response.apply { + visibility = VISIBLE + set(it.api.response, it.api.exception) { action -> handleUserAction(action, it.api) } + } + } + + private fun handleCtas(onAction: (String) -> Unit) { + binding.settingStub.proxyRoot.setOnDebounceClickListener { + onAction.invoke(ACTION_OPEN_MOCK_SETTINGS) + } + binding.settingStub.copyCurl.setOnDebounceClickListener { _ -> + onAction.invoke(ACTION_SHARE_CURL) + } + binding.settingStub.dividerTop.visibility = VISIBLE + binding.settingStub.proxyRoot.visibility = VISIBLE + } + + private val listUpdateObserver = Observer> { + val id = requireArguments().getString(API_CALL_ID, null) + if (!id.isNullOrEmpty()) { + viewModel.setCurrent(id) + } else { + requireContext().toast("invalid id") + requireActivity().onBackPressed() + } + } + + private fun setupStatusView(data: ApiCallData) { + binding.progress.visibility = VISIBLE + binding.status.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) + binding.status.setSpan { + append( + italic( + fontColor( + context.getString(R.string.pluto_network___network_state_in_progress), + context.color(com.pluto.plugin.R.color.pluto___white_60) + ) + ) + ) + } + + data.exception?.let { + binding.progress.visibility = GONE + binding.status.setCompoundDrawablesWithIntrinsicBounds( + R.drawable.pluto_network___ic_error, + 0, + 0, + 0 + ) + binding.status.setSpan { + append( + bold( + fontColor( + context.getString(R.string.pluto_network___network_state_failed), + context.color(com.pluto.plugin.R.color.pluto___red) + ) + ) + ) + } + } + + data.response?.let { + binding.progress.visibility = GONE + binding.status.setCompoundDrawablesWithIntrinsicBounds( + getErrorIcon(it), 0, 0, 0 + ) + binding.status.setSpan { + append( + fontColor( + bold(it.status.code.toString()), + context.color(getStatusTextColorId(it)) + ) + ) + append( + italic( + fontColor( + " ${it.status.message} ", + context.color(getStatusTextColorId(it)) + ) + ) + ) + } + } + } + + private fun getStatusTextColorId(it: NetworkData.Response): Int { + return if (it.isSuccessful) { + com.pluto.plugin.R.color.pluto___dull_green + } else { + if (it.status.code in RESPONSE_ERROR_STATUS_RANGE) { + com.pluto.plugin.R.color.pluto___orange + } else { + com.pluto.plugin.R.color.pluto___red + } + } + } + + private fun getErrorIcon(it: NetworkData.Response): Int { + return if (it.isSuccessful) { + R.drawable.pluto_network___ic_success + } else { + if (it.status.code in RESPONSE_ERROR_STATUS_RANGE) { + R.drawable.pluto_network___ic_error_orange + } else { + R.drawable.pluto_network___ic_error + } + } + } + + companion object { + internal const val API_CALL_ID = "id" + internal const val ACTION_SHARE_CURL = "share_curl" + internal const val ACTION_OPEN_MOCK_SETTINGS = "open_mock_settings" + internal const val ACTION_OPEN_REQ_HEADERS = "open_request_headers" + internal const val ACTION_OPEN_REQ_PARAMS = "open_request_params" + internal const val ACTION_OPEN_REQ_BODY = "open_request_body" + internal const val ACTION_OPEN_RES_HEADERS = "open_response_headers" + internal const val ACTION_OPEN_RES_BODY = "open_response_body" + internal const val ACTION_CUSTOM_TRACE_INFO = "custom_trace_info" + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/ListFragment.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/ListFragment.kt new file mode 100644 index 000000000..f3a09735c --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/ListFragment.kt @@ -0,0 +1,108 @@ +package com.pluto.plugins.network.internal.interceptor.ui + +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkFragmentListBinding +import com.pluto.plugins.network.internal.ApiCallData +import com.pluto.plugins.network.internal.Session +import com.pluto.plugins.network.internal.interceptor.logic.NetworkCallsRepo +import com.pluto.plugins.network.internal.interceptor.ui.DetailsFragment.Companion.API_CALL_ID +import com.pluto.plugins.network.internal.interceptor.ui.list.NetworkAdapter +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.hideKeyboard +import com.pluto.utilities.extensions.linearLayoutManager +import com.pluto.utilities.extensions.showMoreOptions +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +internal class ListFragment : Fragment(R.layout.pluto_network___fragment_list) { + + private val binding by viewBinding(PlutoNetworkFragmentListBinding::bind) + private val viewModel: NetworkViewModel by activityViewModels() + private val networkAdapter: BaseAdapter by autoClearInitializer { + NetworkAdapter(onActionListener) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.apiList.apply { + adapter = networkAdapter + addItemDecoration(CustomItemDecorator(requireContext())) + } + binding.search.doOnTextChanged { text, _, _, _ -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + text?.toString()?.let { + Session.lastSearchText = it + networkAdapter.list = filteredLogs(it) + if (it.isEmpty()) { + binding.apiList.linearLayoutManager()?.scrollToPositionWithOffset(0, 0) + } + } + } + } + binding.search.setText(Session.lastSearchText) + binding.close.setOnDebounceClickListener { + requireActivity().finish() + } + binding.options.setOnDebounceClickListener { + requireContext().showMoreOptions(it, R.menu.pluto_network___menu_more_options) { item -> + when (item.itemId) { + R.id.clear -> NetworkCallsRepo.deleteAll() + R.id.mock_settings -> findNavController().navigate(R.id.mockSettingsListView) + } + } + } + + viewModel.apiCalls.removeObserver(listObserver) + viewModel.apiCalls.observe(viewLifecycleOwner, listObserver) + } + + private fun filteredLogs(search: String): List { + var list = emptyList() + viewModel.apiCalls.value?.let { + list = it.filter { api -> + api.request.url.contains(search, true) || + api.request.graphqlData?.queryName?.contains(search, true) ?: false + } + } + binding.noItemText.text = getString( + if (search.isNotEmpty()) { + R.string.pluto_network___no_search_result + } else { + R.string.pluto_network___no_api_text + } + ) + binding.noItemText.visibility = if (list.isEmpty()) View.VISIBLE else View.GONE + return list + } + + private val listObserver = Observer> { + networkAdapter.list = filteredLogs(binding.search.text.toString()) + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is ApiCallData) { + requireActivity().let { + it.hideKeyboard(viewLifecycleOwner.lifecycleScope) { + val bundle = bundleOf(API_CALL_ID to data.id) + findNavController().navigate(R.id.openDetails, bundle) + } + } + } + } + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/NetworkViewModel.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/NetworkViewModel.kt new file mode 100644 index 000000000..eda7e54fe --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/NetworkViewModel.kt @@ -0,0 +1,49 @@ +package com.pluto.plugins.network.internal.interceptor.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.pluto.plugins.network.internal.ApiCallData +import com.pluto.plugins.network.internal.interceptor.logic.NetworkCallsRepo + +internal class NetworkViewModel : ViewModel() { + + val apiCalls: LiveData> + get() = NetworkCallsRepo.apiCalls + + private val _currentApiCall = MutableLiveData() + private val _contentSearch = MutableLiveData() + + val detailContentLiveData: LiveData + get() = _detailContentLiveData + private val _detailContentLiveData = MediatorLiveData() + + init { + _detailContentLiveData.addSource(_currentApiCall) { + combineData(_currentApiCall, _contentSearch) + } + _detailContentLiveData.addSource(_contentSearch) { + combineData(_currentApiCall, _contentSearch) + } + } + + private fun combineData(apiCallLD: MutableLiveData, searchLD: MutableLiveData) { + apiCallLD.value?.let { + _detailContentLiveData.postValue(DetailContentData(it, searchLD.value)) + } + } + + fun setCurrent(id: String) { + _currentApiCall.postValue(NetworkCallsRepo.get(id)) + } + + fun searchContent(it: String) { + _contentSearch.postValue(it.trim()) + } +} + +internal data class DetailContentData( + val api: ApiCallData, + val search: String? +) diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/CommonDetailsComponents.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/CommonDetailsComponents.kt new file mode 100644 index 000000000..8a2721758 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/CommonDetailsComponents.kt @@ -0,0 +1,143 @@ +package com.pluto.plugins.network.internal.interceptor.ui.components + +import android.content.Context +import com.pluto.plugins.network.R +import com.pluto.plugins.network.intercept.NetworkData.Body +import com.pluto.plugins.network.internal.interceptor.logic.formatSizeAsBytes +import com.pluto.utilities.extensions.color +import com.pluto.utilities.spannable.createSpan +import com.pluto.utilities.views.keyvalue.KeyValuePairData +import io.ktor.http.Url + +internal fun waitingText(context: Context) = context.createSpan { + append( + italic( + light( + fontColor( + context.getString(R.string.pluto_network___waiting_for_response), + context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + ) + ) +} + +internal fun tapIndicatorText(context: Context) = context.createSpan { + append("\t\t") + append( + italic( + light( + fontColor( + context.getString(R.string.pluto_network___tap_for_details), + context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + ) + ) +} + +internal fun binaryBodyText(context: Context) = context.createSpan { + append( + italic( + light( + fontColor( + context.getString(R.string.pluto_network___binary_body), + context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + ) + ) +} + +internal fun getHeadersData(context: Context, headers: Map, onClick: () -> Unit) = KeyValuePairData( + key = context.getString(R.string.pluto_network___headers_title), + value = context.createSpan { + if (headers.isNotEmpty()) { + append(semiBold(context.resources.getQuantityString(R.plurals.pluto_network___headers_value_text, headers.size, headers.size))) + append(tapIndicatorText(context)) + } else { + append(fontColor("--", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + }, + showClickIndicator = headers.isNotEmpty(), + onClick = if (headers.isNotEmpty()) { + { onClick.invoke() } + } else null +) + +@SuppressWarnings("UnnecessaryParentheses") +internal fun getBodyData(context: Context, body: Body?, onClick: () -> Unit) = KeyValuePairData( + key = context.getString(R.string.pluto_network___body_title), + value = context.createSpan { + body?.let { + if (it.isBinary) { + append(binaryBodyText(context)) + } else { + if (it.sizeInBytes > 0) { + append(semiBold(formatSizeAsBytes(it.sizeInBytes))) + append(tapIndicatorText(context)) + } else { + append( + fontColor( + "0 bytes", + context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + } + } + } ?: run { + append(fontColor("--", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + }, + showClickIndicator = body?.isBinary != true && (body?.sizeInBytes ?: 0L) > 0, + onClick = body?.let { + if (it.isBinary) { + null + } else { + if (it.sizeInBytes > 0) { + { onClick.invoke() } + } else { + null + } + } + } +) + +internal fun getQueryParamsData( + context: Context, + url: String, + onClick: () -> Unit +): KeyValuePairData { + val url = Url(url) + return KeyValuePairData( + key = context.getString(R.string.pluto_network___query_params_title), + value = context.createSpan { + if (url.areQueryParamsAvailable) { + append( + semiBold( + context.resources.getQuantityString( + R.plurals.pluto_network___query_params_value_text, + url.querySize, + url.querySize + ) + ) + ) + append(tapIndicatorText(context)) + } else { + append(fontColor("--", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + }, + showClickIndicator = url.areQueryParamsAvailable, + onClick = if (url.areQueryParamsAvailable) { + { onClick.invoke() } + } else { + null + } + ) +} + +private val Url.querySize: Int + get() = parameters.names().size + +private val Url.areQueryParamsAvailable: Boolean + get() = querySize > 0 diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/CustomTraceInfoFragment.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/CustomTraceInfoFragment.kt new file mode 100644 index 000000000..698aea864 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/CustomTraceInfoFragment.kt @@ -0,0 +1,16 @@ +package com.pluto.plugins.network.internal.interceptor.ui.components + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugins.network.R + +internal class CustomTraceInfoFragment : BottomSheetDialogFragment() { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_network___dialog_custom_traces_info, container, false) + + override fun getTheme(): Int = R.style.PlutoNetworkBottomSheetDialog +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/OverviewStub.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/OverviewStub.kt new file mode 100644 index 000000000..f729d0f88 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/OverviewStub.kt @@ -0,0 +1,125 @@ +package com.pluto.plugins.network.internal.interceptor.ui.components + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkStubDetailsOverviewBinding +import com.pluto.plugins.network.internal.ApiCallData +import com.pluto.utilities.extensions.asFormattedDate +import com.pluto.utilities.extensions.color +import com.pluto.utilities.spannable.createSpan +import com.pluto.utilities.views.keyvalue.KeyValuePairData + +internal class OverviewStub : ConstraintLayout { + + private val binding = PlutoNetworkStubDetailsOverviewBinding.inflate(LayoutInflater.from(context), this, true) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + fun set(api: ApiCallData) { + setupOverview(api, waitingText(context)) + } + + private fun setupOverview(api: ApiCallData, waitingText: CharSequence) { + binding.table.set( + title = context.getString(R.string.pluto_network___tab_overview), + keyValuePairs = arrayListOf().apply { + add( + KeyValuePairData( + key = context.getString(R.string.pluto_network___ssl_label), + value = context.createSpan { append(bold(api.request.url.startsWith("https").toString())) } + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_network___protocol_label), + value = generateProtocol(api) ?: waitingText + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_network___request_time_label), + value = api.request.sentTimestamp.asFormattedDate(DATE_FORMAT) + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_network___response_time_label), + value = (api.exception?.timeStamp ?: api.response?.receiveTimestamp)?.asFormattedDate( + DATE_FORMAT + ) ?: waitingText + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_network___delay_label), + value = api.responseTime?.let { "${it - api.request.sentTimestamp} ms" } ?: waitingText + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_network___interceptor_type_label), + value = context.createSpan { append(semiBold(api.interceptorOption.name)) } + ) + ) + if (api.request.graphqlData != null) { + add( + KeyValuePairData( + key = context.getString(R.string.pluto_network___method_label), + value = api.request.method + ) + ) + add( + KeyValuePairData( + key = context.getString(R.string.pluto_network___url_label), + value = api.request.url + ) + ) + } + } + ) + } + + private fun generateProtocol(api: ApiCallData): CharSequence? { + return api.exception?.let { + context.createSpan { + append( + fontColor( + context.getString(R.string.pluto_network___na), + context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + } + } ?: run { + api.response?.protocol?.let { + context.createSpan { + if (it.isBlank()) { + append( + fontColor( + context.getString(R.string.pluto_network___na), + context.color(com.pluto.plugin.R.color.pluto___text_dark_40) + ) + ) + } else { + append( + semiBold( + fontColor( + it, + context.color(com.pluto.plugin.R.color.pluto___text_dark_80) + ) + ) + ) + } + } + } + } + } + + companion object { + private const val DATE_FORMAT = "MMM dd, yyyy, HH:mm:ss.SSS" + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/RequestStub.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/RequestStub.kt new file mode 100644 index 000000000..407970876 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/RequestStub.kt @@ -0,0 +1,45 @@ +package com.pluto.plugins.network.internal.interceptor.ui.components + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkStubDetailsRequestBinding +import com.pluto.plugins.network.intercept.NetworkData.Request +import com.pluto.plugins.network.internal.interceptor.ui.DetailsFragment.Companion.ACTION_OPEN_REQ_BODY +import com.pluto.plugins.network.internal.interceptor.ui.DetailsFragment.Companion.ACTION_OPEN_REQ_HEADERS +import com.pluto.plugins.network.internal.interceptor.ui.DetailsFragment.Companion.ACTION_OPEN_REQ_PARAMS +import com.pluto.utilities.views.keyvalue.KeyValuePairData + +internal class RequestStub : ConstraintLayout { + + private val binding = PlutoNetworkStubDetailsRequestBinding.inflate(LayoutInflater.from(context), this, true) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + fun set(request: Request, onAction: (String) -> Unit) { + binding.table.set( + title = context.getString(R.string.pluto_network___tab_request), + keyValuePairs = arrayListOf().apply { + add( + getHeadersData(context, request.headers) { + onAction.invoke(ACTION_OPEN_REQ_HEADERS) + } + ) + add( + getQueryParamsData(context, request.url) { + onAction.invoke(ACTION_OPEN_REQ_PARAMS) + } + ) + add( + getBodyData(context, request.body) { + onAction.invoke(ACTION_OPEN_REQ_BODY) + } + ) + } + ) + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/ResponseStub.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/ResponseStub.kt new file mode 100644 index 000000000..ffb0052d3 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/components/ResponseStub.kt @@ -0,0 +1,64 @@ +package com.pluto.plugins.network.internal.interceptor.ui.components + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View.VISIBLE +import androidx.constraintlayout.widget.ConstraintLayout +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkStubDetailsResponseBinding +import com.pluto.plugins.network.intercept.NetworkData.Response +import com.pluto.plugins.network.internal.interceptor.logic.ExceptionData +import com.pluto.plugins.network.internal.interceptor.ui.DetailsFragment.Companion.ACTION_OPEN_RES_BODY +import com.pluto.plugins.network.internal.interceptor.ui.DetailsFragment.Companion.ACTION_OPEN_RES_HEADERS +import com.pluto.utilities.extensions.color +import com.pluto.utilities.spannable.setSpan +import com.pluto.utilities.views.keyvalue.KeyValuePairData + +internal class ResponseStub : ConstraintLayout { + + private val binding = PlutoNetworkStubDetailsResponseBinding.inflate(LayoutInflater.from(context), this, true) + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + fun set(response: Response?, exception: ExceptionData?, onAction: (String) -> Unit) { + binding.loaderGroup.visibility = GONE + binding.response.visibility = GONE + binding.exceptionGroup.visibility = GONE + + exception?.setup(context, binding) ?: run { + response?.setup(context, binding, onAction) ?: run { + binding.loaderGroup.visibility = VISIBLE + } + } + } +} + +private fun Response.setup(context: Context, binding: PlutoNetworkStubDetailsResponseBinding, onAction: (String) -> Unit) { + binding.response.visibility = VISIBLE + binding.response.set( + title = context.getString(R.string.pluto_network___tab_response), + keyValuePairs = arrayListOf().apply { + add( + getHeadersData(context, headers) { + onAction.invoke(ACTION_OPEN_RES_HEADERS) + } + ) + add( + getBodyData(context, body) { + onAction.invoke(ACTION_OPEN_RES_BODY) + } + ) + } + ) +} + +private fun ExceptionData.setup(context: Context, binding: PlutoNetworkStubDetailsResponseBinding) { + binding.exceptionGroup.visibility = VISIBLE + binding.exceptionDetails.setSpan { + append(semiBold(fontColor("${name}\n", context.color(com.pluto.plugin.R.color.pluto___text_dark_80)))) + message?.let { append(it) } + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/list/ApiItemHolder.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/list/ApiItemHolder.kt new file mode 100644 index 000000000..f2ee622e4 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/list/ApiItemHolder.kt @@ -0,0 +1,115 @@ +package com.pluto.plugins.network.internal.interceptor.ui.list + +import android.view.View.GONE +import android.view.View.INVISIBLE +import android.view.View.VISIBLE +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkItemNetworkBinding +import com.pluto.plugins.network.intercept.NetworkData.Response +import com.pluto.plugins.network.internal.ApiCallData +import com.pluto.plugins.network.internal.interceptor.logic.RESPONSE_ERROR_STATUS_RANGE +import com.pluto.utilities.extensions.asTimeElapsed +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import io.ktor.http.Url + +internal class ApiItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto_network___item_network), actionListener) { + + private val binding = PlutoNetworkItemNetworkBinding.bind(itemView) + private val host = binding.host + private val url = binding.url + private val progress = binding.progress + private val status = binding.status + private val error = binding.error + private val timeElapsed = binding.timeElapsed + private val proxyIndicator = binding.proxyIndicator + private val graphqlIcon = binding.graphqlIcon + + override fun onBind(item: ListItem) { + if (item is ApiCallData) { + host.text = Url(item.request.url).host + timeElapsed.text = item.request.sentTimestamp.asTimeElapsed() + binding.root.setBackgroundColor(context.color(com.pluto.plugin.R.color.pluto___transparent)) + + val method = (item.request.graphqlData?.queryType ?: item.request.method).uppercase() + val urlOrQuery = item.request.graphqlData?.queryName ?: Url(item.request.url).encodedPath + graphqlIcon.isVisible = item.request.graphqlData != null + + url.setSpan { + append(fontColor(method, context.color(com.pluto.plugin.R.color.pluto___text_dark_60))) + append(" $urlOrQuery") + } + progress.visibility = VISIBLE + status.visibility = INVISIBLE + error.visibility = INVISIBLE + proxyIndicator.visibility = GONE + + item.exception?.let { + handleExceptionUI() + } + + item.mock?.let { + proxyIndicator.visibility = VISIBLE + } + + item.response?.let { + handleResponseUI(it) + } + binding.root.setOnDebounceClickListener(DEBOUNCE_DELAY) { + onAction("click") + } + } + } + + private fun handleResponseUI(it: Response) { + error.visibility = INVISIBLE + progress.visibility = INVISIBLE + status.visibility = VISIBLE + status.text = it.status.code.toString() + status.setTextColor( + context.color( + if (it.isSuccessful) { + com.pluto.plugin.R.color.pluto___dull_green + } else { + if (it.status.code in RESPONSE_ERROR_STATUS_RANGE) { + com.pluto.plugin.R.color.pluto___orange + } else { + com.pluto.plugin.R.color.pluto___red + } + } + ) + ) + binding.root.setBackgroundColor( + context.color( + if (it.isSuccessful) { + com.pluto.plugin.R.color.pluto___dull_green_05 + } else { + if (it.status.code in RESPONSE_ERROR_STATUS_RANGE) { + com.pluto.plugin.R.color.pluto___orange_05 + } else { + com.pluto.plugin.R.color.pluto___red_05 + } + } + ) + ) + } + + private fun handleExceptionUI() { + error.visibility = VISIBLE + progress.visibility = INVISIBLE + status.visibility = INVISIBLE + binding.root.setBackgroundColor(context.color(com.pluto.plugin.R.color.pluto___red_05)) + } + + private companion object { + const val DEBOUNCE_DELAY = 1_000L + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/list/NetworkAdapter.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/list/NetworkAdapter.kt new file mode 100644 index 000000000..eb7df2592 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/list/NetworkAdapter.kt @@ -0,0 +1,27 @@ +package com.pluto.plugins.network.internal.interceptor.ui.list + +import android.view.ViewGroup +import com.pluto.plugins.network.internal.ApiCallData +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class NetworkAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is ApiCallData -> ITEM_TYPE_API + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_API -> ApiItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_API = 1000 + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/MockSettingsRepo.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/MockSettingsRepo.kt new file mode 100644 index 000000000..c2ce29c50 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/MockSettingsRepo.kt @@ -0,0 +1,76 @@ +package com.pluto.plugins.network.internal.mock.logic + +import android.content.Context +import com.pluto.plugins.network.internal.database.DatabaseManager +import com.pluto.plugins.network.internal.interceptor.logic.pruneQueryParams +import com.pluto.plugins.network.internal.mock.logic.dao.MockData +import com.pluto.plugins.network.internal.mock.logic.dao.MockSettingsDao +import com.pluto.plugins.network.internal.mock.logic.dao.MockSettingsEntity +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +internal object MockSettingsRepo { + private var mockSettingsDao: MockSettingsDao? = null + private var mockSettingsMap = linkedSetOf() + + fun init(context: Context) { + mockSettingsDao = DatabaseManager(context).db.mockSettingsDao() + GlobalScope.launch { + populateMap() + } + } + + private suspend fun populateMap() { + mockSettingsDao?.fetchList()?.let { + mockSettingsMap.clear() + mockSettingsMap.addAll(it) + } + } + + fun get(url: String, method: String): String? { + val mockData = + mockSettingsMap.singleOrNull { p -> + p.requestUrl == url.pruneQueryParams() && p.requestMethod.lowercase() == method.lowercase() + }?.mockData + return mockData?.url // todo url before returning, like wildcards, response_status & delay + } + + fun fetch(url: String, method: String): MockSettingsEntity? { + return mockSettingsMap.singleOrNull { p -> p.requestUrl == url && p.requestMethod.lowercase() == method.lowercase() } + } + + fun fetchList(search: String = ""): List { + return if (search.trim().isEmpty()) { + mockSettingsMap.toList() + } else { + mockSettingsMap.filter { p -> p.requestUrl.contains(search.trim()) } + } + } + + suspend fun update(requestUrl: String, requestMethod: String, mockData: MockData) { + val data = MockSettingsEntity( + requestUrl = requestUrl, + requestMethod = requestMethod, + mockData = mockData, + timestamp = System.currentTimeMillis() + ) + mockSettingsDao?.let { + it.save(data) + populateMap() + } + } + + suspend fun delete(url: String) { + mockSettingsDao?.let { + it.delete(url) + populateMap() + } + } + + suspend fun deleteAll() { + mockSettingsDao?.let { + it.deleteAll() + populateMap() + } + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/dao/MockSettingsConverters.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/dao/MockSettingsConverters.kt new file mode 100644 index 000000000..a10736d33 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/dao/MockSettingsConverters.kt @@ -0,0 +1,22 @@ +package com.pluto.plugins.network.internal.mock.logic.dao + +import androidx.room.TypeConverter +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi + +internal class MockSettingsConverters { + + private var moshi = Moshi.Builder().build() + private var moshiAdapter: JsonAdapter = moshi.adapter(MockData::class.java) + + @TypeConverter + fun stringToMockData(data: String?): MockData? { + data?.let { return moshiAdapter.fromJson(data) } + return null + } + + @TypeConverter + fun mockDataToString(data: MockData): String? { + return moshiAdapter.toJson(data) + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/dao/MockSettingsDao.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/dao/MockSettingsDao.kt new file mode 100644 index 000000000..868431bc7 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/dao/MockSettingsDao.kt @@ -0,0 +1,25 @@ +package com.pluto.plugins.network.internal.mock.logic.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +internal interface MockSettingsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(entity: MockSettingsEntity) + + @Query("SELECT * FROM network_mock where request_url is :requestUrl") + suspend fun fetch(requestUrl: String): MockSettingsEntity? + + @Query("SELECT * FROM network_mock where request_url like '%' || :search || '%' order by timestamp DESC") + suspend fun fetchList(search: String = ""): List? + + @Query("DELETE FROM network_mock where request_url is :requestUrl") + suspend fun delete(requestUrl: String) + + @Query("DELETE FROM network_mock") + suspend fun deleteAll() +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/dao/MockSettingsEntity.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/dao/MockSettingsEntity.kt new file mode 100644 index 000000000..6a995fe64 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/logic/dao/MockSettingsEntity.kt @@ -0,0 +1,39 @@ +package com.pluto.plugins.network.internal.mock.logic.dao + +import androidx.annotation.Keep +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.pluto.utilities.list.ListItem +import com.squareup.moshi.JsonClass + +@Keep +@TypeConverters(MockSettingsConverters::class) +@Entity(tableName = "network_mock", indices = [Index(value = ["request_url"], unique = true)]) +internal data class MockSettingsEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Int? = null, + @ColumnInfo(name = "timestamp") + val timestamp: Long, + @ColumnInfo(name = "request_url") + val requestUrl: String, + @ColumnInfo(name = "request_method") + val requestMethod: String, + @ColumnInfo(name = "mock_data") + val mockData: MockData +) : ListItem() { + override fun isSame(other: Any): Boolean { + return other is MockSettingsEntity && other.requestUrl == requestUrl + } +} + +@Keep +@JsonClass(generateAdapter = true) +internal data class MockData( + val url: String, + val statusCode: Int? = null, + val delay: Int? = null +) diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/MockSettingsFragment.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/MockSettingsFragment.kt new file mode 100644 index 000000000..03f8dcf59 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/MockSettingsFragment.kt @@ -0,0 +1,161 @@ +package com.pluto.plugins.network.internal.mock.ui + +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.annotation.Keep +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkFragmentMockSettingsBinding +import com.pluto.plugins.network.internal.customTab +import com.pluto.plugins.network.internal.interceptor.logic.pruneQueryParams +import com.pluto.plugins.network.internal.mock.logic.dao.MockData +import com.pluto.plugins.network.internal.mock.logic.dao.MockSettingsEntity +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.delayedLaunchWhenResumed +import com.pluto.utilities.extensions.fadeInAndOut +import com.pluto.utilities.extensions.hideKeyboard +import com.pluto.utilities.extensions.onBackPressed +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +internal class MockSettingsFragment : Fragment(R.layout.pluto_network___fragment_mock_settings) { + + private val binding by viewBinding(PlutoNetworkFragmentMockSettingsBinding::bind) + private val viewModel: MockSettingsViewModel by activityViewModels() + private var requestConfig: RequestConfig? = null + private var isCustomTabOpen = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requestConfig = convertArguments(arguments) + onBackPressed { findNavController().navigateUp() } + + viewModel.currentMock.removeObservers(viewLifecycleOwner) + viewModel.currentMock.observe(viewLifecycleOwner, mockObserver) + + viewModel.mockletsUrlEvent.removeObservers(viewLifecycleOwner) + viewModel.mockletsUrlEvent.observe(viewLifecycleOwner, mockletsUrlEventObserver) + + viewModel.event.removeObservers(viewLifecycleOwner) + viewModel.event.observe(viewLifecycleOwner, eventsObserver) + + requestConfig?.let { viewModel.fetch(it.url, it.method) } + + binding.back.setOnDebounceClickListener { + requireActivity().hideKeyboard() + findNavController().navigateUp() + } + binding.save.setOnDebounceClickListener { + requestConfig?.let { + viewModel.update( + binding.endPoint.text.toString(), + it.method, + MockData(url = binding.proxyUrl.text.toString()) + ) + } + requireActivity().hideKeyboard() + } + binding.delete.setOnDebounceClickListener { + requireActivity().hideKeyboard() + requestConfig?.url?.let { viewModel.delete(it) } + } + binding.method.text = requestConfig?.method?.uppercase() + binding.accessMocklets.setOnDebounceClickListener { + val uri = Uri.parse(MOCKLETS_URL) + .buildUpon() + .appendQueryParameter(METHOD_PARAM, requestConfig?.method) + .build() + requireActivity().customTab(uri) + isCustomTabOpen = true + } + } + + private val eventsObserver = Observer> { + requireContext().toast(it.second) + if (it.first) { + requireActivity().onBackPressed() + } + } + + private fun setupUpdateUI(entity: MockSettingsEntity) { + binding.endPoint.isEnabled = false + binding.delete.visibility = View.VISIBLE + binding.note.visibility = View.VISIBLE + context?.let { binding.endPoint.setTextColor(it.color(com.pluto.plugin.R.color.pluto___text_dark_40)) } + binding.endPoint.setText(entity.requestUrl) + binding.proxyUrl.setText(entity.mockData.url) + } + + private val mockObserver = Observer { + binding.endPoint.setText(requestConfig?.url?.pruneQueryParams()) + binding.delete.visibility = View.GONE +// binding.divider.visibility = View.GONE + it?.let { + setupUpdateUI(it) + } + binding.proxyUrl.setSelection(binding.proxyUrl.text.toString().length) + } + + private val mockletsUrlEventObserver = Observer { + binding.proxyUrl.requestFocus() + viewLifecycleOwner.lifecycleScope.delayedLaunchWhenResumed(CLIPBOARD_PROCESS_DELAY) { + context?.clipboardData()?.let { text -> + if (text != binding.proxyUrl.text.toString() && isSelectionValid(text)) { + binding.proxyUrl.setText(text) + binding.mockletsSuccess.fadeInAndOut(viewLifecycleOwner.lifecycleScope) + } + } + binding.proxyUrl.clearFocus() + } + } + + override fun onResume() { + super.onResume() + if (isCustomTabOpen) { + isCustomTabOpen = false + viewModel.onInAppBrowserClose() + } + } + + private fun Context.clipboardData(): String? { + val clipBoardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + return clipBoardManager.primaryClip?.getItemAt(0)?.text?.toString() + } + + private fun isSelectionValid(it: String): Boolean = + it.startsWith(MOCKLETS_API_PREFIX) && binding.proxyUrl.text.toString() != it + + companion object { + const val IN_APP_BROWSER_RESULT_CODE = 10_001 + const val MOCKLETS_URL = "https://connect.mocklets.com?ref=pluto" + const val MOCKLETS_API_PREFIX = "https://api.mocklets.com/" + const val ARG_URL = "url" + const val ARG_METHOD = "method" + const val METHOD_PARAM = "method" + const val CLIPBOARD_PROCESS_DELAY = 50L + } + + private fun convertArguments(arguments: Bundle?): RequestConfig? { + arguments?.let { + if (it.getString(ARG_URL) != null && it.getString(ARG_METHOD) != null) { + return RequestConfig(it.getString(ARG_URL), (it.getString(ARG_METHOD) ?: "any").lowercase()) + } + return null + } + return null + } +} + +@Keep +internal data class RequestConfig( + val url: String?, + val method: String +) diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/MockSettingsListFragment.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/MockSettingsListFragment.kt new file mode 100644 index 000000000..53f89753b --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/MockSettingsListFragment.kt @@ -0,0 +1,111 @@ +package com.pluto.plugins.network.internal.mock.ui + +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkFragmentMockSettingsListBinding +import com.pluto.plugins.network.internal.mock.logic.dao.MockSettingsEntity +import com.pluto.plugins.network.internal.mock.ui.list.MockSettingsItemAdapter +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.hideKeyboard +import com.pluto.utilities.extensions.onBackPressed +import com.pluto.utilities.extensions.showKeyboard +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +internal class MockSettingsListFragment : Fragment(R.layout.pluto_network___fragment_mock_settings_list) { + + private val binding by viewBinding(PlutoNetworkFragmentMockSettingsListBinding::bind) + private val viewModel: MockSettingsViewModel by activityViewModels() + private val mockSettingsAdapter: BaseAdapter by autoClearInitializer { + MockSettingsItemAdapter(onActionListener) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBackPressed { handleBackPress() } + binding.apiList.apply { + adapter = mockSettingsAdapter + addItemDecoration(CustomItemDecorator(context, DECORATOR_DIVIDER_PADDING)) + } + binding.close.setOnDebounceClickListener { + handleBackPress() + } + binding.search.setOnDebounceClickListener { + binding.searchView.visibility = View.VISIBLE + binding.searchView.requestFocus() + } + binding.closeSearch.setOnDebounceClickListener { + exitSearch() + } + binding.clearSearch.setOnDebounceClickListener { + binding.editSearch.text = null + } + binding.editSearch.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + v.showKeyboard() + } else { + v.hideKeyboard() + } + } + binding.editSearch.doOnTextChanged { text, _, _, _ -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + text?.toString()?.let { + viewModel.fetchList(it) + } + } + } + + viewModel.mockList.removeObserver(networkProxyObserver) + viewModel.mockList.observe(viewLifecycleOwner, networkProxyObserver) + viewModel.fetchList() + } + + private val networkProxyObserver = Observer> { + mockSettingsAdapter.list = it + binding.noItemText.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE + } + + private fun exitSearch() { + binding.editSearch.text = null + binding.searchView.visibility = View.GONE + binding.editSearch.clearFocus() + } + + private fun handleBackPress() { + if (binding.searchView.isVisible) { + exitSearch() + } else { + findNavController().navigateUp() + } + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + when (data) { + is MockSettingsEntity -> { + val bundle = bundleOf("url" to data.requestUrl, "method" to data.requestMethod) + findNavController().navigate(R.id.openMockSettingsEdit, bundle) + } + } + } + } + + private companion object { + val DECORATOR_DIVIDER_PADDING = 16f.dp.toInt() + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/MockSettingsViewModel.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/MockSettingsViewModel.kt new file mode 100644 index 000000000..42244c07b --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/MockSettingsViewModel.kt @@ -0,0 +1,77 @@ +package com.pluto.plugins.network.internal.mock.ui + +import android.app.Application +import android.webkit.URLUtil +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.pluto.plugins.network.internal.interceptor.logic.pruneQueryParams +import com.pluto.plugins.network.internal.mock.logic.MockSettingsRepo +import com.pluto.plugins.network.internal.mock.logic.dao.MockData +import com.pluto.plugins.network.internal.mock.logic.dao.MockSettingsEntity +import com.pluto.utilities.SingleLiveEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal class MockSettingsViewModel(application: Application) : AndroidViewModel(application) { + + val mockList: LiveData> + get() = _mockList + private val _mockList = MutableLiveData>() + + val currentMock: SingleLiveEvent + get() = _currentMock + private val _currentMock = SingleLiveEvent() + + val event: SingleLiveEvent> + get() = _event + private val _event = SingleLiveEvent>() + + val mockletsUrlEvent: SingleLiveEvent + get() = _mockletsUrlEvent + private val _mockletsUrlEvent = SingleLiveEvent() + + fun fetchList(search: String = "") { + viewModelScope.launch(Dispatchers.IO) { + val list = MockSettingsRepo.fetchList(search) + _mockList.postValue(list) + } + } + + fun update(requestUrl: String, requestMethod: String, mockData: MockData) { + if (!URLUtil.isHttpUrl(mockData.url) && !URLUtil.isHttpsUrl(mockData.url)) { + _event.postValue(Pair(false, "URL must start with http:// or https://")) + return + } + if (mockData.url.length < URL_MIN_LENGTH) { // length of https:// + _event.postValue(Pair(false, "Malformed URL")) + return + } + viewModelScope.launch(Dispatchers.IO) { + MockSettingsRepo.update(requestUrl.pruneQueryParams(), requestMethod, mockData) + _event.postValue(Pair(true, "Mock Setting updated!")) + } + } + + fun fetch(url: String?, method: String?) { + if (url != null && method != null) { + _currentMock.postValue(MockSettingsRepo.fetch(url.pruneQueryParams(), method)) + } + } + + fun delete(url: String) { + viewModelScope.launch(Dispatchers.IO) { + MockSettingsRepo.delete(url) + _event.postValue(Pair(true, "Mock Setting deleted!")) + } + } + + fun onInAppBrowserClose() { + _mockletsUrlEvent.postValue("mock") + } + + private companion object { + const val URL_MIN_LENGTH = 9 + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/list/MockSettingsItemAdapter.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/list/MockSettingsItemAdapter.kt new file mode 100644 index 000000000..6366817c2 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/list/MockSettingsItemAdapter.kt @@ -0,0 +1,27 @@ +package com.pluto.plugins.network.internal.mock.ui.list + +import android.view.ViewGroup +import com.pluto.plugins.network.internal.mock.logic.dao.MockSettingsEntity +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class MockSettingsItemAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is MockSettingsEntity -> ITEM_TYPE_NETWORK_PROXY + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_NETWORK_PROXY -> MockSettingsItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_NETWORK_PROXY = 1000 + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/list/MockSettingsItemHolder.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/list/MockSettingsItemHolder.kt new file mode 100644 index 000000000..47961a01f --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/mock/ui/list/MockSettingsItemHolder.kt @@ -0,0 +1,29 @@ +package com.pluto.plugins.network.internal.mock.ui.list + +import android.view.ViewGroup +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkItemMockSettingsBinding +import com.pluto.plugins.network.internal.mock.logic.dao.MockSettingsEntity +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class MockSettingsItemHolder( + parent: ViewGroup, + listener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_network___item_mock_settings), listener) { + + private val binding = PlutoNetworkItemMockSettingsBinding.bind(itemView) + private val value = binding.value + + override fun onBind(item: ListItem) { + if (item is MockSettingsEntity) { + value.text = item.requestUrl + binding.root.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareFragment.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareFragment.kt new file mode 100644 index 000000000..b37772c9b --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareFragment.kt @@ -0,0 +1,84 @@ +package com.pluto.plugins.network.internal.share + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Observer +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkFragmentShareBinding +import com.pluto.plugins.network.internal.interceptor.ui.DetailContentData +import com.pluto.plugins.network.internal.interceptor.ui.NetworkViewModel +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.viewBinding + +internal class ShareFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoNetworkFragmentShareBinding::bind) + private val detailsViewModel: NetworkViewModel by activityViewModels() + private val shareViewModel: ShareOptionsViewModel by viewModels() + private val optionAdapter: BaseAdapter by autoClearInitializer { ShareOptionsAdapter(onActionListener) } + private val contentSharer by lazyContentSharer() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_network___fragment_share, container, false) + + override fun getTheme(): Int = R.style.PlutoNetworkBottomSheetDialog + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.shareOptionsList.apply { + adapter = optionAdapter + addItemDecoration(CustomItemDecorator(requireContext())) + } + + detailsViewModel.detailContentLiveData.removeObserver(detailsObserver) + detailsViewModel.detailContentLiveData.observe(viewLifecycleOwner, detailsObserver) + + shareViewModel.shareOptions.removeObserver(shareOptionsObserver) + shareViewModel.shareOptions.observe(viewLifecycleOwner, shareOptionsObserver) + } + + private val detailsObserver = Observer { + shareViewModel.generate(it.api) + } + + private val shareOptionsObserver = Observer> { + optionAdapter.list = it + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is ShareOptionType) { + detailsViewModel.detailContentLiveData.value?.let { + val shareContent: String? = when (data) { + is ShareOptionType.All -> it.api.toShareText() + is ShareOptionType.CURL -> it.api.curl + is ShareOptionType.Header -> null + is ShareOptionType.Request -> it.api.request.toShareText() + is ShareOptionType.Response -> it.api.responseToText() + } + shareContent?.let { content -> + contentSharer.share( + Shareable( + title = "Share Network Call details", + content = content, + fileName = "Network Call details from Pluto" + ) + ) + } + } + } + } + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareHelper.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareHelper.kt new file mode 100644 index 000000000..9b9caadc0 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareHelper.kt @@ -0,0 +1,107 @@ +package com.pluto.plugins.network.internal.share + +import com.pluto.plugins.network.intercept.NetworkData.Request +import com.pluto.plugins.network.internal.ApiCallData +import com.pluto.plugins.network.internal.interceptor.logic.formatSizeAsBytes +import com.pluto.utilities.extensions.asFormattedDate + +@Suppress("StringLiteralDuplication") +internal fun ApiCallData.toShareText(): String { + val text = StringBuilder() + text.append("${request.method.uppercase()}, ${request.url} ") + response?.let { response -> + text.append("\n\nStatus : ${response.status.code} (${response.status.message})") + } + text.append( + "\nRequested at : ${request.sentTimestamp.asFormattedDate("MMM dd, yyyy, HH:mm:ss.SSS")}" + ) + response?.let { + text.append( + "\nReceived at : ${it.receiveTimestamp.asFormattedDate("MMM dd, yyyy, HH:mm:ss.SSS")}" + ) + text.append("\nDelay : ${it.receiveTimestamp - it.sentTimestamp} ms") + text.append("\nProtocol : ${it.protocol}") + } + text.append("\n\n==================\n\n") + text.append("REQUEST") + text.append("\n\n*** Headers (${request.headers.size} items) *** \n${request.headers.toShareText()}") + text.append("\n*** Body (${formatSizeAsBytes(request.body?.sizeInBytes ?: 0L)}) ***\n${request.body?.body}") + + response?.let { response -> + text.append("\n\n==================\n\n") + text.append("RESPONSE") + text.append("\n\n*** Headers (${response.headers.size} items) *** \n${response.headers.toShareText()}") + text.append("\n*** Body (${formatSizeAsBytes(response.body?.sizeInBytes ?: 0L)}) *** \n${response.body?.body}") + } + exception?.let { exception -> + text.append("\n\n==================\n\n") + text.append("RESPONSE\n") + text.append("\n${exception.name}: ${exception.message}\n") + exception.stackTrace.take(STACK_TRACE_LENGTH).forEach { + text.append("\t at $it\n") + } + if (exception.stackTrace.size - STACK_TRACE_LENGTH > 0) { + text.append("\t + ${exception.stackTrace.size - STACK_TRACE_LENGTH} more lines") + } + } + return text.toString() +} + +internal fun Request.toShareText(): String { + val text = StringBuilder() + text.append("${method.uppercase()}, $url") + text.append("\n\n*** Headers (${headers.size} items) *** \n${headers.toShareText()}") + text.append("\n*** Body (${formatSizeAsBytes(body?.sizeInBytes ?: 0L)}) ***\n${body?.body}") + + return text.toString() +} + +internal fun ApiCallData.responseToText(): String { + val text = StringBuilder() + response?.let { response -> + text.append("Status : ${response.status.code} (${response.status.message})\n") + text.append("*** Headers (${response.headers.size} items) *** \n${response.headers.toShareText()}") + text.append("\n*** Body (${formatSizeAsBytes(response.body?.sizeInBytes ?: 0L)}) *** \n${response.body?.body}") + } + exception?.let { exception -> + text.append("*** Exception *** \n${exception.name}: ${exception.message}\n") + exception.stackTrace.take(STACK_TRACE_LENGTH).forEach { + text.append("\t at $it\n") + } + if (exception.stackTrace.size - STACK_TRACE_LENGTH > 0) { + text.append("\t + ${exception.stackTrace.size - STACK_TRACE_LENGTH} more lines") + } + } + return text.toString() +} + +private fun Map.toShareText(): String { + val text = StringBuilder() + forEach { entry -> + text.append("${entry.key} : ${entry.value}\n") + } + return text.toString() +} + +internal fun Request.getCurl(): String { + val curlCommandBuilder = StringBuilder("") + curlCommandBuilder.append("cURL") + curlCommandBuilder.append(" -X") + curlCommandBuilder.append(" ${method.uppercase()}") + for (headerName in headers) { + curlCommandBuilder.append(headerPair(headerName.key, headerName.value)) + } + + body?.let { + curlCommandBuilder.append(" -d '${it.body}'") + } + curlCommandBuilder.append(" \"$url\"") + curlCommandBuilder.append(" -L") + return curlCommandBuilder.toString() // beautify(request.url.toString(), curlCommandBuilder.toString()) +} + +private fun headerPair(headerName: String, headerValue: String?): String { + return " -H \"$headerName: $headerValue\"" +} + +private const val STACK_TRACE_LENGTH = 10 diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareOptionsAdapter.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareOptionsAdapter.kt new file mode 100644 index 000000000..ad114674d --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareOptionsAdapter.kt @@ -0,0 +1,37 @@ +package com.pluto.plugins.network.internal.share + +import android.view.ViewGroup +import com.pluto.plugins.network.internal.share.holders.ShareHeadingHolder +import com.pluto.plugins.network.internal.share.holders.ShareOptionHolder +import com.pluto.plugins.network.internal.share.holders.ShareResponseOptionHolder +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class ShareOptionsAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is ShareOptionType.All -> ITEM_TYPE_OPTION + is ShareOptionType.CURL -> ITEM_TYPE_OPTION + is ShareOptionType.Request -> ITEM_TYPE_OPTION + is ShareOptionType.Response -> ITEM_TYPE_OPTION_RESPONSE + is ShareOptionType.Header -> ITEM_TYPE_HEADER + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_OPTION -> ShareOptionHolder(parent, listener) + ITEM_TYPE_OPTION_RESPONSE -> ShareResponseOptionHolder(parent, listener) + ITEM_TYPE_HEADER -> ShareHeadingHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_OPTION = 1000 + const val ITEM_TYPE_OPTION_RESPONSE = 1001 + const val ITEM_TYPE_HEADER = 1002 + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareOptionsViewModel.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareOptionsViewModel.kt new file mode 100644 index 000000000..a087d208a --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/ShareOptionsViewModel.kt @@ -0,0 +1,38 @@ +package com.pluto.plugins.network.internal.share + +import androidx.annotation.DrawableRes +import androidx.annotation.Keep +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.pluto.plugins.network.R +import com.pluto.plugins.network.internal.ApiCallData +import com.pluto.utilities.list.ListItem + +internal class ShareOptionsViewModel : ViewModel() { + + val shareOptions: LiveData> + get() = _shareOptions + private val _shareOptions = MutableLiveData>() + + fun generate(apiData: ApiCallData) { + val list = mutableListOf().apply { + add(ShareOptionType.All) + add(ShareOptionType.CURL) + add(ShareOptionType.Header) + add(ShareOptionType.Request) + add(ShareOptionType.Response(enable = apiData.response != null || apiData.exception != null)) + } + _shareOptions.postValue(list) + } +} + +@Keep +internal sealed class ShareOptionType(@DrawableRes val icon: Int, val title: String, val subtitle: String? = null, val enabled: Boolean = true) : ListItem() { + object All : ShareOptionType(R.drawable.pluto_network___ic_share_all, "Complete API data") + object CURL : ShareOptionType(R.drawable.pluto_network___ic_share_curl, "Request cURL code") + object Request : ShareOptionType(R.drawable.pluto_network___ic_proxy_base_request_dark, "Only Request data") + class Response(enable: Boolean) : + ShareOptionType(R.drawable.pluto_network___ic_proxy_base_response_dark, "Only Response data", "~ Waiting for response", enable) + object Header : ShareOptionType(R.drawable.pluto_network___ic_share_all, "Share in parts") +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/holders/ShareHeadingHolder.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/holders/ShareHeadingHolder.kt new file mode 100644 index 000000000..e86699b44 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/holders/ShareHeadingHolder.kt @@ -0,0 +1,14 @@ +package com.pluto.plugins.network.internal.share.holders + +import android.view.ViewGroup +import com.pluto.plugins.network.R +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class ShareHeadingHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto_network___item_share_option_header), actionListener) { + override fun onBind(item: ListItem) { + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/holders/ShareOptionHolder.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/holders/ShareOptionHolder.kt new file mode 100644 index 000000000..a50222086 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/holders/ShareOptionHolder.kt @@ -0,0 +1,26 @@ +package com.pluto.plugins.network.internal.share.holders + +import android.view.ViewGroup +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkItemShareOptionBinding +import com.pluto.plugins.network.internal.share.ShareOptionType +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class ShareOptionHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto_network___item_share_option), actionListener) { + + private val binding = PlutoNetworkItemShareOptionBinding.bind(itemView) + override fun onBind(item: ListItem) { + if (item is ShareOptionType) { + binding.label.text = item.title + binding.label.setCompoundDrawablesWithIntrinsicBounds(item.icon, 0, 0, 0) + binding.root.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/holders/ShareResponseOptionHolder.kt b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/holders/ShareResponseOptionHolder.kt new file mode 100644 index 000000000..8ba7aa2b2 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/java/com/pluto/plugins/network/internal/share/holders/ShareResponseOptionHolder.kt @@ -0,0 +1,47 @@ +package com.pluto.plugins.network.internal.share.holders + +import android.content.res.ColorStateList +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import com.pluto.plugins.network.R +import com.pluto.plugins.network.databinding.PlutoNetworkItemShareOptionResponseBinding +import com.pluto.plugins.network.internal.share.ShareOptionType +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class ShareResponseOptionHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto_network___item_share_option_response), actionListener) { + + private val binding = PlutoNetworkItemShareOptionResponseBinding.bind(itemView) + override fun onBind(item: ListItem) { + if (item is ShareOptionType) { + binding.label.setCompoundDrawablesWithIntrinsicBounds(item.icon, 0, 0, 0) + if (item.enabled) { + binding.root.setOnDebounceClickListener { + onAction("click") + } + binding.label.setSpan { + append(fontColor(item.title, context.color(com.pluto.plugin.R.color.pluto___text_dark_80))) + } + binding.description.visibility = GONE + binding.waiting.visibility = GONE + binding.root.setBackgroundColor(context.color(com.pluto.plugin.R.color.pluto___transparent)) + } else { + binding.label.setSpan { + append(fontColor(item.title, context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + binding.description.text = item.subtitle + binding.description.visibility = VISIBLE + binding.waiting.visibility = VISIBLE + binding.waiting.indeterminateTintList = ColorStateList.valueOf(context.color(com.pluto.plugin.R.color.pluto___dark_20)) + binding.root.setBackgroundColor(context.color(com.pluto.plugin.R.color.pluto___app_bg)) + } + } + } +} diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_bottom_sheet.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_bottom_sheet.xml new file mode 100644 index 000000000..6f95cbbe7 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_bottom_sheet.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/src/main/res/drawable/pluto___bg_cta_dashed_blue.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_cta_dashed_blue.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___bg_cta_dashed_blue.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_cta_dashed_blue.xml diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_details_tab.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_details_tab.xml new file mode 100644 index 000000000..c34810d37 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_details_tab.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto/src/main/res/drawable/pluto___bg_section_body.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_section_body.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___bg_section_body.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_section_body.xml diff --git a/pluto/src/main/res/drawable/pluto___bg_section_header.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_section_header.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___bg_section_header.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___bg_section_header.xml diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_alert.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_alert.xml new file mode 100644 index 000000000..d4f88d16f --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_alert.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_arrow_back.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_back.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_arrow_back.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_back.xml diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_back_white.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_back_white.xml new file mode 100644 index 000000000..bd6a5a9cb --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_back_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_down.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_down.xml new file mode 100644 index 000000000..57a114b05 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_down.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_up.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_up.xml new file mode 100644 index 000000000..ac40d08dc --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_arrow_up.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_chevron_right.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_chevron_right.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_chevron_right.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_chevron_right.xml diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_clear_all.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_clear_all.xml new file mode 100644 index 000000000..bb96fcb2d --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_clear_all.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_close.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_close.xml new file mode 100644 index 000000000..82eaa9f97 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_close_gray.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_close_gray.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_close_gray.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_close_gray.xml diff --git a/pluto/src/main/res/drawable/pluto___ic_copy.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_copy.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_copy.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_copy.xml diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_custom_trace_info.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_custom_trace_info.xml new file mode 100644 index 000000000..56f36eb77 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_custom_trace_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_delete.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_delete.xml new file mode 100644 index 000000000..af232e55b --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_done.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_done.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_done.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_done.xml diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_error.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_error.xml new file mode 100644 index 000000000..d13af702b --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_error_orange.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_error_orange.xml new file mode 100644 index 000000000..4fa8cd159 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_error_orange.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_error_outline.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_error_outline.xml new file mode 100644 index 000000000..3c03bfdf0 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_error_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_graphql.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_graphql.xml new file mode 100644 index 000000000..e1f692542 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_graphql.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_more.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_more.xml new file mode 100644 index 000000000..e5db6edf0 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_option_clear.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_option_clear.xml new file mode 100644 index 000000000..cf09ba481 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_option_clear.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_proxy_settings_disabled.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_option_mock_settings.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_proxy_settings_disabled.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_option_mock_settings.xml diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_plugin_logo.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_plugin_logo.xml new file mode 100644 index 000000000..137f7a889 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_plugin_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_proxy_base_request_dark.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_base_request_dark.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_proxy_base_request_dark.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_base_request_dark.xml diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_base_response_dark.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_base_response_dark.xml new file mode 100644 index 000000000..e01a8f056 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_base_response_dark.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_proxy_indicator.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_indicator.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_proxy_indicator.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_indicator.xml diff --git a/pluto/src/main/res/drawable/pluto___ic_proxy_settings.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_settings.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_proxy_settings.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_settings.xml diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_settings_disabled.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_settings_disabled.xml new file mode 100644 index 000000000..658cfbbc8 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_settings_disabled.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_proxy_settings_enabled.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_settings_enabled.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_proxy_settings_enabled.xml rename to pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_proxy_settings_enabled.xml diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_search_white.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_search_white.xml new file mode 100644 index 000000000..76946d7b4 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_search_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_share.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_share.xml new file mode 100644 index 000000000..85c8262d6 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_share_all.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_share_all.xml new file mode 100644 index 000000000..c67e82304 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_share_all.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_share_curl.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_share_curl.xml new file mode 100644 index 000000000..3e5dd3713 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_share_curl.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_success.xml b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_success.xml new file mode 100644 index 000000000..80531b098 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/drawable/pluto_network___ic_success.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___dialog_custom_traces_info.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___dialog_custom_traces_info.xml new file mode 100644 index 000000000..84a85187e --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___dialog_custom_traces_info.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_content.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_content.xml new file mode 100644 index 000000000..72b1f8ded --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_content.xml @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_details.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_details.xml new file mode 100644 index 000000000..563b1a4a7 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_details.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_list.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_list.xml new file mode 100644 index 000000000..06931c5bb --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_list.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_mock_settings.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_mock_settings.xml new file mode 100644 index 000000000..d0722cfea --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_mock_settings.xml @@ -0,0 +1,384 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_mock_settings_list.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_mock_settings_list.xml new file mode 100644 index 000000000..50f67e19b --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_mock_settings_list.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_network.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_network.xml new file mode 100644 index 000000000..d55d6cfba --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_network.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_share.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_share.xml new file mode 100644 index 000000000..fa21c4a5a --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___fragment_share.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_mock_settings.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_mock_settings.xml new file mode 100644 index 000000000..c3dd672e9 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_mock_settings.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_network.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_network.xml new file mode 100644 index 000000000..eb626f644 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_network.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_share_option.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_share_option.xml new file mode 100644 index 000000000..a7a4e7061 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_share_option.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_share_option_header.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_share_option_header.xml new file mode 100644 index 000000000..2b008f46b --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_share_option_header.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_share_option_response.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_share_option_response.xml new file mode 100644 index 000000000..566a04404 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___item_share_option_response.xml @@ -0,0 +1,48 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_details_overview.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_details_overview.xml new file mode 100644 index 000000000..7419278c7 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_details_overview.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_details_request.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_details_request.xml new file mode 100644 index 000000000..7419278c7 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_details_request.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_details_response.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_details_response.xml new file mode 100644 index 000000000..67b06a863 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_details_response.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_network_settings.xml b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_network_settings.xml new file mode 100644 index 000000000..dbbc3a848 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/layout/pluto_network___stub_network_settings.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/menu/pluto_network___menu_more_options.xml b/pluto-plugins/plugins/network/lib/src/main/res/menu/pluto_network___menu_more_options.xml new file mode 100644 index 000000000..c60ff4490 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/menu/pluto_network___menu_more_options.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/navigation/pluto_network___navigation.xml b/pluto-plugins/plugins/network/lib/src/main/res/navigation/pluto_network___navigation.xml new file mode 100644 index 000000000..3dd7ab5ca --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/navigation/pluto_network___navigation.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/values/colors.xml b/pluto-plugins/plugins/network/lib/src/main/res/values/colors.xml new file mode 100644 index 000000000..aecf1cee7 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0c192b + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/values/strings.xml b/pluto-plugins/plugins/network/lib/src/main/res/values/strings.xml new file mode 100644 index 000000000..99bdef37a --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/values/strings.xml @@ -0,0 +1,99 @@ + + + Network Calls + No API call detected. + 200 + Search Network Calls + No search result found. + exit network call details + Share + Network + exit network call details search + Search API content + Clear API search + More Details + Request + Response + Error Response + N/A + Network + showing response from + Headers + Query Params + Body + Exception + Request Headers + Request Query Params + Request Body + Response Headers + Response Body + ~ No Headers + In Progress + Error + URL + ~ Waiting for response + Method + Is SSL + Protocol + Requested at + Response at + Delay + Link Mocklets Account + Link + Connect you Mocklets account to manage your network proxy setting efficiently + See All + Network Proxy Settings + Delete All Calls + Request URL + Redirect call to + Save Settings + Choose API from Mocklets + Enter the https request URL + You can either enter the mock API url manually\nor + Exit Network Proxy List + Setup API Proxy for future calls + Update Settings + open shared preferences menu + exit network proxy settings + exit network proxy edit + open network menu + Share Request cURL + cURL code copied! + Clear + Back + No Network Proxy found + New + Changes will be reflected in future API calls + https:// + URL auto filled by Mocklets selection + Search proxy settings + ~ Laa…aarge response body,\nworking hard to process it… + Open Mock Settings + Mock Settings + Search Mock Settings + Search Content + No Mock settings available + Use CTA on Network call details to add Mock settings + Create Mock Settings + Changes will be reflected in future API calls + Share in parts + Request Size + Response Size + Search is disabled due to large content size. + tap for details + ~ Binary Data ~ + Custom + Custom Network trace + This network trace was logged by client app, not PlutoInterceptor + Interceptor type + + 1 param + %d params + + + 1 items + %d items + + Next highlight + Previous highlight + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/main/res/values/styles.xml b/pluto-plugins/plugins/network/lib/src/main/res/values/styles.xml new file mode 100644 index 000000000..5adb79df4 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/main/res/values/styles.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/network/lib/src/test/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/JsonTransformerTest.kt b/pluto-plugins/plugins/network/lib/src/test/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/JsonTransformerTest.kt new file mode 100644 index 000000000..eab50c774 --- /dev/null +++ b/pluto-plugins/plugins/network/lib/src/test/java/com/pluto/plugins/network/internal/interceptor/logic/transformers/JsonTransformerTest.kt @@ -0,0 +1,33 @@ +package com.pluto.plugins.network.internal.interceptor.logic.transformers + +import org.junit.Test + +class JsonTransformerTest { + + @Test + fun test_int_parsing() { + val actualOutPut = + JsonTransformer().beautify("""{"int":10}""".trimIndent()) + + val expectedOutput = """{ + | "int": 10 + |}""".trimMargin() + assert(actualOutPut == expectedOutput) { + "\nExpected output is \n$expectedOutput\nbut actual output is \n$actualOutPut" + } + } + + @Test + fun test_array_parsing() { + val actualOutPut = + JsonTransformer().beautify("""[20,10]""".trimIndent()) + + val expectedOutput = """[ + | 20, + | 10 + |]""".trimMargin() + assert(actualOutPut == expectedOutput) { + "\nExpected output is \n$expectedOutput\nbut actual output is \n$actualOutPut" + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/README.md b/pluto-plugins/plugins/rooms-database/README.md new file mode 100644 index 000000000..5e5029c43 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/README.md @@ -0,0 +1,46 @@ +## Integrate Rooms Database Plugin in your application + +[Rooms DB Demo.webm](https://github.com/user-attachments/assets/6a496856-7db3-43c5-89ec-6e036dd2ee41) + +### Add Gradle Dependencies +Pluto Rooms Database is distributed through [***mavenCentral***](https://central.sonatype.com/artifact/com.androidpluto.plugins/rooms-db). To use it, you need to add the following Gradle dependency to your build.gradle file of you android app module. + +> Note: add the `no-op` variant to isolate the plugin from release builds. +```groovy +dependencies { + debugImplementation "com.androidpluto.plugins:rooms-db:$plutoVersion" + releaseImplementation "com.androidpluto.plugins:rooms-db-no-op:$plutoVersion" +} +``` +
+ +### Install plugin to Pluto + +Now to start using the plugin, add it to Pluto +```kotlin +Pluto.Installer(this) + .addPlugin(PlutoRoomsDatabasePlugin()) + .install() +``` +
+ +### Start watching Rooms Database + +Create intance of DataStore Preferences and start watching in Pluto. +```kotlin +// DB_NAME should be same as database name assigned while creating the database. +PlutoRoomsDBWatcher.watch(DB_NAME, SampleDatabase::class.java) +``` +
+ +πŸŽ‰  You are all done! + +Now re-build and run your app and open Pluto, you will see the Rooms Database plugin installed. + +
+ +### Open Plugin view programmatically +To open Rooms Database plugin screen via code, use this +```kotlin +Pluto.open(PlutoRoomsDatabasePlugin.ID) +``` diff --git a/pluto-plugins/plugins/rooms-database/lib-no-op/.gitignore b/pluto-plugins/plugins/rooms-database/lib-no-op/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib-no-op/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib-no-op/build.gradle.kts b/pluto-plugins/plugins/rooms-database/lib-no-op/build.gradle.kts new file mode 100644 index 000000000..0c18f2505 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib-no-op/build.gradle.kts @@ -0,0 +1,91 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.rooms.db" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "rooms-db-no-op" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Rooms DB Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to manage & alter Rooms database in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { +} diff --git a/pluto-plugins/plugins/rooms-database/lib-no-op/src/main/AndroidManifest.xml b/pluto-plugins/plugins/rooms-database/lib-no-op/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib-no-op/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib-no-op/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDBWatcher.kt b/pluto-plugins/plugins/rooms-database/lib-no-op/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDBWatcher.kt new file mode 100644 index 000000000..714b8079c --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib-no-op/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDBWatcher.kt @@ -0,0 +1,8 @@ +package com.pluto.plugins.rooms.db + +@SuppressWarnings("UnusedPrivateMember", "EmptyFunctionBlock") +object PlutoRoomsDBWatcher { + fun watch(name: String, dbClass: Class<*>) {} + + fun remove(name: String) {} +} diff --git a/pluto-plugins/plugins/rooms-database/lib-no-op/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDatabasePlugin.kt b/pluto-plugins/plugins/rooms-database/lib-no-op/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDatabasePlugin.kt new file mode 100644 index 000000000..71974870f --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib-no-op/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDatabasePlugin.kt @@ -0,0 +1,8 @@ +package com.pluto.plugins.rooms.db + +@SuppressWarnings("UnusedPrivateMember") +class PlutoRoomsDatabasePlugin @JvmOverloads constructor(identifier: String = ID) { + companion object { + const val ID = "rooms-database" + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/.gitignore b/pluto-plugins/plugins/rooms-database/lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/build.gradle.kts b/pluto-plugins/plugins/rooms-database/lib/build.gradle.kts new file mode 100644 index 000000000..284b5b28d --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/build.gradle.kts @@ -0,0 +1,106 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.ksp) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + resourcePrefix = "pluto_rooms___" + namespace = "com.pluto.plugins.rooms.db" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"$verPublish\"") + buildConfigField("long", "VERSION_CODE", "$verCode") + buildConfigField("String", "GIT_SHA", "\"$verGitSHA\"") + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "rooms-db" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Rooms DB Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to manage & alter Rooms database in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(project(":pluto-plugins:base:lib")) + + implementation(libs.androidx.core) + implementation(libs.androidx.swiperefreshlayout) + + implementation(libs.room) + ksp(libs.room.compiler) +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/AndroidManifest.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDBWatcher.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDBWatcher.kt new file mode 100644 index 000000000..0b28dd0cf --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDBWatcher.kt @@ -0,0 +1,30 @@ +package com.pluto.plugins.rooms.db + +import androidx.room.RoomDatabase +import com.pluto.plugins.rooms.db.internal.DatabaseModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +object PlutoRoomsDBWatcher { + internal const val LOG_TAG = "rooms_db" + internal val watchList = MutableStateFlow>(emptySet()) + + fun watch(name: String, dbClass: Class) { + watchList.update { oldSet -> + mutableSetOf().apply { + addAll(oldSet) + add(DatabaseModel(name, dbClass)) + } + } + } + + fun remove(name: String) { + watchList.update { oldSet -> + mutableSetOf().apply { + oldSet.forEach { + if (it.name != name) add(it) + } + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDatabasePlugin.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDatabasePlugin.kt new file mode 100644 index 000000000..080f8fd3e --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/PlutoRoomsDatabasePlugin.kt @@ -0,0 +1,41 @@ +package com.pluto.plugins.rooms.db + +import androidx.fragment.app.Fragment +import com.pluto.plugin.DeveloperDetails +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginConfiguration +import com.pluto.plugins.rooms.db.internal.ui.filter.FilterConfig + +class PlutoRoomsDatabasePlugin() : Plugin(ID) { + + @SuppressWarnings("UnusedPrivateMember") + @Deprecated("Use the default constructor PlutoRoomsDatabasePlugin() instead.") + constructor(identifier: String) : this() + + override fun getConfig(): PluginConfiguration = PluginConfiguration( + name = context.getString(R.string.pluto_rooms___plugin_name), + icon = R.drawable.pluto_rooms___ic_rooms_icon, + version = BuildConfig.VERSION_NAME + ) + + override fun getView(): Fragment = RoomsDBFragment() + + override fun getDeveloperDetails(): DeveloperDetails { + return DeveloperDetails( + website = "https://androidpluto.com", + vcsLink = "https://github.com/androidPluto/pluto", + twitter = "https://twitter.com/android_pluto" + ) + } + + override fun onPluginInstalled() { + } + + override fun onPluginDataCleared() { + FilterConfig.clear() + } + + companion object { + const val ID = "rooms-database" + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/RoomsDBFragment.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/RoomsDBFragment.kt new file mode 100644 index 000000000..b14693622 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/RoomsDBFragment.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.rooms.db + +import androidx.fragment.app.Fragment + +internal class RoomsDBFragment : Fragment(R.layout.pluto_rooms___fragment_rooms_db) diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/Session.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/Session.kt new file mode 100644 index 000000000..b9041cf30 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/Session.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.rooms.db + +internal object Session { + var searchText: String? = null +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ContentViewModel.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ContentViewModel.kt new file mode 100644 index 000000000..b68feb6bb --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ContentViewModel.kt @@ -0,0 +1,329 @@ +package com.pluto.plugins.rooms.db.internal + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.room.RoomDatabase +import com.pluto.plugins.rooms.db.PlutoRoomsDBWatcher.LOG_TAG +import com.pluto.plugins.rooms.db.internal.core.isSystemTable +import com.pluto.plugins.rooms.db.internal.core.query.ExecuteResult +import com.pluto.plugins.rooms.db.internal.core.query.Executor +import com.pluto.plugins.rooms.db.internal.core.query.Query +import com.pluto.plugins.rooms.db.internal.ui.filter.FilterConfig +import com.pluto.utilities.DebugLog +import com.pluto.utilities.SingleLiveEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal class ContentViewModel(application: Application) : AndroidViewModel(application) { + + val tables: LiveData> + get() = _tables + private val _tables = MutableLiveData>() + + val currentTable: LiveData + get() = _currentTable + private val _currentTable = SingleLiveEvent() + + val processedTableContent: LiveData + get() = _tableContent + private val _tableContent = SingleLiveEvent() + + val rowActionEvent: LiveData> + get() = _rowActionEvent + private val _rowActionEvent = SingleLiveEvent>() + + val editEventState: LiveData + get() = _editEventState + private val _editEventState = SingleLiveEvent() + + val queryError: LiveData> + get() = _queryError + private val _queryError = SingleLiveEvent>() + + val editError: LiveData> + get() = _editError + private val _editError = SingleLiveEvent>() + + val rowCounts: LiveData> + get() = _rowCounts + private val _rowCounts = SingleLiveEvent>() + + var filters: List = emptyList() + private set + + var sortBy: Pair? = null + private set + + override fun onCleared() { + super.onCleared() + Executor.destroySession() + _tableContent.value = null + } + + fun initDBSession(context: Context, name: String, dbClass: Class) { + Executor.initSession(context, name, dbClass) + fetchTables() + } + + fun destroyDBSession() { + Executor.destroySession() + } + + @SuppressWarnings("TooGenericExceptionCaught") + private fun fetchTables() { + viewModelScope.launch(Dispatchers.Default) { + val tables = arrayListOf() + Executor.instance.query(Query.Database.GET_TABLE_NAMES).collect { + when (it) { + is ExecuteResult.Success.Query -> { + it.data.second.forEach { list -> + tables.addAll(list) + } + + val processedTableList = arrayListOf() + val processedSystemTableList = arrayListOf() + tables.forEach { table -> + if (isSystemTable(table)) { + processedSystemTableList.add(TableModel(table, true)) + } else { + processedTableList.add(TableModel(table, false)) + } + } + if (processedTableList.size == 1) { + selectTable(processedTableList.first()) + } else { + _currentTable.postValue(null) + } + _tables.postValue(processedTableList.plus(processedSystemTableList)) + } + is ExecuteResult.Failure -> _queryError.postValue(Pair(ERROR_FETCH_TABLES, it.exception)) + else -> DebugLog.e(LOG_TAG, "fetchTables: invalid result") + } + } + } + } + + fun selectTable(table: TableModel) { + if (table.name != _currentTable.value?.name) { + sortBy = null + filters = FilterConfig.get(Executor.instance.databaseName, table.name) + } + _currentTable.postValue(table) + } + + @SuppressWarnings("TooGenericExceptionCaught") + fun fetchData(table: String) { + viewModelScope.launch(Dispatchers.Default) { + Executor.instance.query(Query.Tables.columns(table)).collect { columnResult -> + when (columnResult) { + is ExecuteResult.Success.Query -> { + val columns = columnResult.data.second.map { col -> + ColumnModel( + columnId = col[COLUMN_CID_INDEX].toInt(), + name = col[COLUMN_NAME_INDEX], + type = col[COLUMN_TYPE_INDEX], + isNotNull = col[COLUMN_NOTNULL_INDEX].toInt() > 0, + defaultValue = col[COLUMN_DFLT_VALUE_INDEX], + isPrimaryKey = col[COLUMN_PRIMARY_KEY_INDEX].toInt() > 0 + ) + } + + Executor.instance.query(Query.Tables.values(table, filters, sortBy)).collect { valueResult -> + when (valueResult) { + is ExecuteResult.Success.Query -> { + if (filters.isEmpty()) { + _rowCounts.postValue(Pair(valueResult.data.second.size, valueResult.data.second.size)) + } else { + fetchRowCount(table, valueResult.data.second.size) + } + _tableContent.postValue(ProcessedTableContents(columns, valueResult.data.second)) + } + + is ExecuteResult.Failure -> _queryError.postValue(Pair(ERROR_FETCH_CONTENT, valueResult.exception)) + else -> DebugLog.e(LOG_TAG, "fetch values: invalid result") + } + } + } + + is ExecuteResult.Failure -> _queryError.postValue(Pair(ERROR_FETCH_CONTENT, columnResult.exception)) + else -> DebugLog.e(LOG_TAG, "fetch column: invalid result") + } + } + } + } + + private fun fetchRowCount(table: String, filteredCount: Int) { + viewModelScope.launch(Dispatchers.Default) { + Executor.instance.query(Query.Tables.count(table)).collect { + when (it) { + is ExecuteResult.Success.Query -> _rowCounts.postValue(Pair(filteredCount, it.data.second[0][0].toInt())) + is ExecuteResult.Failure -> _rowCounts.postValue(Pair(filteredCount, null)) + else -> DebugLog.e(LOG_TAG, "fetchRowCount: invalid result") + } + } + } + } + + @SuppressWarnings("TooGenericExceptionCaught") + fun triggerAddRecordEvent(table: String, index: Int, list: List?, isInsertEvent: Boolean) { + viewModelScope.launch(Dispatchers.Default) { + Executor.instance.query(Query.Tables.columns(table)).collect { + when (it) { + is ExecuteResult.Success.Query -> { + val eventData = RowDetailsData( + index = index, + table = table, + columns = it.data.second.map { col -> + ColumnModel( + columnId = col[COLUMN_CID_INDEX].toInt(), + name = col[COLUMN_NAME_INDEX], + type = col[COLUMN_TYPE_INDEX], + isNotNull = col[COLUMN_NOTNULL_INDEX].toInt() > 0, + defaultValue = col[COLUMN_DFLT_VALUE_INDEX], + isPrimaryKey = col[COLUMN_PRIMARY_KEY_INDEX].toInt() > 0 + ) + }, + values = list, + ) + performAction(RowAction.Click(isInsertEvent), eventData) + } + + is ExecuteResult.Failure -> _queryError.postValue(Pair(ERROR_ADD_UPDATE_REQUEST, it.exception)) + else -> DebugLog.e(LOG_TAG, "triggerAddRecordEvent: invalid result") + } + } + } + } + + fun triggerActionsOpenEvent(table: String, index: Int, list: List?) { + viewModelScope.launch(Dispatchers.Default) { + Executor.instance.query(Query.Tables.columns(table)).collect { + when (it) { + is ExecuteResult.Success.Query -> { + val eventData = RowDetailsData( + index = index, + table = table, + columns = it.data.second.map { col -> + ColumnModel( + columnId = col[COLUMN_CID_INDEX].toInt(), + name = col[COLUMN_NAME_INDEX], + type = col[COLUMN_TYPE_INDEX], + isNotNull = col[COLUMN_NOTNULL_INDEX].toInt() > 0, + defaultValue = col[COLUMN_DFLT_VALUE_INDEX], + isPrimaryKey = col[COLUMN_PRIMARY_KEY_INDEX].toInt() > 0 + ) + }, + values = list, + ) + performAction(RowAction.LongClick, eventData) + } + + is ExecuteResult.Failure -> _queryError.postValue(Pair(ERROR_ADD_UPDATE_REQUEST, it.exception)) + else -> DebugLog.e(LOG_TAG, "triggerAddRecordEvent: invalid result") + } + } + } + } + + fun performAction(action: RowAction, data: RowDetailsData) { + _rowActionEvent.postValue(Pair(action, data)) + } + + fun addNewRow(table: String, values: List>) { + viewModelScope.launch(Dispatchers.Default) { + Executor.instance.insert(table, values).collect { + when (it) { + is ExecuteResult.Success.Insert -> { + fetchData(table) + _editEventState.postValue(it) + } + + is ExecuteResult.Failure -> _editError.postValue(Pair(ERROR_ADD_UPDATE, it.exception)) + else -> DebugLog.e(LOG_TAG, "addNewRow: invalid result") + } + } + } + } + + fun updateRow(table: String, newValues: List>, prevValues: ArrayList>) { + viewModelScope.launch(Dispatchers.Default) { + Executor.instance.update(table, newValues, prevValues).collect { + when (it) { + is ExecuteResult.Success.Update -> { + fetchData(table) + _editEventState.postValue(it) + } + + is ExecuteResult.Failure -> _editError.postValue(Pair(ERROR_ADD_UPDATE, it.exception)) + else -> DebugLog.e(LOG_TAG, "updateRow: invalid result") + } + } + } + } + + fun deleteRow(table: String, values: List>) { + viewModelScope.launch(Dispatchers.Default) { + Executor.instance.delete(table, values).collect { + when (it) { + is ExecuteResult.Success.Delete -> { + fetchData(table) + _editEventState.postValue(it) + } + + is ExecuteResult.Failure -> _editError.postValue(Pair(ERROR_ADD_UPDATE, it.exception)) + else -> DebugLog.e(LOG_TAG, "updateRow: invalid result") + } + } + } + } + + fun clearTable(table: String) { + viewModelScope.launch(Dispatchers.Default) { + Executor.instance.query(Query.Tables.clear(table)).collect { + when (it) { + is ExecuteResult.Success.Query -> { + fetchData(table) + _editEventState.postValue(it) + } + + is ExecuteResult.Failure -> _editError.postValue(Pair(ERROR_ADD_UPDATE, it.exception)) + else -> DebugLog.e(LOG_TAG, "clearTable: invalid result") + } + } + } + } + + fun setSortBy(column: ColumnModel, sort: SortBy) { + sortBy = Pair(column.name, sort) + } + + fun clearSortBy() { + sortBy = null + } + + fun updateFilter(filterList: List) { + currentTable.value?.let { + filters = filterList + FilterConfig.set(Executor.instance.databaseName, it.name, filterList) + selectTable(it) + } + } + + companion object { + const val ERROR_FETCH_TABLES = "error_fetch_tables" + const val ERROR_FETCH_CONTENT = "error_fetch_content" + const val ERROR_ADD_UPDATE_REQUEST = "error_add_update_request" + const val ERROR_ADD_UPDATE = "error_add_update" + + private const val COLUMN_CID_INDEX = 0 + private const val COLUMN_NAME_INDEX = 1 + private const val COLUMN_TYPE_INDEX = 2 + private const val COLUMN_NOTNULL_INDEX = 3 + private const val COLUMN_DFLT_VALUE_INDEX = 4 + private const val COLUMN_PRIMARY_KEY_INDEX = 5 + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/DataModels.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/DataModels.kt new file mode 100644 index 000000000..4d4819f25 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/DataModels.kt @@ -0,0 +1,107 @@ +package com.pluto.plugins.rooms.db.internal + +import android.os.Parcelable +import androidx.annotation.DrawableRes +import androidx.annotation.Keep +import androidx.room.RoomDatabase +import com.pluto.plugins.rooms.db.R +import com.pluto.utilities.list.ListItem +import kotlinx.parcelize.Parcelize + +@Keep +internal data class DatabaseModel( + val name: String, + val dbClass: Class +) : ListItem() + +@Keep +@Parcelize +internal data class TableModel( + val name: String, + val isSystemTable: Boolean +) : Parcelable, ListItem() + +/** + * column properties (ordered) + * cid, name, type, notnull, dflt_value, pk + */ +@Keep +@Parcelize +internal data class ColumnModel( + val columnId: Int, + val name: String, + val type: String, + val isNotNull: Boolean, + val defaultValue: String?, + val isPrimaryKey: Boolean +) : Parcelable, ListItem() { + override fun equals(other: Any?): Boolean { + return other is ColumnModel && other.columnId == columnId + } +} + +internal typealias RawTableContents = Pair, List>> + +internal typealias ProcessedTableContents = Pair, List>> + +@Keep +@Parcelize +internal data class RowDetailsData( + val index: Int, + val table: String, + val columns: List, + val values: List? +) : Parcelable + +internal data class FilterModel( + val column: ColumnModel, + val value: String?, + val relation: FilterRelation = FilterRelation.Equals +) : ListItem(), Comparable { + override fun compareTo(other: FilterModel): Int { + return column.name.compareTo(other.column.name) + } + + override fun equals(other: Any?): Boolean { + return other is FilterModel && other.column == column + } + + override fun isSame(other: Any): Boolean { + return other is FilterModel && + other.column == column && + other.value == value && + other.relation.symbol == relation.symbol + } +} + +internal sealed class RowAction { + class Click(val isInsert: Boolean) : RowAction() + object LongClick : RowAction() + object Delete : RowAction() + object Duplicate : RowAction() +} + +internal sealed class FilterRelation(val symbol: String) : ListItem() { + object Equals : FilterRelation("=") + object NotEquals : FilterRelation("!=") + object Like : FilterRelation("%") + object Between : FilterRelation("BTW") + object In : FilterRelation("IN") + object LessThan : FilterRelation("<") + object LessThanOrEquals : FilterRelation("<=") + object GreaterThan : FilterRelation(">") + object GreaterThanOrEquals : FilterRelation(">=") + + override fun equals(other: Any?): Boolean { + return other is FilterRelation && other.symbol == symbol + } + + override fun hashCode(): Int { + return super.hashCode() + } +} + +internal sealed class SortBy(val label: String, @DrawableRes val indicator: Int) { + class Asc(label: String = "ASC", indicator: Int = R.drawable.pluto_rooms___ic_sort_indicator_asc) : SortBy(label, indicator) + class Desc(label: String = "DESC", indicator: Int = R.drawable.pluto_rooms___ic_sort_indicator_desc) : SortBy(label, indicator) +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/RoomsDBViewModel.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/RoomsDBViewModel.kt new file mode 100644 index 000000000..a9745b4ba --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/RoomsDBViewModel.kt @@ -0,0 +1,18 @@ +package com.pluto.plugins.rooms.db.internal + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.pluto.plugins.rooms.db.PlutoRoomsDBWatcher + +internal class RoomsDBViewModel(application: Application) : AndroidViewModel(application) { + + val dbs: LiveData> + get() = _dbs + private val _dbs = MutableLiveData>() + + fun fetch() { + _dbs.postValue(PlutoRoomsDBWatcher.watchList.value.toList()) + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/UIViewModel.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/UIViewModel.kt new file mode 100644 index 000000000..d5c04ca88 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/UIViewModel.kt @@ -0,0 +1,92 @@ +package com.pluto.plugins.rooms.db.internal + +import android.app.Application +import android.content.Context +import android.widget.FrameLayout +import android.widget.HorizontalScrollView +import android.widget.LinearLayout +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.viewModelScope +import com.pluto.plugins.rooms.db.internal.core.widgets.DataEditWidget +import com.pluto.plugins.rooms.db.internal.core.widgets.TableGridView +import com.pluto.utilities.SingleLiveEvent +import com.pluto.utilities.extensions.forEachIndexed +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal class UIViewModel(application: Application) : AndroidViewModel(application) { + + val tableGridView: LiveData + get() = _tableGridView + private val _tableGridView = SingleLiveEvent() + + val rowEditView: LiveData, LinearLayout>> + get() = _rowEditView + private val _rowEditView = SingleLiveEvent, LinearLayout>>() + + @SuppressWarnings("LongParameterList") + fun generateTableGridView( + context: Context, + columns: List, + rows: List>, + sortBy: Pair?, + onRowClick: (Int, List) -> Unit, + onRowLongClick: (Int, List) -> Unit, + onColumnClick: (ColumnModel) -> Unit, + onColumnLongClick: (ColumnModel) -> Unit + ) { + viewModelScope.launch(Dispatchers.Default) { + val hsv = HorizontalScrollView(context) + TableGridView(context).create( + columns, rows, sortBy, + onClick = { index -> // row click + onRowClick.invoke(index, rows[index]) + }, + onLongClick = { index -> + onRowLongClick.invoke(index, rows[index]) + }, + onColumnClick = { column -> // column click + onColumnClick.invoke(column) + }, + onColumnLongClick = { column -> // column long click + onColumnLongClick.invoke(column) + } + ).also { view -> hsv.addView(view) } + _tableGridView.postValue(hsv) + } + } + + fun generateRowEditView(context: Context, dataConfig: RowDetailsData) { + viewModelScope.launch(Dispatchers.Default) { + val etList = mutableListOf() + val mainLayout = LinearLayout(context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + orientation = LinearLayout.VERTICAL + } + + val columns = dataConfig.columns + val rows = dataConfig.values ?: dataConfig.columns.map { col -> + col.defaultValue?.replace("\'", "") + } + Pair(columns, rows).forEachIndexed { _, column, value -> + val valueEditText = DataEditWidget(context) + etList.add(valueEditText) + valueEditText.create(column, value) + val row = LinearLayout(context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + orientation = LinearLayout.VERTICAL + addView(valueEditText) + } + mainLayout.addView(row) + } + _rowEditView.postValue(Pair(etList, mainLayout)) + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/Utils.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/Utils.kt new file mode 100644 index 000000000..7d6eeabde --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/Utils.kt @@ -0,0 +1,11 @@ +package com.pluto.plugins.rooms.db.internal.core + +internal fun isSystemTable(name: String): Boolean { + val systemTables = arrayListOf().apply { + add("android_metadata") + add("room_master_table") + add("sqlite_master") + add("sqlite_sequence") + } + return systemTables.contains(name) +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/query/Executor.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/query/Executor.kt new file mode 100644 index 000000000..26ffbde80 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/query/Executor.kt @@ -0,0 +1,172 @@ +package com.pluto.plugins.rooms.db.internal.core.query + +import android.content.ContentValues +import android.content.Context +import android.database.CursorIndexOutOfBoundsException +import android.database.SQLException +import android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.RawTableContents +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn + +internal class Executor private constructor(private val dbOpenHelper: SupportSQLiteOpenHelper, val databaseName: String) { + + private val database: SupportSQLiteDatabase + get() = dbOpenHelper.writableDatabase + + companion object { + val instance: Executor + get() = returnInstance() + + private fun returnInstance(): Executor { + _instance?.let { + return it + } + throw IllegalStateException("session not initialised") + } + + private var _instance: Executor? = null + + @Synchronized + fun initSession(context: Context, databaseName: String, databaseClass: Class): Executor { + _instance?.let { + throw IllegalStateException("session already initialised") + } ?: run { + val roomDatabase = Room.databaseBuilder(context, databaseClass, databaseName).build() + _instance = Executor(roomDatabase.openHelper, databaseName) + return _instance as Executor + } + } + + fun destroySession() { + _instance?.dbOpenHelper?.close() + _instance = null + } + } + + @SuppressWarnings("TooGenericExceptionCaught", "TooGenericExceptionThrown", "SwallowedException") + internal fun query(query: String) = flow { + try { + val c = database.query(query) + c?.let { + val columnNames = arrayListOf() + for (i in 0 until c.columnCount) columnNames.add(c.getColumnName(i)) + val rows = mutableListOf>() + c.moveToFirst() + do { + val rowValues = arrayListOf() + for (i in 0 until c.columnCount) { + try { + val value = c.getString(i) + rowValues.add(value) + } catch (e: CursorIndexOutOfBoundsException) { + /* handled empty table case */ + } + } + if (rowValues.isNotEmpty()) { + rows.add(rowValues) + } + } while (c.moveToNext()) + c.close() + emit(ExecuteResult.Success.Query(RawTableContents(columnNames, rows))) + } ?: run { + throw Exception() + } + } catch (e: Exception) { + emit(ExecuteResult.Failure(e)) + } + }.flowOn(Dispatchers.IO) + + @SuppressWarnings("TooGenericExceptionCaught") + internal fun insert(table: String, values: List>) = flow { + try { + val contentValue = ContentValues() + values.forEach { + it.second?.let { value -> + contentValue.put(it.first.name, value) + } + } + val id = database.insert(table, CONFLICT_FAIL, contentValue) + emit(ExecuteResult.Success.Insert(id)) + } catch (e: Exception) { + emit(ExecuteResult.Failure(e)) + } + }.flowOn(Dispatchers.IO) + + @SuppressWarnings("TooGenericExceptionCaught") + internal fun update(table: String, newValues: List>, prevValues: ArrayList>) = flow { + try { + val contentValue = ContentValues().apply { + newValues.map { put(it.first.name, it.second) } + } + val whereClause = StringBuilder() + val whereArgs = arrayListOf() + prevValues.filter { it.second != null }.apply { + forEachIndexed { index, pair -> + whereClause.append("${pair.first.name}=?") + if (index < lastIndex) { + whereClause.append(" and ") + } + whereArgs.add(pair.second) + } + } + val numberOfRows = database.update(table, CONFLICT_FAIL, contentValue, whereClause.toString(), whereArgs.toArray()) + emit(ExecuteResult.Success.Update(numberOfRows)) + } catch (e: Exception) { + emit(ExecuteResult.Failure(e)) + } + }.flowOn(Dispatchers.IO) + + @SuppressWarnings("TooGenericExceptionCaught") + internal fun delete(table: String, values: List>) = flow { + try { + val whereClause = StringBuilder() + val whereArgs = arrayListOf() + values.filter { it.second != null }.apply { + forEachIndexed { index, pair -> + whereClause.append("${pair.first.name}=?") + if (index < lastIndex) { + whereClause.append(" and ") + } + whereArgs.add(pair.second) + } + } + val numberOfRows = database.delete(table, whereClause.toString(), whereArgs.toArray()) + emit(ExecuteResult.Success.Delete(numberOfRows)) + } catch (e: Exception) { + emit(ExecuteResult.Failure(e)) + } + }.flowOn(Dispatchers.IO) + + /** + * Executes the given [query]. + * + * @param query SQL query + */ + internal fun execSQL(query: String) = flow { + try { + database.execSQL(query) + emit(ExecuteResult.Success.ExecSQL) + } catch (e: SQLException) { + emit(ExecuteResult.Failure(e)) + } + }.flowOn(Dispatchers.IO) +} + +sealed class ExecuteResult { + sealed class Success : ExecuteResult() { + data class Query(val data: RawTableContents) : Success() + data class Insert(val id: Long) : Success() + data class Update(val numberOfRows: Int) : Success() + data class Delete(val numberOfRows: Int) : Success() + object ExecSQL : Success() + } + + data class Failure(val exception: Exception) : ExecuteResult() +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/query/Query.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/query/Query.kt new file mode 100644 index 000000000..e6e3460b1 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/query/Query.kt @@ -0,0 +1,81 @@ +package com.pluto.plugins.rooms.db.internal.core.query + +import com.pluto.plugins.rooms.db.internal.FilterModel +import com.pluto.plugins.rooms.db.internal.SortBy +import com.pluto.plugins.rooms.db.internal.ui.filter.FilterQueryTransformer +import java.lang.StringBuilder + +internal class Query private constructor() { + + class Database private constructor() { + companion object { + /** + * Query to get all table names in the database. + */ + const val GET_TABLE_NAMES = "SELECT name _id FROM sqlite_master WHERE type ='table'" + } + } + + class Tables private constructor() { + companion object { + /** + * Query to get all column names of the table. + * + * @param table name of the table + * @return query + */ + fun columns(table: String) = "PRAGMA table_info($table)" + + /** + * Query to get all values of the table. + * + * @param table name of the table + * @param filters list of filters + * @param sortBy sorting logic + * @return query + */ + fun values(table: String, filters: List?, sortBy: Pair?): String { + val stringBuilder = StringBuilder() + stringBuilder.append("SELECT * FROM $table") + if (!filters.isNullOrEmpty()) { + stringBuilder.append(" WHERE") + filters.forEachIndexed { index, filter -> + stringBuilder.append(" ${filter.column.name}") + stringBuilder.append(FilterQueryTransformer.transform(filter.value, filter.relation)) + if (index < filters.lastIndex) { + stringBuilder.append(" AND") + } + } + } + sortBy?.let { + stringBuilder.append(" ORDER BY ${sortBy.first} ${sortBy.second.label.uppercase()}") + } + return stringBuilder.toString() + } + + /** + * Query to count the rows in the table. + * + * @param table name of the table + * @return query + */ + fun count(table: String) = "SELECT COUNT(*) FROM $table" + + /** + * Query to drop the table. + * + * @param table name of the table + * @return query + */ + fun dropTable(table: String) = "DROP TABLE $table" + + /** + * Query to clear all values of the table. + * + * @param table name of the table + * @return query + */ + fun clear(table: String) = "DELETE FROM $table" + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/widgets/DataEditWidget.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/widgets/DataEditWidget.kt new file mode 100644 index 000000000..5a973dc4d --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/widgets/DataEditWidget.kt @@ -0,0 +1,84 @@ +package com.pluto.plugins.rooms.db.internal.core.widgets + +import android.content.Context +import android.text.InputType +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.widget.doOnTextChanged +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsDataEditWidgetBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.createSpan + +internal class DataEditWidget(context: Context) : ConstraintLayout(context) { + + private val binding = PlutoRoomsDataEditWidgetBinding.inflate(LayoutInflater.from(context), this, true) + + var input: Pair? = null + + fun get(): Pair { + input?.let { + return Pair(it.first, if (isNull) null else binding.value.text.toString()) + } + throw IllegalStateException("calling get before creating the widget") + } + + private var isNull: Boolean = false + + fun create(column: ColumnModel, value: String?) { + input = Pair(column, value) + binding.column.text = context.createSpan { + if (column.isPrimaryKey) { + append(semiBold(underline(column.name))) + append(fontColor(italic(" (Primary Key)"), context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } else { + append(column.name) + } + + if (!column.isNotNull) { + append(fontColor(italic(" (nullable)"), context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + } + binding.nullCta.visibility = if (column.isNotNull) GONE else VISIBLE + setValue(value) + binding.value.inputType = handleKeypad(column.type) + binding.nullCta.setOnLongClickListener { + context?.toast(context.getString(R.string.pluto_rooms___set_as_null)) + true + } + binding.nullCta.setOnDebounceClickListener { + if (!column.isNotNull) { + setValue(null) + } + } + binding.value.doOnTextChanged { text, _, _, _ -> + if (text?.length ?: 0 > 0) { + isNull = false + binding.value.hint = context?.createSpan { + append("blank") + } + } + } + } + + private fun setValue(value: String?) { + isNull = value == null + binding.value.hint = context?.createSpan { + if (isNull) { + append(italic("null")) + } else { + append("blank") + } + } + binding.value.setText(value) + } + + // todo increase data type coverage + private fun handleKeypad(type: String): Int = when (type) { + "INTEGER" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_NUMBER_FLAG_DECIMAL + else -> InputType.TYPE_CLASS_TEXT + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/widgets/TableGridView.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/widgets/TableGridView.kt new file mode 100644 index 000000000..2b8877fc8 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/core/widgets/TableGridView.kt @@ -0,0 +1,238 @@ +package com.pluto.plugins.rooms.db.internal.core.widgets + +import android.content.Context +import android.graphics.Paint +import android.os.Build +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.widget.TableLayout +import android.widget.TableRow +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.SortBy +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.truncateExcess +import com.pluto.utilities.hapticFeedback +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +/** + * A custom [TableLayout] class having functionality for creating table by using given rows and columns. + * + */ +internal class TableGridView(context: Context) : TableLayout(context) { + + private val tableRowHeight by lazy { + context.resources.getDimension(R.dimen.pluto_rooms___header_height).toInt() + } + + private val tableHeaderBackground by lazy { + ContextCompat.getColor( + context, + com.pluto.plugin.R.color.pluto___dark_80 + ) + } + + private val tableHeaderSortedBackground by lazy { + ContextCompat.getColor( + context, + com.pluto.plugin.R.color.pluto___dark + ) + } + + private val tableRowBackground by lazy { + ContextCompat.getColor( + context, + com.pluto.plugin.R.color.pluto___app_bg + ) + } + + /** + * Creates a [TextView] to display column names in the table. + * + * @param column column data + * @param sortBy sorting logic + * @param onColumnClick function to get called on clicking a column item + * @param onColumnLongClick function to get called on long pressing a column item + * @return RowHeader + */ + private fun rowHeader(column: ColumnModel, sortBy: SortBy?, onColumnClick: (ColumnModel) -> Unit, onColumnLongClick: (ColumnModel) -> Unit) = + TextView(context).apply { + height = tableRowHeight + minWidth = 20f.dp.toInt() + gravity = Gravity.CENTER_VERTICAL + setBackgroundColor(tableHeaderBackground) + setPadding(PADDING_HORIZONTAL, 0, PADDING_HORIZONTAL, 0) + text = column.name.truncateExcess(TEXT_OFFSET) + textSize = TEXT_SIZE_RECORD + setTextColor(ContextCompat.getColor(context, com.pluto.plugin.R.color.pluto___app_bg)) + typeface = ResourcesCompat.getFont( + context, + if (column.isPrimaryKey) { + com.pluto.plugin.R.font.muli_bold + } else { + com.pluto.plugin.R.font.muli_semibold + } + ) + if (column.isPrimaryKey) { + paintFlags = Paint.UNDERLINE_TEXT_FLAG + } + sortBy?.let { + setCompoundDrawablesWithIntrinsicBounds(0, 0, it.indicator, 0) + typeface = ResourcesCompat.getFont(context, com.pluto.plugin.R.font.muli_bold) + setBackgroundColor(tableHeaderSortedBackground) + } + setOnDebounceClickListener(haptic = true) { + onColumnClick.invoke(column) + } + setOnLongClickListener { + onColumnLongClick.invoke(column) + true + } + } + + /** + * Creates a [TextView] to display data in the table. + * + * @param text field value + * @return RowData + */ + private fun rowData(text: String?) = TextView(context) + .apply { + minHeight = tableRowHeight + minWidth = 20f.dp.toInt() + gravity = Gravity.CENTER_VERTICAL + setPadding(PADDING_HORIZONTAL, PADDING_VERTICAL, PADDING_HORIZONTAL, PADDING_VERTICAL) + setSpan { + text?.let { + if (it == "") { + append(light(italic(fontColor("blank", context.color(com.pluto.plugin.R.color.pluto___text_dark_20))))) + } else { + append(it.truncateExcess(TEXT_OFFSET)) + } + } ?: run { + append(light(italic(fontColor("null", context.color(com.pluto.plugin.R.color.pluto___text_dark_20))))) + } + } + textSize = TEXT_SIZE_RECORD + setTextColor(ContextCompat.getColor(context, com.pluto.plugin.R.color.pluto___text_dark)) + typeface = ResourcesCompat.getFont(context, com.pluto.plugin.R.font.muli) + } + + private fun rowTableEnd() = TextView(context) + .apply { + minHeight = tableRowHeight + minWidth = 20f.dp.toInt() + gravity = Gravity.CENTER_VERTICAL + setPadding(PADDING_HORIZONTAL, PADDING_VERTICAL, PADDING_HORIZONTAL, PADDING_VERTICAL) + this.text = context.getString(R.string.pluto_rooms___end_of_table) + textSize = TEXT_SIZE_EOT + setBackgroundColor(tableRowBackground) + setTextColor(ContextCompat.getColor(context, com.pluto.plugin.R.color.pluto___text_dark_80)) + typeface = ResourcesCompat.getFont(context, com.pluto.plugin.R.font.muli_semibold) + } + + /** + * creates a header by using the provided values. + * + * @param values list of values to display + * @param sortBy sorting logic + * @param onColumnClick function to get called on clicking a column item + * @param onColumnLongClick function to get called on long pressing a column item + * @return TableRow + */ + private fun tableHeader( + values: List, + sortBy: Pair?, + onColumnClick: (ColumnModel) -> Unit, + onColumnLongClick: (ColumnModel) -> Unit + ) = + TableRow(context).apply { + setPadding(0, 0, 0, 0) + values.forEach { + val sort = if (it.name == sortBy?.first) sortBy.second else null + val headerColumn = rowHeader(it, sort, onColumnClick, onColumnLongClick).apply { + enableRippleEffect() + } + addView(headerColumn) + } + } + + /** + * creates a row of tuples by using the provided values. + * + * @param values list of values to display + * @return TableRow + */ + private fun tableRow(values: List) = + TableRow(context).apply { + setPadding(0, 0, 0, 0) + values.forEach { + addView(rowData(it)) + } + enableRippleEffect() + } + + /** + * Creates a table with provided values. + * + * @param column list of column names + * @param rows list of rows, each row contains list of fields + * @param sortBy sorting logic + * @param onClick function to get called on clicking the row + * @param onLongClick function to get called on long pressing the row + * @param onColumnClick function to get called on clicking a column item + * @param onColumnLongClick function to get called on long pressing a column item + * @return [TableLayout] containing rows and columns filled with the provided values + */ + @SuppressWarnings("LongParameterList") + fun create( + column: List, + rows: List>, + sortBy: Pair?, + onClick: (Int) -> Unit, + onLongClick: (Int) -> Unit, + onColumnClick: (ColumnModel) -> Unit, + onColumnLongClick: (ColumnModel) -> Unit + ): TableGridView { + addView(tableHeader(column, sortBy, onColumnClick, onColumnLongClick)) + rows.forEachIndexed { index, list -> + val tableRow = tableRow(list).apply { + setOnDebounceClickListener(haptic = true) { onClick(index) } + setOnLongClickListener { + hapticFeedback(true) + onLongClick(index) + true + } + if (index % 2 != 0) { + setBackgroundColor(tableRowBackground) + } + } + addView(tableRow) + } + addView(rowTableEnd()) + return this + } + + companion object { + val PADDING_VERTICAL = 12f.dp.toInt() + val PADDING_HORIZONTAL = 18f.dp.toInt() + const val TEXT_SIZE_RECORD = 14f + const val TEXT_SIZE_EOT = 13f + const val TEXT_OFFSET = 20 + } +} + +private fun View.enableRippleEffect() { + val outValue = TypedValue() + context.theme.resolveAttribute(android.R.attr.selectableItemBackground, outValue, true) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + foreground = ContextCompat.getDrawable(context, outValue.resourceId) + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/ActionsFragment.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/ActionsFragment.kt new file mode 100644 index 000000000..b91a1caa6 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/ActionsFragment.kt @@ -0,0 +1,59 @@ +package com.pluto.plugins.rooms.db.internal.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsFragmentRowActionsBinding +import com.pluto.plugins.rooms.db.internal.ContentViewModel +import com.pluto.plugins.rooms.db.internal.RowAction +import com.pluto.plugins.rooms.db.internal.RowDetailsData +import com.pluto.utilities.device.Device +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +internal class ActionsFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoRoomsFragmentRowActionsBinding::bind) + private val viewModel: ContentViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_rooms___fragment_row_actions, container, false) + + override fun getTheme(): Int = R.style.PlutoRoomsDBBottomSheetDialog + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.setOnShowListener { + val dialog = it as BottomSheetDialog + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + dialog.behavior.peekHeight = Device(requireContext()).screen.heightPx + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + convertArguments(arguments)?.let { dataConfig -> + setupView(dataConfig) + } ?: dismiss() + } + + private fun setupView(dataConfig: RowDetailsData) { + binding.delete.setOnDebounceClickListener { + viewModel.performAction(RowAction.Delete, dataConfig) + dismiss() + } + binding.duplicate.setOnDebounceClickListener { + viewModel.performAction(RowAction.Duplicate, dataConfig) + dismiss() + } + } + + private fun convertArguments(arguments: Bundle?): RowDetailsData? { + return arguments?.getParcelable("data") + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/ColumnDetailsFragment.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/ColumnDetailsFragment.kt new file mode 100644 index 000000000..f43b2cfb2 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/ColumnDetailsFragment.kt @@ -0,0 +1,129 @@ +package com.pluto.plugins.rooms.db.internal.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.fragment.app.activityViewModels +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.chip.Chip +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsFragmentColumnDetailsBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.ContentViewModel +import com.pluto.plugins.rooms.db.internal.SortBy +import com.pluto.plugins.rooms.db.internal.TableModel +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import com.pluto.utilities.viewBinding + +internal class ColumnDetailsFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoRoomsFragmentColumnDetailsBinding::bind) + private val viewModel: ContentViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_rooms___fragment_column_details, container, false) + + override fun getTheme(): Int = R.style.PlutoRoomsDBBottomSheetDialog + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.setOnShowListener { + val dialog = it as BottomSheetDialog + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + dialog.behavior.peekHeight = Device(requireContext()).screen.heightPx + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + convertArguments(arguments)?.let { data -> + setupView(data) + } ?: dismiss() + } + + private fun setupView(data: Pair) { + binding.title.text = data.second.name + binding.primaryKey.visibility = if (data.second.isPrimaryKey) VISIBLE else GONE + viewModel.sortBy?.let { + if (data.second.name.lowercase() == it.first.lowercase()) { + when (it.second) { + is SortBy.Asc -> + binding.sortAscLabel.select() + + is SortBy.Desc -> + binding.sortDescLabel.select() + } + } + } + binding.type.setSpan { + append("Data type: ") + append(semiBold(fontColor("${data.second.type}, ", context.color(com.pluto.plugin.R.color.pluto___text_dark_80)))) + append(semiBold(fontColor(if (data.second.isNotNull) "NOT_NULL" else "NULL", context.color(com.pluto.plugin.R.color.pluto___text_dark_80)))) + } + data.second.defaultValue?.let { + binding.defaultValue.visibility = VISIBLE + binding.defaultValue.setSpan { + append("Default value: ") + append(semiBold(fontColor(it, context.color(com.pluto.plugin.R.color.pluto___text_dark_80)))) + } + } ?: run { + binding.defaultValue.visibility = GONE + } + binding.sortAscLabel.setOnDebounceClickListener(haptic = true) { + applySort(data, SortBy.Asc()) + } + + binding.sortDescLabel.setOnDebounceClickListener(haptic = true) { + applySort(data, SortBy.Desc()) + } + + binding.sortClear.setOnDebounceClickListener(haptic = true) { + viewModel.clearSortBy() + viewModel.selectTable(data.first) + dismiss() + } + } + + private fun applySort(data: Pair, sort: SortBy) { + toast("Sorting by ${data.second.name} : ${sort.label}") + viewModel.setSortBy(data.second, sort) + viewModel.selectTable(data.first) + dismiss() + } + + private fun convertArguments(arguments: Bundle?): Pair? { + val table = arguments?.getParcelable(ATTR_TABLE) + val column = arguments?.getParcelable(ATTR_COLUMN) + return if (table != null && column != null) { + Pair(table, column) + } else { + null + } + } + + companion object { + const val ATTR_COLUMN = "column" + const val ATTR_TABLE = " table" + const val CHIP_TEXT_PADDING = 8f + const val CHIP_ICON_PADDING = 10f + } +} + +private fun Chip.select() { + apply { + chipIcon = ContextCompat.getDrawable(context, R.drawable.pluto_rooms___ic_sort_option_selected) + chipIconSize = 18f.dp + iconStartPadding = ColumnDetailsFragment.CHIP_ICON_PADDING + textStartPadding = ColumnDetailsFragment.CHIP_TEXT_PADDING + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/DetailsFragment.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/DetailsFragment.kt new file mode 100644 index 000000000..1d9b106f1 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/DetailsFragment.kt @@ -0,0 +1,325 @@ +package com.pluto.plugins.rooms.db.internal.ui + +import android.os.Bundle +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.widget.HorizontalScrollView +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.room.RoomDatabase +import com.pluto.plugin.share.ContentShareViewModel +import com.pluto.plugin.share.ShareAction +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.csv.CSVFormatter +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.rooms.db.PlutoRoomsDBWatcher.LOG_TAG +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsFragmentDbDetailsBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.ContentViewModel +import com.pluto.plugins.rooms.db.internal.ContentViewModel.Companion.ERROR_ADD_UPDATE_REQUEST +import com.pluto.plugins.rooms.db.internal.ContentViewModel.Companion.ERROR_FETCH_CONTENT +import com.pluto.plugins.rooms.db.internal.ContentViewModel.Companion.ERROR_FETCH_TABLES +import com.pluto.plugins.rooms.db.internal.DatabaseModel +import com.pluto.plugins.rooms.db.internal.ProcessedTableContents +import com.pluto.plugins.rooms.db.internal.RowAction +import com.pluto.plugins.rooms.db.internal.RowDetailsData +import com.pluto.plugins.rooms.db.internal.TableModel +import com.pluto.plugins.rooms.db.internal.UIViewModel +import com.pluto.plugins.rooms.db.internal.core.query.Query +import com.pluto.plugins.rooms.db.internal.ui.ColumnDetailsFragment.Companion.ATTR_COLUMN +import com.pluto.plugins.rooms.db.internal.ui.ColumnDetailsFragment.Companion.ATTR_TABLE +import com.pluto.utilities.DebugLog +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.delayedLaunchWhenResumed +import com.pluto.utilities.extensions.forEachIndexed +import com.pluto.utilities.extensions.onBackPressed +import com.pluto.utilities.extensions.showMoreOptions +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import com.pluto.utilities.viewBinding +import java.lang.Exception +import java.lang.StringBuilder + +internal class DetailsFragment : Fragment(R.layout.pluto_rooms___fragment_db_details) { + + private val binding by viewBinding(PlutoRoomsFragmentDbDetailsBinding::bind) + private val viewModel: ContentViewModel by activityViewModels() + private val uiViewModel: UIViewModel by viewModels() + private val sharer: ContentShareViewModel by lazyContentSharer() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + convertArguments(arguments)?.let { dbConfig -> + onBackPressed { findNavController().navigateUp() } + viewModel.initDBSession(requireContext(), dbConfig.name, dbConfig.dbClass) + binding.dbName.setSpan { + append(getString(R.string.pluto_rooms___db_title)) + append(bold(" ${dbConfig.name}".uppercase())) + } + + binding.table.setOnDebounceClickListener { openTableSelector() } + binding.alert.setOnDebounceClickListener { + context?.toast(getString(R.string.pluto_rooms___system_table_error)) + } + binding.close.setOnDebounceClickListener { requireActivity().onBackPressed() } + binding.options.setOnDebounceClickListener { + viewModel.currentTable.value?.let { table -> + context?.showMoreOptions(it, R.menu.pluto_rooms___menu_table_options) { item -> + when (item.itemId) { + R.id.add -> openDetailsView(table.name, -1, null, true) + R.id.export -> shareTableContent(table.name) + R.id.refresh -> viewModel.currentTable.value?.let { viewModel.selectTable(it) } + R.id.clear -> viewModel.clearTable(table.name) + R.id.schema -> openTableSchema() + } + } + } ?: toast(getString(R.string.pluto_rooms___select_table_options)) + } + binding.query.setOnDebounceClickListener { + viewModel.currentTable.value?.let { + sharer.share( + Shareable( + title = "Share SQL Query", content = Query.Tables.values(it.name, viewModel.filters, viewModel.sortBy) + ) + ) + } + } + binding.pullToRefresh.setOnRefreshListener { + viewModel.currentTable.value?.let { viewModel.selectTable(it) } + binding.pullToRefresh.isRefreshing = false + } + + binding.applyFilter.setOnDebounceClickListener(haptic = true) { + findNavController().navigate(R.id.openFilterView) + } + + viewModel.currentTable.removeObserver(currentTableObserver) + viewModel.currentTable.observe(viewLifecycleOwner, currentTableObserver) + + viewModel.rowActionEvent.removeObserver(rowClickEventObserver) + viewModel.rowActionEvent.observe(viewLifecycleOwner, rowClickEventObserver) + + viewModel.processedTableContent.removeObserver(tableContentObserver) + viewModel.processedTableContent.observe(viewLifecycleOwner, tableContentObserver) + + viewModel.queryError.removeObserver(errorObserver) + viewModel.queryError.observe(viewLifecycleOwner, errorObserver) + + viewModel.rowCounts.removeObserver(rowCountObserver) + viewModel.rowCounts.observe(viewLifecycleOwner, rowCountObserver) + + uiViewModel.tableGridView.removeObserver(tableUIObserver) + uiViewModel.tableGridView.observe(viewLifecycleOwner, tableUIObserver) + } ?: requireActivity().onBackPressed() + } + + private fun shareTableContent(table: String) { + viewModel.processedTableContent.value?.let { content -> + sharer.performAction( + ShareAction.ShareAsFile( + Shareable( + title = "Export $table Table", content = content.serialize(), fileName = "Export $table table" + ), + "text/csv" + ) + ) + } ?: run { + toast("no content found to share") + } + } + + private fun openTableSelector() { + findNavController().navigate(R.id.openTableSelector) + } + + private fun openTableSchema() { + findNavController().navigate(R.id.openTableSchemaView) + } + + private val rowClickEventObserver = Observer> { + when (it.first) { + is RowAction.Click -> { + val bundle = bundleOf("data" to it.second, "isInsert" to (it.first as RowAction.Click).isInsert) + findNavController().navigate(R.id.openDataEditor, bundle) + } + + is RowAction.LongClick -> { + val bundle = bundleOf("data" to it.second) + findNavController().navigate(R.id.openActionsView, bundle) + } + + RowAction.Duplicate -> viewLifecycleOwner.lifecycleScope.delayedLaunchWhenResumed(100L) { + val bundle = bundleOf("data" to it.second, "isInsert" to true) + findNavController().navigate(R.id.openDataEditor, bundle) + } + + RowAction.Delete -> it.second.values?.let { values -> + val values1 = arrayListOf>().apply { + Pair(it.second.columns, values).forEachIndexed { _, column, row -> + add(Pair(column, row)) + } + } + viewModel.deleteRow(it.second.table, values1) + } + } + } + + private val errorObserver = Observer> { + handleError(it.first, it.second) + } + + private val rowCountObserver = Observer> { + binding.count.setSpan { + append(bold(" ${it.first}")) + it.second?.let { + append("/$it") + } + append(" rows") + } + setupFilterUi() + } + + private fun setupFilterUi() { + if (viewModel.filters.isEmpty()) { + binding.applyFilter.setCompoundDrawablesWithIntrinsicBounds(R.drawable.pluto_rooms___ic_no_filter, 0, 0, 0) + binding.applyFilter.setSpan { + append(fontColor(getString(R.string.pluto_rooms___no_data_filter_applied), context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + append(" ${bold(getString(R.string.pluto_rooms___apply_filter))}") + } + } else { + binding.applyFilter.setCompoundDrawablesWithIntrinsicBounds(R.drawable.pluto_rooms___ic_filter, 0, 0, 0) + binding.applyFilter.setSpan { + append( + fontColor( + String.format( + resources.getQuantityString( + R.plurals.pluto_rooms___applied_filters, viewModel.filters.size, viewModel.filters.size + ) + ), + context.color(com.pluto.plugin.R.color.pluto___blue) + ) + ) + } + } + } + + private fun handleError(error: String, exception: Exception) { + when (error) { + ERROR_FETCH_TABLES, ERROR_FETCH_CONTENT, ERROR_ADD_UPDATE_REQUEST -> { + findNavController().navigate( + R.id.openQueryErrorDialog, bundleOf(QueryErrorFragment.ERROR_MESSAGE to exception.message) + ) + DebugLog.e(LOG_TAG, "error while fetching from table", exception) + } + } + setupFilterUi() + } + + private val currentTableObserver = Observer { table -> + table?.let { + binding.alert.visibility = if (it.isSystemTable) VISIBLE else GONE + binding.table.text = it.name + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + binding.loader.visibility = VISIBLE + resetTableGrid() + viewModel.fetchData(it.name) + } + } ?: openTableSelector() + } + + private val tableContentObserver = Observer { + uiViewModel.generateTableGridView( + requireContext(), it.first, it.second, viewModel.sortBy, + onRowClick = { index, value -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + viewModel.currentTable.value?.let { table -> + openDetailsView(table.name, index, value, false) + } + } + }, + onRowLongClick = { index, value -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + viewModel.currentTable.value?.let { table -> + openActionsView(table.name, index, value) + } + } + }, + onColumnClick = { column -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + viewModel.currentTable.value?.let { table -> + val bundle = bundleOf(ATTR_COLUMN to column, ATTR_TABLE to table) + findNavController().navigate(R.id.openColumnDetailsView, bundle) + } + } + }, + onColumnLongClick = { column -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + viewModel.currentTable.value?.let { table -> + val bundle = bundleOf(ATTR_COLUMN to column, ATTR_TABLE to table) + findNavController().navigate(R.id.openColumnDetailsView, bundle) + } + } + } + ) + } + + private val tableUIObserver = Observer { + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + binding.loader.visibility = GONE + binding.count.visibility = VISIBLE + binding.nsv.removeAllViews() + binding.nsv.addView(it) + } + } + + private fun resetTableGrid() { + binding.nsv.scrollTo(0, 0) + binding.nsv.removeAllViews() + binding.count.visibility = GONE + } + + private fun convertArguments(arguments: Bundle?): DatabaseModel? { + return arguments?.let { + val dbClass = it.get(DB_CLASS) + val dbName = it.getString(DB_NAME) + if (dbClass != null && dbName != null) { + return DatabaseModel(dbName, dbClass as Class) + } + return null + } + } + + private fun openDetailsView(table: String, index: Int, list: List? = null, isInsertEvent: Boolean) { + viewModel.triggerAddRecordEvent(table, index, list, isInsertEvent) + } + + private fun openActionsView(table: String, index: Int, list: List? = null) { + viewModel.triggerActionsOpenEvent(table, index, list) + } + + override fun onDestroyView() { + super.onDestroyView() + viewModel.destroyDBSession() + } + + companion object { + const val DB_CLASS = "dbClass" + const val DB_NAME = "dbName" + } +} + +private fun ProcessedTableContents.serialize(): String { + val stringBuilder = StringBuilder() + stringBuilder.append(CSVFormatter.write(first.map { it.name }.toTypedArray())) + second.forEach { + stringBuilder.append(CSVFormatter.write(it.toTypedArray())) + } + return stringBuilder.toString() +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/EditFragment.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/EditFragment.kt new file mode 100644 index 000000000..ea259280b --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/EditFragment.kt @@ -0,0 +1,196 @@ +package com.pluto.plugins.rooms.db.internal.ui + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.EditText +import android.widget.LinearLayout +import androidx.core.os.bundleOf +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugin.share.ContentShareViewModel +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.rooms.db.PlutoRoomsDBWatcher.LOG_TAG +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsFragmentDataEditorBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.ContentViewModel +import com.pluto.plugins.rooms.db.internal.ContentViewModel.Companion.ERROR_ADD_UPDATE +import com.pluto.plugins.rooms.db.internal.RowDetailsData +import com.pluto.plugins.rooms.db.internal.UIViewModel +import com.pluto.plugins.rooms.db.internal.core.isSystemTable +import com.pluto.plugins.rooms.db.internal.core.query.ExecuteResult +import com.pluto.plugins.rooms.db.internal.core.widgets.DataEditWidget +import com.pluto.plugins.rooms.db.internal.ui.QueryErrorFragment.Companion.ERROR_MESSAGE +import com.pluto.utilities.DebugLog +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.forEachIndexed +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +internal class EditFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoRoomsFragmentDataEditorBinding::bind) + private val viewModel: ContentViewModel by activityViewModels() + private val uiViewModel: UIViewModel by viewModels() + private val sharer: ContentShareViewModel by lazyContentSharer() + private lateinit var deviceInfo: Device + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_rooms___fragment_data_editor, container, false) + + override fun getTheme(): Int = R.style.PlutoRoomsDBBottomSheetDialog + + /** + * Holds all [EditText]s in this view. + */ + private val etList = mutableListOf() + private val fieldValues + get() = etList.map { it.get() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + deviceInfo = Device(requireContext()) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + return dialog + } + + override fun onStart() { + super.onStart() + val width = ViewGroup.LayoutParams.MATCH_PARENT + val height = ViewGroup.LayoutParams.MATCH_PARENT + dialog?.window?.setLayout(width, height) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.setOnShowListener { + val dialog = it as BottomSheetDialog + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + dialog.behavior.peekHeight = deviceInfo.screen.heightPx + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + convertArguments(arguments).apply { + convertArguments(arguments).second?.let { + showInsertUI(this.first, it) + } + } + + viewModel.editEventState.removeObserver(editStateObserver) + viewModel.editEventState.observe(viewLifecycleOwner, editStateObserver) + + viewModel.editError.removeObserver(errorObserver) + viewModel.editError.observe(viewLifecycleOwner, errorObserver) + + uiViewModel.rowEditView.removeObserver(uiObserver) + uiViewModel.rowEditView.observe(viewLifecycleOwner, uiObserver) + } + + private val uiObserver = Observer, LinearLayout>> { + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + binding.loader.visibility = GONE + etList.clear() + etList.addAll(it.first) + + binding.nsv.removeAllViews() + binding.nsv.addView(it.second) + } + } + + private fun showInsertUI(isInsertEvent: Boolean, dataConfig: RowDetailsData) { + if (isInsertEvent) { + binding.title.text = getString(R.string.pluto_rooms___add_row_title) + binding.save.text = getString(R.string.pluto_rooms___add_cta_text) + binding.share.visibility = GONE + } else { + binding.title.text = getString(R.string.pluto_rooms___edit_row_title) + binding.save.text = getString(R.string.pluto_rooms___edit_cta_text) + binding.share.visibility = VISIBLE + } + binding.warning.visibility = if (isSystemTable(dataConfig.table)) VISIBLE else GONE + uiViewModel.generateRowEditView(requireContext(), dataConfig) + binding.share.setOnDebounceClickListener { + sharer.share( + Shareable( + title = "Share Row", + content = dataConfig.toShareText() + ) + ) + } + binding.save.setOnDebounceClickListener { + if (isInsertEvent) { + viewModel.addNewRow(dataConfig.table, fieldValues) + } else { + dataConfig.values?.let { values -> + val prevValues: ArrayList> = arrayListOf() + fieldValues.forEachIndexed { index, pair -> + prevValues.add(Pair(pair.first, values[index])) + } + viewModel.updateRow(dataConfig.table, fieldValues, prevValues) + } + } + } + } + + private val editStateObserver = Observer { + when (it) { + is ExecuteResult.Success.Insert -> toast("item id ${it.id} inserted!") + is ExecuteResult.Success.Update -> toast("${it.numberOfRows} row updated!") + else -> { /*ignore*/ + } + } + dismiss() + } + + private val errorObserver = Observer> { + handleError(it.first, it.second) + } + + private fun handleError(error: String, exception: Exception) { + when (error) { + ERROR_ADD_UPDATE -> { + findNavController().navigate( + R.id.openQueryErrorDialog, + bundleOf(ERROR_MESSAGE to exception.message) + ) + DebugLog.e(LOG_TAG, "error while editing the table", exception) + } + } + } + + private fun convertArguments(arguments: Bundle?): Pair { + return Pair(arguments?.getBoolean("isInsert") ?: true, arguments?.getParcelable("data")) + } +} + +private fun RowDetailsData.toShareText(): String { + val text = StringBuilder() + text.append("Row from table: $table\n") + text.append("{\n") + values?.let { + Pair(columns, it).forEachIndexed { _, column, value -> + text.append("\t${column.name}: $value\n") + } + } + text.append("}") + return text.toString() +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/QueryErrorFragment.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/QueryErrorFragment.kt new file mode 100644 index 000000000..44c67462c --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/QueryErrorFragment.kt @@ -0,0 +1,52 @@ +package com.pluto.plugins.rooms.db.internal.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsFragmentQueryErrorBinding +import com.pluto.utilities.extensions.color +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import com.pluto.utilities.viewBinding + +internal class QueryErrorFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoRoomsFragmentQueryErrorBinding::bind) + private val errorMessage: String? + get() = arguments?.getString(ERROR_MESSAGE) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_rooms___fragment_query_error, container, false) + + override fun getTheme(): Int = R.style.PlutoRoomsDBBottomSheetDialog + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.close.setOnDebounceClickListener { + dismiss() + } + + binding.description.setSpan { + errorMessage?.let { + val errorSplit = it.split(": ") + if (errorSplit.size == 1) { + append(fontColor(errorSplit[0], context.color(com.pluto.plugin.R.color.pluto___text_dark_60))) + } + if (errorSplit.size > 1) { + append(fontColor(semiBold("${errorSplit[0]} : "), context.color(com.pluto.plugin.R.color.pluto___text_dark_80))) + append(fontColor(errorSplit[1], context.color(com.pluto.plugin.R.color.pluto___text_dark_60))) + } + } ?: run { + append(fontColor(GENERIC_ERROR, context.color(com.pluto.plugin.R.color.pluto___text_dark_60))) + } + } + } + + companion object { + const val ERROR_MESSAGE = "error_message" + const val GENERIC_ERROR = "Something went wrong" + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/SelectDBFragment.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/SelectDBFragment.kt new file mode 100644 index 000000000..641aae8b9 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/SelectDBFragment.kt @@ -0,0 +1,92 @@ +package com.pluto.plugins.rooms.db.internal.ui + +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.Session +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsFragmentDbSelectorBinding +import com.pluto.plugins.rooms.db.internal.DatabaseModel +import com.pluto.plugins.rooms.db.internal.RoomsDBViewModel +import com.pluto.plugins.rooms.db.internal.ui.DetailsFragment.Companion.DB_CLASS +import com.pluto.plugins.rooms.db.internal.ui.DetailsFragment.Companion.DB_NAME +import com.pluto.plugins.rooms.db.internal.ui.list.database.DBListAdapter +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.hideKeyboard +import com.pluto.utilities.extensions.linearLayoutManager +import com.pluto.utilities.extensions.setList +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +internal class SelectDBFragment : Fragment(R.layout.pluto_rooms___fragment_db_selector) { + private val binding by viewBinding(PlutoRoomsFragmentDbSelectorBinding::bind) + private val viewModel: RoomsDBViewModel by activityViewModels() + private val dbListAdapter by autoClearInitializer { DBListAdapter(onActionListener) } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.fetch() + binding.list.apply { + adapter = dbListAdapter + addItemDecoration(CustomItemDecorator(requireContext())) + } + + binding.search.doOnTextChanged { text, _, _, _ -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + text?.toString()?.let { + Session.searchText = it + binding.list.setList(filteredDBs(it)) + if (it.isEmpty()) { + binding.list.linearLayoutManager()?.scrollToPositionWithOffset(0, 0) + } + } + } + } + binding.search.setText(Session.searchText) + viewModel.dbs.removeObserver(dbListObserver) + viewModel.dbs.observe(viewLifecycleOwner, dbListObserver) + + binding.close.setOnDebounceClickListener { + activity?.finish() + } + } + + private fun filteredDBs(search: String): List { + var list = emptyList() + viewModel.dbs.value?.let { + list = it.filter { pref -> + pref.name.contains(search, true) + } + } + binding.noItemText.visibility = if (list.isEmpty()) View.VISIBLE else View.GONE + return list + } + + private val dbListObserver = Observer> { + binding.list.setList(filteredDBs(binding.search.text.toString())) + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is DatabaseModel) { + activity?.let { + it.hideKeyboard(viewLifecycleOwner.lifecycleScope) { + val bundle = bundleOf(DB_CLASS to data.dbClass, DB_NAME to data.name) + findNavController().navigate(R.id.openDetails, bundle) + } + } + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/SelectTableFragment.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/SelectTableFragment.kt new file mode 100644 index 000000000..12b390bac --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/SelectTableFragment.kt @@ -0,0 +1,78 @@ +package com.pluto.plugins.rooms.db.internal.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsFragmentTableSelectorBinding +import com.pluto.plugins.rooms.db.internal.ContentViewModel +import com.pluto.plugins.rooms.db.internal.ContentViewModel.Companion.ERROR_FETCH_TABLES +import com.pluto.plugins.rooms.db.internal.TableModel +import com.pluto.plugins.rooms.db.internal.ui.list.table.TableListAdapter +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.setList +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.viewBinding +import java.lang.Exception + +internal class SelectTableFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoRoomsFragmentTableSelectorBinding::bind) + private val viewModel: ContentViewModel by activityViewModels() + private val tableListAdapter: BaseAdapter by autoClearInitializer { TableListAdapter(onActionListener) } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_rooms___fragment_table_selector, container, false) + + override fun getTheme(): Int = R.style.PlutoRoomsDBBottomSheetDialog + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.list.apply { + adapter = tableListAdapter + addItemDecoration(CustomItemDecorator(requireContext(), 16f.dp.toInt())) + } + + viewModel.tables.removeObserver(tableListObserver) + viewModel.tables.observe(viewLifecycleOwner, tableListObserver) + + viewModel.queryError.removeObserver(errorObserver) + viewModel.queryError.observe(viewLifecycleOwner, errorObserver) + } + + private val tableListObserver = Observer> { + binding.list.setList(it) + } + + private val errorObserver = Observer> { + handleError(it.first, it.second) + } + + private fun handleError(error: String, exception: Exception) { + when (error) { + ERROR_FETCH_TABLES -> { + toast("Exception occurred (see logs) : $exception") + exception.printStackTrace() + } + } + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is TableModel) { + viewModel.selectTable(data) + dismiss() + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/TableSchemaFragment.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/TableSchemaFragment.kt new file mode 100644 index 000000000..318752a8a --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/TableSchemaFragment.kt @@ -0,0 +1,86 @@ +package com.pluto.plugins.rooms.db.internal.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugin.share.ContentShareViewModel +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsFragmentTableSchemaBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.ContentViewModel +import com.pluto.plugins.rooms.db.internal.ui.list.column.ColumnListAdapter +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.setList +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +internal class TableSchemaFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoRoomsFragmentTableSchemaBinding::bind) + private val viewModel: ContentViewModel by activityViewModels() + private val sharer: ContentShareViewModel by lazyContentSharer() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_rooms___fragment_table_schema, container, false) + + override fun getTheme(): Int = R.style.PlutoRoomsDBBottomSheetDialog + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.setOnShowListener { + val dialog = it as BottomSheetDialog + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + dialog.behavior.peekHeight = Device(requireContext()).screen.heightPx + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + + binding.list.apply { + adapter = ColumnListAdapter() + addItemDecoration(CustomItemDecorator(requireContext())) + } + binding.share.setOnDebounceClickListener { + viewModel.processedTableContent.value?.first?.let { + sharer.share( + Shareable( + title = "Share Row", + content = it.toShareText() + ) + ) + } + } + + viewModel.processedTableContent.value?.first?.let { + binding.list.setList(it) + } + } +} + +private fun List.toShareText(): String { + val text = StringBuilder() + text.append("Table Schema\n") + forEach { + text.append("${it.name}: {\n") + text.append("\tprimary_key: ${it.isPrimaryKey}\n ") + text.append("\ttype: ${it.type}, ") + if (it.isNotNull) { + text.append("NOT_NULL\n") + } else { + text.append("NULL\n") + } + it.defaultValue?.let { def -> + text.append("\tdefault_value: $def\n") + } + text.append("}\n") + } + return text.toString() +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/AddFilterConditionDialog.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/AddFilterConditionDialog.kt new file mode 100644 index 000000000..548add810 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/AddFilterConditionDialog.kt @@ -0,0 +1,120 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter + +import android.content.Context +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsDialogAddFilterConditionBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.FilterModel +import com.pluto.plugins.rooms.db.internal.FilterRelation +import com.pluto.plugins.rooms.db.internal.ui.filter.value.ValueStubFactory +import com.pluto.plugins.rooms.db.internal.ui.filter.value.components.BaseValueStub +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import java.lang.IllegalStateException + +internal class AddFilterConditionDialog( + context: Context, + onAction: (FilterModel) -> Unit +) : BottomSheetDialog(context, R.style.PlutoRoomsDBBottomSheetDialog) { + + private var valueStub: BaseValueStub? = null + private val sheetView: View = context.inflate(R.layout.pluto_rooms___dialog_add_filter_condition) + private val binding = PlutoRoomsDialogAddFilterConditionBinding.bind(sheetView) + private var data: FilterModel? = null + private var deviceInfo = Device(context) + private var relation: FilterRelation = FilterRelation.Equals + private var isFirstRefresh: Boolean = false + private var isEdit: Boolean = false + + init { + setContentView(sheetView) + setOnShowListener { dialog -> + if (dialog is BottomSheetDialog) { + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) as FrameLayout? + val behavior = BottomSheetBehavior.from(bottomSheet!!) + behavior.apply { + state = BottomSheetBehavior.STATE_EXPANDED + isHideable = false + skipCollapsed = true + peekHeight = deviceInfo.screen.heightPx + } + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } + data?.let { filter -> + isFirstRefresh = true + binding.column.setSpan { + append(semiBold(filter.column.name)) + append(italic(regular(fontColor("\t(${filter.column.type}, ", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))))) + if (filter.column.isNotNull) { + append(italic(regular(fontColor("NOT_NULL)", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))))) + } else { + append(italic(regular(fontColor("NULL)", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))))) + } + } + updateUi(filter.relation, filter.column, data?.value) + binding.cta.setOnDebounceClickListener { + try { + onAction.invoke( + FilterModel( + column = filter.column, + relation = relation, + value = valueStub?.getValue() + ) + ) + dismiss() + } catch (e: IllegalStateException) { + context.toast(e.message ?: "") + } + } + binding.relation.setOnDebounceClickListener { + ChooseRelationDialog(context, filter.column) { + updateUi(it, filter.column) + }.show() + } + } + } + } + + private fun updateUi(relation: FilterRelation, column: ColumnModel, value: String? = null) { + if (isFirstRefresh || this.relation != relation) { + this.relation = relation + binding.relation.setSpan { + append(bold(fontColor(relation.symbol, context.color(com.pluto.plugin.R.color.pluto___text_dark_80)))) + append(italic(light("\t(${relation.javaClass.simpleName})"))) + } + refreshValueView(relation, column, value) + isFirstRefresh = false + } + } + + private fun refreshValueView(relation: FilterRelation, column: ColumnModel, value: String?) { + binding.valueStub.getChildAt(0)?.let { binding.valueStub.removeView(it) } + valueStub = ValueStubFactory.getStub(context, relation, column).apply { + if (isFirstRefresh && isEdit) { + setValue(value) + } + } + binding.valueStub.addView(valueStub) + } + + fun add(data: FilterModel) { + this.data = data + this.isEdit = false + show() + } + + fun edit(data: FilterModel) { + this.data = data + this.isEdit = true + show() + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/ChooseColumnForFilterDialog.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/ChooseColumnForFilterDialog.kt new file mode 100644 index 000000000..605e36bdb --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/ChooseColumnForFilterDialog.kt @@ -0,0 +1,68 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter + +import android.view.View +import android.widget.FrameLayout +import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsDialogChooseColumnForFilterBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.FilterModel +import com.pluto.plugins.rooms.db.internal.ProcessedTableContents +import com.pluto.plugins.rooms.db.internal.ui.filter.list.column.ColumnForFilterListAdapter +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.extensions.setList +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class ChooseColumnForFilterDialog( + fragment: Fragment, + processedTableContent: LiveData, + filterConfig: List?, + onAction: (ColumnModel) -> Unit +) : BottomSheetDialog(fragment.requireContext(), R.style.PlutoRoomsDBBottomSheetDialog) { + + private val sheetView: View = context.inflate(R.layout.pluto_rooms___dialog_choose_column_for_filter) + private val binding = PlutoRoomsDialogChooseColumnForFilterBinding.bind(sheetView) + private var columns: List? = null + private val deviceInfo = Device(fragment.requireContext()) + + init { + setContentView(sheetView) + columns = processedTableContent.value?.first + setOnShowListener { dialog -> + if (dialog is BottomSheetDialog) { + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) as FrameLayout? + val behavior = BottomSheetBehavior.from(bottomSheet!!) + behavior.apply { + state = BottomSheetBehavior.STATE_EXPANDED + isHideable = false + skipCollapsed = true + peekHeight = deviceInfo.screen.heightPx + } + } + filterConfig?.let { + binding.list.apply { + adapter = ColumnForFilterListAdapter(onActionListener, it) + addItemDecoration(CustomItemDecorator(fragment.requireContext(), 12f.dp.toInt())) + } + binding.list.setList(processedTableContent.value?.first ?: emptyList()) + } + } + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is ColumnModel) { + onAction.invoke(data) + dismiss() + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/ChooseRelationDialog.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/ChooseRelationDialog.kt new file mode 100644 index 000000000..724a29a34 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/ChooseRelationDialog.kt @@ -0,0 +1,79 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter + +import android.content.Context +import android.view.View +import android.widget.FrameLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsDialogChooseRelationBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.FilterRelation +import com.pluto.plugins.rooms.db.internal.ui.filter.list.relation.FilterRelationListAdapter +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.extensions.setList +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class ChooseRelationDialog( + context: Context, + columnModel: ColumnModel, + onAction: (FilterRelation) -> Unit +) : BottomSheetDialog(context, R.style.PlutoRoomsDBBottomSheetDialog) { + + private val sheetView: View = context.inflate(R.layout.pluto_rooms___dialog_choose_relation) + private val binding = PlutoRoomsDialogChooseRelationBinding.bind(sheetView) + private val deviceInfo = Device(context) + + init { + setContentView(sheetView) + setOnShowListener { dialog -> + if (dialog is BottomSheetDialog) { + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) as FrameLayout? + val behavior = BottomSheetBehavior.from(bottomSheet!!) + behavior.apply { + state = BottomSheetBehavior.STATE_EXPANDED + isHideable = false + skipCollapsed = true + peekHeight = deviceInfo.screen.heightPx + } + } + binding.list.apply { + adapter = FilterRelationListAdapter(onActionListener) + addItemDecoration(CustomItemDecorator(context, 12f.dp.toInt())) + } + binding.list.setList( + mutableListOf().apply { + add(FilterRelation.Equals) + add(FilterRelation.NotEquals) + add(FilterRelation.Like) + if (columnModel.isNumericalType()) { + add(FilterRelation.GreaterThan) + add(FilterRelation.GreaterThanOrEquals) + add(FilterRelation.LessThan) + add(FilterRelation.LessThanOrEquals) + add(FilterRelation.Between) + } + add(FilterRelation.In) + } + ) + } + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is FilterRelation) { + onAction.invoke(data) + dismiss() + } + } + } +} + +private fun ColumnModel.isNumericalType(): Boolean { + return type.lowercase() == "integer" || type.lowercase() == "boolean" || type.lowercase() == "float" +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/FilterConfig.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/FilterConfig.kt new file mode 100644 index 000000000..6379748e0 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/FilterConfig.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter + +import com.pluto.plugins.rooms.db.internal.FilterModel + +internal object FilterConfig { + private val filterConfigsMap: HashMap?> = hashMapOf() + + fun get(databaseName: String, name: String): List { + return filterConfigsMap[generateKey(databaseName, name)] ?: emptyList() + } + + fun delete(databaseName: String, name: String) { + filterConfigsMap.remove(generateKey(databaseName, name)) + } + + fun set(databaseName: String, name: String, configs: List) { + val key = generateKey(databaseName, name) + val list = arrayListOf().apply { + addAll(configs) + } + filterConfigsMap[key] = list + } + + private fun generateKey(databaseName: String, name: String) = "$databaseName::$name" + fun clear() { + filterConfigsMap.clear() + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/FilterFragment.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/FilterFragment.kt new file mode 100644 index 000000000..2c43068cc --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/FilterFragment.kt @@ -0,0 +1,113 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.activityViewModels +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsFragmentFilterBinding +import com.pluto.plugins.rooms.db.internal.ContentViewModel +import com.pluto.plugins.rooms.db.internal.FilterModel +import com.pluto.plugins.rooms.db.internal.ui.filter.list.filter.FilterListAdapter +import com.pluto.plugins.rooms.db.internal.ui.filter.list.filter.FilterListItemHolder.Companion.ACTION_DELETE +import com.pluto.plugins.rooms.db.internal.ui.filter.list.filter.FilterListItemHolder.Companion.ACTION_EDIT +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +internal class FilterFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoRoomsFragmentFilterBinding::bind) + private val viewModel: ContentViewModel by activityViewModels() + private val filterAdapter: BaseAdapter by autoClearInitializer { FilterListAdapter(onActionListener) } + private val addFilterConditionDialog: AddFilterConditionDialog by autoClearInitializer { + AddFilterConditionDialog(requireContext()) { + addCondition(it) + } + } + private val filterList = arrayListOf() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto_rooms___fragment_filter, container, false) + + override fun getTheme(): Int = R.style.PlutoRoomsDBBottomSheetDialog + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.setOnShowListener { + val dialog = it as BottomSheetDialog + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + dialog.behavior.peekHeight = Device(requireContext()).screen.heightPx + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + binding.list.apply { + adapter = filterAdapter + addItemDecoration(CustomItemDecorator(requireContext(), 16f.dp.toInt())) + } + binding.noItemText.setOnDebounceClickListener { + openColumnChooser() + } + binding.add.setOnDebounceClickListener { + openColumnChooser() + } + binding.applyFilter.setOnDebounceClickListener { + viewModel.updateFilter(filterList) + dismiss() + } + binding.clearFilter.setOnDebounceClickListener { + filterList.clear() + viewModel.updateFilter(filterList) + dismiss() + } + binding.clearFilter.isVisible = viewModel.filters.isNotEmpty() + filterList.addAll(viewModel.filters) + filterAdapter.list = filterList + binding.noItemText.isVisible = filterList.isEmpty() + } + + private fun openColumnChooser() { + ChooseColumnForFilterDialog(this, viewModel.processedTableContent, filterList) { + addFilterConditionDialog.add(FilterModel(it, null)) + }.show() + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is FilterModel) { + when (action) { + ACTION_DELETE -> deleteCondition(data) + ACTION_EDIT -> addFilterConditionDialog.edit(data) + } + } + } + } + + private fun addCondition(data: FilterModel) { + if (filterList.contains(data)) { + filterList.remove(data) + } + filterList.add(data) + filterAdapter.notifyDataSetChanged() + binding.noItemText.isVisible = filterList.isEmpty() + } + + private fun deleteCondition(data: FilterModel) { + filterList.remove(data) + filterAdapter.notifyDataSetChanged() + binding.noItemText.isVisible = filterList.isEmpty() + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/FilterQueryTransformer.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/FilterQueryTransformer.kt new file mode 100644 index 000000000..0ca77c59a --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/FilterQueryTransformer.kt @@ -0,0 +1,65 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter + +import com.pluto.plugins.rooms.db.internal.FilterRelation + +internal class FilterQueryTransformer private constructor() { + companion object { + fun transform(value: String?, relation: FilterRelation): String { + return when (relation) { + FilterRelation.Equals -> transformEquals(value) + FilterRelation.NotEquals -> transformNotEquals(value) + FilterRelation.Like -> " LIKE '%$value%'" + FilterRelation.GreaterThan -> " > $value" + FilterRelation.GreaterThanOrEquals -> " >= $value" + FilterRelation.LessThan -> " < $value" + FilterRelation.LessThanOrEquals -> " <= $value" + FilterRelation.Between -> transformBetween(value) + FilterRelation.In -> transformIn(value) + } + } + + private fun transformEquals(value: String?): String { + return value?.let { + " = '$it'" + } ?: run { + " IS NULL" + } + } + + private fun transformNotEquals(value: String?): String { + return value?.let { + " != '$it'" + } ?: run { + " IS NOT NULL" + } + } + + private fun transformIn(value: String?): String { + val stringBuilder = StringBuilder() + val split = value?.split(",") + if (!split.isNullOrEmpty()) { + stringBuilder.append(" IN (") + split.forEachIndexed { index, data -> + stringBuilder.append("'${data.trim()}'") + if (index < split.lastIndex) { + stringBuilder.append(",") + } + } + stringBuilder.append(")") + } + return stringBuilder.toString() + } + + private fun transformBetween(value: String?): String { + val stringBuilder = StringBuilder() + val split = value?.split(",") + if (!split.isNullOrEmpty()) { + stringBuilder.append(" BETWEEN") + stringBuilder.append(" ${split[0].trim()}") + stringBuilder.append(" AND") + stringBuilder.append(" ${split[1].trim()}") + } + return stringBuilder.toString() + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/column/ColumnForFilterListAdapter.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/column/ColumnForFilterListAdapter.kt new file mode 100644 index 000000000..5ded98ce7 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/column/ColumnForFilterListAdapter.kt @@ -0,0 +1,29 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.list.column + +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.FilterModel +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class ColumnForFilterListAdapter(private val listener: OnActionListener, private val appliedFilters: List) : BaseAdapter() { + + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is ColumnModel -> ITEM_TYPE_MODEL + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_MODEL -> ColumnForFilterListItemHolder(parent, listener, appliedFilters) + else -> null + } + } + + companion object { + const val ITEM_TYPE_MODEL = 1001 + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/column/ColumnForFilterListItemHolder.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/column/ColumnForFilterListItemHolder.kt new file mode 100644 index 000000000..1014aba54 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/column/ColumnForFilterListItemHolder.kt @@ -0,0 +1,43 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.list.column + +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsItemColumnForFilterBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.FilterModel +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class ColumnForFilterListItemHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener, + appliedFilters: List +) : DiffAwareHolder(parent.inflate(R.layout.pluto_rooms___item_column_for_filter), actionListener) { + + private val binding = PlutoRoomsItemColumnForFilterBinding.bind(itemView) + private val alreadyUsedColumns: List = appliedFilters.map { it.column } + + override fun onBind(item: ListItem) { + if (item is ColumnModel) { + if (alreadyUsedColumns.contains(item)) { + binding.value.setSpan { + append(semiBold(fontColor(item.name, context.color(com.pluto.plugin.R.color.pluto___text_dark_40)))) + append(italic(light(fontColor("\t(already applied)", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))))) + } + binding.root.setOnDebounceClickListener {} + } else { + binding.value.setSpan { + append(semiBold(fontColor(item.name, context.color(com.pluto.plugin.R.color.pluto___text_dark_80)))) + } + binding.root.setOnDebounceClickListener { + onAction("click") + } + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/filter/FilterListAdapter.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/filter/FilterListAdapter.kt new file mode 100644 index 000000000..9ea321b2b --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/filter/FilterListAdapter.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.list.filter + +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.internal.FilterModel +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class FilterListAdapter(private val listener: OnActionListener) : BaseAdapter() { + + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is FilterModel -> ITEM_TYPE_MODEL + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_MODEL -> FilterListItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_MODEL = 1001 + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/filter/FilterListItemHolder.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/filter/FilterListItemHolder.kt new file mode 100644 index 000000000..299cd9ea1 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/filter/FilterListItemHolder.kt @@ -0,0 +1,88 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.list.filter + +import android.content.Context +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsItemFilterCreateBinding +import com.pluto.plugins.rooms.db.internal.FilterModel +import com.pluto.plugins.rooms.db.internal.FilterRelation +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.createSpan +import com.pluto.utilities.spannable.setSpan + +internal class FilterListItemHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_rooms___item_filter_create), actionListener) { + + private val binding = PlutoRoomsItemFilterCreateBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is FilterModel) { + binding.column.text = item.column.name + binding.relation.text = item.relation.symbol + binding.values.setSpan { append(getFormattedValue(context, item)) } + binding.root.setOnDebounceClickListener { + onAction(ACTION_EDIT) + } + binding.delete.setOnDebounceClickListener { + onAction(ACTION_DELETE) + } + } + } + + companion object { + const val ACTION_DELETE = "delete" + const val ACTION_EDIT = "edit" + } + + private fun getFormattedValue(context: Context, item: FilterModel): CharSequence { + return when (item.relation) { + is FilterRelation.Between -> { + val split = item.value?.split(",") + if (!split.isNullOrEmpty()) { + context.createSpan { + append(regular(split[0].trim())) + append(light(fontColor(" & ", context.color(com.pluto.plugin.R.color.pluto___text_dark_40)))) + append(regular(split[1].trim())) + } + } else { + item.value ?: "" + } + } + + is FilterRelation.In -> { + val split = item.value?.split(",") + if (!split.isNullOrEmpty()) { + context.createSpan { + split.forEachIndexed { index, value -> + if (value == "") { + append(light(italic(fontColor("blank", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))))) + } else { + append(regular(value.trim())) + } + if (index < split.lastIndex) { + append(light(fontColor(" , ", context.color(com.pluto.plugin.R.color.pluto___text_dark_40)))) + } + } + } + } else { + item.value ?: "" + } + } + + else -> context.createSpan { + when (item.value) { + "" -> append(light(italic(fontColor("blank", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))))) + null -> append(light(italic(fontColor("null", context.color(com.pluto.plugin.R.color.pluto___text_dark_40))))) + else -> append(item.value) + } + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/relation/FilterRelationListAdapter.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/relation/FilterRelationListAdapter.kt new file mode 100644 index 000000000..5177f94d4 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/relation/FilterRelationListAdapter.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.list.relation + +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.internal.FilterRelation +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class FilterRelationListAdapter(private val listener: OnActionListener) : BaseAdapter() { + + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is FilterRelation -> ITEM_TYPE_MODEL + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_MODEL -> FilterRelationListItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_MODEL = 1001 + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/relation/FilterRelationListItemHolder.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/relation/FilterRelationListItemHolder.kt new file mode 100644 index 000000000..38d86b991 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/list/relation/FilterRelationListItemHolder.kt @@ -0,0 +1,33 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.list.relation + +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsItemFilterRelationBinding +import com.pluto.plugins.rooms.db.internal.FilterRelation +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class FilterRelationListItemHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_rooms___item_filter_relation), actionListener) { + + private val binding = PlutoRoomsItemFilterRelationBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is FilterRelation) { + binding.name.setSpan { + append(bold(fontColor(item.symbol, context.color(com.pluto.plugin.R.color.pluto___text_dark_80)))) + append(italic("\t\t(${item.javaClass.simpleName})")) + } + binding.root.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/ValueStubFactory.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/ValueStubFactory.kt new file mode 100644 index 000000000..007d749b1 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/ValueStubFactory.kt @@ -0,0 +1,30 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.value + +import android.content.Context +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.plugins.rooms.db.internal.FilterRelation +import com.pluto.plugins.rooms.db.internal.ui.filter.value.components.BaseValueStub +import com.pluto.plugins.rooms.db.internal.ui.filter.value.components.BetweenValueStub +import com.pluto.plugins.rooms.db.internal.ui.filter.value.components.ComparisonValueStub +import com.pluto.plugins.rooms.db.internal.ui.filter.value.components.InValueStub +import com.pluto.plugins.rooms.db.internal.ui.filter.value.components.LikeValueStub +import com.pluto.plugins.rooms.db.internal.ui.filter.value.components.StringValueStub + +internal class ValueStubFactory private constructor() { + companion object { + fun getStub(context: Context, relation: FilterRelation, column: ColumnModel): BaseValueStub { + return when (relation) { + FilterRelation.Between -> BetweenValueStub(context) + FilterRelation.In -> InValueStub(context, column) + FilterRelation.Like -> LikeValueStub(context, column) + FilterRelation.LessThan, + FilterRelation.LessThanOrEquals, + FilterRelation.GreaterThan, + FilterRelation.GreaterThanOrEquals -> + ComparisonValueStub(context, column) + + else -> StringValueStub(context, column) + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/BaseValueStub.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/BaseValueStub.kt new file mode 100644 index 000000000..49d37194c --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/BaseValueStub.kt @@ -0,0 +1,16 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.value.components + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout + +internal abstract class BaseValueStub : ConstraintLayout { + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + @Throws(IllegalStateException::class) + abstract fun getValue(): String? + abstract fun setValue(value: String?) +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/BetweenValueStub.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/BetweenValueStub.kt new file mode 100644 index 000000000..a68ef02a4 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/BetweenValueStub.kt @@ -0,0 +1,32 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.value.components + +import android.content.Context +import android.text.InputType +import android.view.LayoutInflater +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsStubFilterValueBetweenBinding + +internal class BetweenValueStub(context: Context) : BaseValueStub(context) { + + private val binding = PlutoRoomsStubFilterValueBetweenBinding.inflate(LayoutInflater.from(context), this, true) + + init { + binding.value1.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_NUMBER_FLAG_DECIMAL + binding.value2.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_NUMBER_FLAG_DECIMAL + } + + @Throws(IllegalStateException::class) + override fun getValue(): String? { + if (binding.value1.text.trim().isEmpty() || binding.value2.text.trim().isEmpty()) { + throw IllegalStateException("values cannot be empty") + } + return "${binding.value1.text.trim()},${binding.value2.text}" + } + + override fun setValue(value: String?) { + val split = value?.split(",") + if (!split.isNullOrEmpty()) { + binding.value1.setText(split[0].trim()) + binding.value2.setText(split[1].trim()) + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/ComparisonValueStub.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/ComparisonValueStub.kt new file mode 100644 index 000000000..4c2307c27 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/ComparisonValueStub.kt @@ -0,0 +1,35 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.value.components + +import android.content.Context +import android.text.InputType +import android.view.LayoutInflater +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsStubFilterValueComparisonBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel + +internal class ComparisonValueStub(context: Context, column: ColumnModel) : BaseValueStub(context) { + + private val binding = PlutoRoomsStubFilterValueComparisonBinding.inflate(LayoutInflater.from(context), this, true) + + init { + binding.value.inputType = when (column.type.lowercase()) { + "integer", + "boolean" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED + + "float" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_NUMBER_FLAG_DECIMAL + else -> InputType.TYPE_CLASS_TEXT + } + setValue("") + } + + @Throws(IllegalStateException::class) + override fun getValue(): String? { + if (binding.value.text.trim().isEmpty()) { + throw IllegalStateException("value cannot be empty") + } + return binding.value.text.toString() + } + + override fun setValue(value: String?) { + binding.value.setText(value) + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/InValueStub.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/InValueStub.kt new file mode 100644 index 000000000..c881e14ff --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/InValueStub.kt @@ -0,0 +1,24 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.value.components + +import android.content.Context +import android.view.LayoutInflater +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsStubFilterValueInBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel + +@SuppressWarnings("UnusedPrivateMember") +/** + * todo need to solve input type integer with comma(,) + */ +internal class InValueStub(context: Context, column: ColumnModel) : BaseValueStub(context) { + + private val binding = PlutoRoomsStubFilterValueInBinding.inflate(LayoutInflater.from(context), this, true) + + @Throws(IllegalStateException::class) + override fun getValue(): String? { + return binding.value.text.toString() + } + + override fun setValue(value: String?) { + binding.value.setText(value) + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/LikeValueStub.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/LikeValueStub.kt new file mode 100644 index 000000000..d4c7d185f --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/LikeValueStub.kt @@ -0,0 +1,35 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.value.components + +import android.content.Context +import android.text.InputType +import android.view.LayoutInflater +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsStubFilterValueLikeBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel + +internal class LikeValueStub(context: Context, column: ColumnModel) : BaseValueStub(context) { + + private val binding = PlutoRoomsStubFilterValueLikeBinding.inflate(LayoutInflater.from(context), this, true) + + init { + binding.value.inputType = when (column.type.lowercase()) { + "integer", + "boolean" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED + + "float" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_NUMBER_FLAG_DECIMAL + else -> InputType.TYPE_CLASS_TEXT + } + setValue("") + } + + @Throws(IllegalStateException::class) + override fun getValue(): String? { + if (binding.value.text.trim().isEmpty()) { + throw IllegalStateException("value cannot be empty") + } + return binding.value.text.toString() + } + + override fun setValue(value: String?) { + binding.value.setText(value) + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/StringValueStub.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/StringValueStub.kt new file mode 100644 index 000000000..ae57545f8 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/filter/value/components/StringValueStub.kt @@ -0,0 +1,61 @@ +package com.pluto.plugins.rooms.db.internal.ui.filter.value.components + +import android.content.Context +import android.text.InputType +import android.view.LayoutInflater +import androidx.core.widget.doOnTextChanged +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsStubFilterValueStringBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.utilities.setOnDebounceClickListener + +internal class StringValueStub(context: Context, private val column: ColumnModel) : BaseValueStub(context) { + + private val binding = PlutoRoomsStubFilterValueStringBinding.inflate(LayoutInflater.from(context), this, true) + private var isNull: Boolean = false + + init { + binding.value.inputType = when (column.type.lowercase()) { + "integer", + "boolean" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED + + "float" -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_NUMBER_FLAG_DECIMAL + else -> InputType.TYPE_CLASS_TEXT + } + binding.value.doOnTextChanged { text, _, _, _ -> + if (!text.isNullOrEmpty()) { + isNull = false + binding.value.hint = BLANK + } + } + binding.nullCta.visibility = if (column.isNotNull) GONE else VISIBLE + binding.nullCta.setOnDebounceClickListener { + if (!column.isNotNull) { + setValue(null) + } + } + setValue("") + } + + @Throws(IllegalStateException::class) + override fun getValue(): String? { + return if (isNull) { + null + } else { + binding.value.text.toString() + } + } + + override fun setValue(value: String?) { + binding.value.setText(value) + binding.value.hint = BLANK + if (!column.isNotNull && value == null) { + isNull = true + binding.value.hint = NULL + } + } + + private companion object { + const val BLANK = "blank" + const val NULL = "null" + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/column/ColumnListAdapter.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/column/ColumnListAdapter.kt new file mode 100644 index 000000000..810d5f5de --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/column/ColumnListAdapter.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.rooms.db.internal.ui.list.column + +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class ColumnListAdapter : BaseAdapter() { + + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is ColumnModel -> ITEM_TYPE_MODEL + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_MODEL -> ColumnListItemHolder(parent, null) + else -> null + } + } + + companion object { + const val ITEM_TYPE_MODEL = 1001 + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/column/ColumnListItemHolder.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/column/ColumnListItemHolder.kt new file mode 100644 index 000000000..0433c2833 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/column/ColumnListItemHolder.kt @@ -0,0 +1,46 @@ +package com.pluto.plugins.rooms.db.internal.ui.list.column + +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsItemColumnDetailsBinding +import com.pluto.plugins.rooms.db.internal.ColumnModel +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.spannable.setSpan + +internal class ColumnListItemHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener? +) : DiffAwareHolder(parent.inflate(R.layout.pluto_rooms___item_column_details), actionListener) { + + private val binding = PlutoRoomsItemColumnDetailsBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is ColumnModel) { + binding.title.setSpan { append("- ${item.name}") } + binding.primaryKey.visibility = if (item.isPrimaryKey) VISIBLE else GONE + binding.type.setSpan { + append("Data type: ") + append(bold("${item.type}, ")) + if (item.isNotNull) { + append(bold("NOT_NULL")) + } else { + append(bold("NULL")) + } + } + item.defaultValue?.let { + binding.defaultValue.visibility = VISIBLE + binding.defaultValue.setSpan { + append("Default value: ") + append(bold(it)) + } + } ?: run { + binding.defaultValue.visibility = GONE + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/database/DBListAdapter.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/database/DBListAdapter.kt new file mode 100644 index 000000000..ec455aa14 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/database/DBListAdapter.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.rooms.db.internal.ui.list.database + +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.internal.DatabaseModel +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class DBListAdapter(private val listener: OnActionListener) : BaseAdapter() { + + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is DatabaseModel -> ITEM_TYPE_DB + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_DB -> DBListItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_DB = 1001 + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/database/DBListItemHolder.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/database/DBListItemHolder.kt new file mode 100644 index 000000000..39ed6f26f --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/database/DBListItemHolder.kt @@ -0,0 +1,29 @@ +package com.pluto.plugins.rooms.db.internal.ui.list.database + +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsItemDbSelectorBinding +import com.pluto.plugins.rooms.db.internal.DatabaseModel +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class DBListItemHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_rooms___item_db_selector), actionListener) { + + private val binding = PlutoRoomsItemDbSelectorBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is DatabaseModel) { + binding.value.text = item.name + binding.dbClassName.text = "${item.dbClass.simpleName}.kt" + binding.root.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/table/TableListAdapter.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/table/TableListAdapter.kt new file mode 100644 index 000000000..6d4cd6034 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/table/TableListAdapter.kt @@ -0,0 +1,28 @@ +package com.pluto.plugins.rooms.db.internal.ui.list.table + +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.internal.TableModel +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class TableListAdapter(private val listener: OnActionListener) : BaseAdapter() { + + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is TableModel -> ITEM_TYPE_MODEL + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_MODEL -> TableListItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_MODEL = 1001 + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/table/TableListItemHolder.kt b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/table/TableListItemHolder.kt new file mode 100644 index 000000000..42c0c7197 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/java/com/pluto/plugins/rooms/db/internal/ui/list/table/TableListItemHolder.kt @@ -0,0 +1,44 @@ +package com.pluto.plugins.rooms.db.internal.ui.list.table + +import android.view.ViewGroup +import com.pluto.plugins.rooms.db.R +import com.pluto.plugins.rooms.db.databinding.PlutoRoomsItemTableSelectorBinding +import com.pluto.plugins.rooms.db.internal.TableModel +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +internal class TableListItemHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_rooms___item_table_selector), actionListener) { + + private val binding = PlutoRoomsItemTableSelectorBinding.bind(itemView) + private val value = binding.value + + override fun onBind(item: ListItem) { + if (item is TableModel) { + value.setSpan { + append( + fontColor( + item.name, + context.color( + if (item.isSystemTable) { + com.pluto.plugin.R.color.pluto___text_dark_40 + } else { + com.pluto.plugin.R.color.pluto___text_dark_80 + } + ) + ) + ) + } + binding.root.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_edittext_nullable.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_edittext_nullable.xml new file mode 100644 index 000000000..8e44d85d6 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_edittext_nullable.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_edittext_round.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_edittext_round.xml new file mode 100644 index 000000000..999335100 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_edittext_round.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_filter_item_delete.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_filter_item_delete.xml new file mode 100644 index 000000000..19e2bc5f9 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_filter_item_delete.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_filter_list_relation.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_filter_list_relation.xml new file mode 100644 index 000000000..7cc8d8160 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_filter_list_relation.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_filter_relation_editor.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_filter_relation_editor.xml new file mode 100644 index 000000000..4f03c87a5 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_filter_relation_editor.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_primary_key_badge.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_primary_key_badge.xml new file mode 100644 index 000000000..38b557f6f --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___bg_primary_key_badge.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_add_record.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_add_record.xml new file mode 100644 index 000000000..8cec27af6 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_add_record.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_alert.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_alert.xml new file mode 100644 index 000000000..7f9c99f63 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_alert.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_arrow_back.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_arrow_back.xml new file mode 100644 index 000000000..bd6a5a9cb --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_chevron_down.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_chevron_down.xml new file mode 100644 index 000000000..313eb3acc --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_chevron_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_chevron_right.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_chevron_right.xml new file mode 100644 index 000000000..5216b480a --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_chevron_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_clear_table.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_clear_table.xml new file mode 100644 index 000000000..45e5b143e --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_clear_table.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_close.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_close.xml new file mode 100644 index 000000000..82eaa9f97 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_custom_query.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_custom_query.xml new file mode 100644 index 000000000..f4666982d --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_custom_query.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_database.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_database.xml new file mode 100644 index 000000000..9336ddb11 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_database.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_delete.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_delete.xml new file mode 100644 index 000000000..e96d55015 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_duplicate.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_duplicate.xml new file mode 100644 index 000000000..49fea8d87 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_duplicate.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_export.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_export.xml new file mode 100644 index 000000000..c7329fc62 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_export.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_filter.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_filter.xml new file mode 100644 index 000000000..515089bf3 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_filter.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_filter_add.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_filter_add.xml new file mode 100644 index 000000000..fcae1df78 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_filter_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_filter_item_delete.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_filter_item_delete.xml new file mode 100644 index 000000000..ec5a3da7d --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_filter_item_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_list_icon.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_list_icon.xml new file mode 100644 index 000000000..26fb5b3a5 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_list_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_more.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_more.xml new file mode 100644 index 000000000..e5db6edf0 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_more_dark.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_more_dark.xml new file mode 100644 index 000000000..58ded10be --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_more_dark.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_no_filter.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_no_filter.xml new file mode 100644 index 000000000..e2abfb1c9 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_no_filter.xml @@ -0,0 +1,12 @@ + + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_refresh.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_refresh.xml new file mode 100644 index 000000000..463cc1616 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_rooms_icon.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_rooms_icon.xml new file mode 100644 index 000000000..346d1a02c --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_rooms_icon.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_share.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_share.xml new file mode 100644 index 000000000..0bcbd95ef --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_sort_indicator_asc.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_sort_indicator_asc.xml new file mode 100644 index 000000000..1bf57f04d --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_sort_indicator_asc.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_sort_indicator_desc.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_sort_indicator_desc.xml new file mode 100644 index 000000000..d39b9c8ae --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_sort_indicator_desc.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_sort_option_selected.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_sort_option_selected.xml new file mode 100644 index 000000000..80531b098 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_sort_option_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_table_schema.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_table_schema.xml new file mode 100644 index 000000000..8f5d461ff --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/drawable/pluto_rooms___ic_table_schema.xml @@ -0,0 +1,10 @@ + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___data_edit_widget.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___data_edit_widget.xml new file mode 100644 index 000000000..08af233e9 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___data_edit_widget.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___dialog_add_filter_condition.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___dialog_add_filter_condition.xml new file mode 100644 index 000000000..22e221ef1 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___dialog_add_filter_condition.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___dialog_choose_column_for_filter.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___dialog_choose_column_for_filter.xml new file mode 100644 index 000000000..d5eed2713 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___dialog_choose_column_for_filter.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___dialog_choose_relation.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___dialog_choose_relation.xml new file mode 100644 index 000000000..fb96e99ec --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___dialog_choose_relation.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_column_details.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_column_details.xml new file mode 100644 index 000000000..f77682cf8 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_column_details.xml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_data_editor.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_data_editor.xml new file mode 100644 index 000000000..d1af38070 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_data_editor.xml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_db_details.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_db_details.xml new file mode 100644 index 000000000..ea9657a9e --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_db_details.xml @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_db_selector.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_db_selector.xml new file mode 100644 index 000000000..d2c1e5166 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_db_selector.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_filter.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_filter.xml new file mode 100644 index 000000000..2234fa97a --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_filter.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_query_error.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_query_error.xml new file mode 100644 index 000000000..3074b6f1b --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_query_error.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_rooms_db.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_rooms_db.xml new file mode 100644 index 000000000..b02d8a5ac --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_rooms_db.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_row_actions.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_row_actions.xml new file mode 100644 index 000000000..f165f1977 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_row_actions.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_table_schema.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_table_schema.xml new file mode 100644 index 000000000..bcdc783f9 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_table_schema.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_table_selector.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_table_selector.xml new file mode 100644 index 000000000..e586171a2 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___fragment_table_selector.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_column_details.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_column_details.xml new file mode 100644 index 000000000..372c2843c --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_column_details.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_column_for_filter.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_column_for_filter.xml new file mode 100644 index 000000000..246f33674 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_column_for_filter.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_db_selector.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_db_selector.xml new file mode 100644 index 000000000..ea9a9318b --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_db_selector.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_filter_create.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_filter_create.xml new file mode 100644 index 000000000..643d18fd6 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_filter_create.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_filter_relation.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_filter_relation.xml new file mode 100644 index 000000000..e57761af2 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_filter_relation.xml @@ -0,0 +1,30 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_table_selector.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_table_selector.xml new file mode 100644 index 000000000..41f5431e8 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___item_table_selector.xml @@ -0,0 +1,30 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_between.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_between.xml new file mode 100644 index 000000000..0a751b681 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_between.xml @@ -0,0 +1,56 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_comparison.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_comparison.xml new file mode 100644 index 000000000..61e64b99d --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_comparison.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_in.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_in.xml new file mode 100644 index 000000000..e8f78dac1 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_in.xml @@ -0,0 +1,39 @@ + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_like.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_like.xml new file mode 100644 index 000000000..61e64b99d --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_like.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_string.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_string.xml new file mode 100644 index 000000000..426e06333 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/layout/pluto_rooms___stub_filter_value_string.xml @@ -0,0 +1,47 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/menu/pluto_rooms___menu_table_options.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/menu/pluto_rooms___menu_table_options.xml new file mode 100644 index 000000000..01855d3e3 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/menu/pluto_rooms___menu_table_options.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/navigation/pluto_rooms___navigation.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/navigation/pluto_rooms___navigation.xml new file mode 100644 index 000000000..e7785eb0c --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/navigation/pluto_rooms___navigation.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/colors.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/colors.xml new file mode 100644 index 000000000..98c5c3409 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #66eeecec + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/dimens.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/dimens.xml new file mode 100644 index 000000000..5f623d5d6 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/dimens.xml @@ -0,0 +1,4 @@ + + + 44dp + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/strings.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/strings.xml new file mode 100644 index 000000000..09a1bbbf3 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/strings.xml @@ -0,0 +1,49 @@ + + Rooms Database + No Rooms Databases\nbeing watched. + Search Database + No filter. + Apply + + %d filter applied + %d filters applied + + showing tables from + Choose a Table + This is a System table.\nModifying it might have unintended errors. + ~~ end of table ~~ + Update row + Add new row + Please select a table + Add Data + Update Data + Export Table + Clear Table + Run Custom Query + Refresh + You are editing a System table. + null + set value as null + Delete Row + Duplicate Row + Actions + loading editor + loading table content + Primary Key + Sort by + ASC + DESC + Clear + Table schema + Error + Create Filter + Save Filter + No condition added + Add filter for + Choose Column for Filter + Choose Relation + Clear All + Apply + Insert comma (,) separated list of values + <SQL> + \ No newline at end of file diff --git a/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/styles.xml b/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/styles.xml new file mode 100644 index 000000000..0898c7669 --- /dev/null +++ b/pluto-plugins/plugins/rooms-database/lib/src/main/res/values/styles.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/pluto-plugins/plugins/shared-preferences/README.md b/pluto-plugins/plugins/shared-preferences/README.md new file mode 100644 index 000000000..9ee232911 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/README.md @@ -0,0 +1,37 @@ +## Integrate Shared Preferences Plugin in your application + +[Shared Preferences Demo.webm](https://github.com/user-attachments/assets/2bef6564-9942-4e91-b14b-bbe4069b3837) + +### Add Gradle Dependencies +Pluto Shared Preferences is distributed through [***mavenCentral***](https://central.sonatype.com/artifact/com.androidpluto.plugins/preferences). To use it, you need to add the following Gradle dependency to your build.gradle file of you android app module. + +> Note: add the `no-op` variant to isolate the plugin from release builds. +```groovy +dependencies { + debugImplementation "com.androidpluto.plugins:preferences:$plutoVersion" + releaseImplementation "com.androidpluto.plugins:preferences-no-op:$plutoVersion" +} +``` +
+ +### Install plugin to Pluto + +Now to start using the plugin, add it to Pluto +```kotlin +Pluto.Installer(this) + .addPlugin(PlutoSharePreferencesPlugin()) + .install() +``` +
+ +πŸŽ‰  You are all done! + +Now re-build and run your app and open Pluto, you will see the Shared Preferences plugin installed. + +
+ +### Open Plugin view programmatically +To open Shared Preferences screen via code, use this +```kotlin +Pluto.open(PlutoSharePreferencesPlugin.ID) +``` diff --git a/pluto-plugins/plugins/shared-preferences/lib-no-op/.gitignore b/pluto-plugins/plugins/shared-preferences/lib-no-op/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib-no-op/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib-no-op/build.gradle.kts b/pluto-plugins/plugins/shared-preferences/lib-no-op/build.gradle.kts new file mode 100644 index 000000000..8eb4257e5 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib-no-op/build.gradle.kts @@ -0,0 +1,91 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto.plugins.preferences" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "preferences-no-op" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Shared Preferences Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to manager Shared Preferences in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { +} diff --git a/pluto-plugins/plugins/shared-preferences/lib-no-op/src/main/AndroidManifest.xml b/pluto-plugins/plugins/shared-preferences/lib-no-op/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib-no-op/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib-no-op/src/main/java/com/pluto/plugins/preferences/PlutoSharePreferencesPlugin.kt b/pluto-plugins/plugins/shared-preferences/lib-no-op/src/main/java/com/pluto/plugins/preferences/PlutoSharePreferencesPlugin.kt new file mode 100644 index 000000000..3781f59c4 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib-no-op/src/main/java/com/pluto/plugins/preferences/PlutoSharePreferencesPlugin.kt @@ -0,0 +1,8 @@ +package com.pluto.plugins.preferences + +@SuppressWarnings("UnusedPrivateMember") +class PlutoSharePreferencesPlugin @JvmOverloads constructor(identifier: String = ID) { + companion object { + const val ID = "shared-preferences" + } +} diff --git a/pluto-plugins/plugins/shared-preferences/lib/.gitignore b/pluto-plugins/plugins/shared-preferences/lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib/build.gradle.kts b/pluto-plugins/plugins/shared-preferences/lib/build.gradle.kts new file mode 100644 index 000000000..f013caba1 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/build.gradle.kts @@ -0,0 +1,107 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + resourcePrefix = "pluto_pref___" + namespace = "com.pluto.plugins.preferences" + + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"$verPublish\"") + buildConfigField("long", "VERSION_CODE", "$verCode") + buildConfigField("String", "GIT_SHA", "\"$verGitSHA\"") + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto.plugins" +extra["PUBLISH_ARTIFACT_ID"] = "preferences" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto Shared Preferences Plugin" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Plugin to manager Shared Preferences in Android Pluto" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(project(":pluto-plugins:base:lib")) + implementation(libs.androidx.core) + implementation(libs.androidx.navigation.ui) + implementation(libs.google.material) + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.preference) + + implementation(libs.moshi) + ksp(libs.moshi.codegen) +} diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/AndroidManifest.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/BaseFragment.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/BaseFragment.kt new file mode 100644 index 000000000..a493c2e97 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/BaseFragment.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.preferences + +import androidx.fragment.app.Fragment + +internal class BaseFragment : Fragment(R.layout.pluto_pref___fragment_base) diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/PlutoSharePreferencesPlugin.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/PlutoSharePreferencesPlugin.kt new file mode 100644 index 000000000..8763230df --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/PlutoSharePreferencesPlugin.kt @@ -0,0 +1,39 @@ +package com.pluto.plugins.preferences + +import androidx.fragment.app.Fragment +import com.pluto.plugin.DeveloperDetails +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginConfiguration + +class PlutoSharePreferencesPlugin() : Plugin(ID) { + + @SuppressWarnings("UnusedPrivateMember") + @Deprecated("Use the default constructor PlutoSharePreferencesPlugin() instead.") + constructor(identifier: String) : this() + + override fun getConfig(): PluginConfiguration = PluginConfiguration( + name = context.getString(R.string.pluto_pref___plugin_name), + icon = R.drawable.pluto_pref___ic_pref_icon, + version = BuildConfig.VERSION_NAME + ) + + override fun getView(): Fragment = BaseFragment() + + override fun getDeveloperDetails(): DeveloperDetails { + return DeveloperDetails( + website = "https://androidpluto.com", + vcsLink = "https://github.com/androidPluto/pluto", + twitter = "https://twitter.com/android_pluto" + ) + } + + override fun onPluginInstalled() { + } + + override fun onPluginDataCleared() { + } + + companion object { + const val ID = "shared-preferences" + } +} diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/Session.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/Session.kt new file mode 100644 index 000000000..409847a61 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/Session.kt @@ -0,0 +1,5 @@ +package com.pluto.plugins.preferences + +internal object Session { + var searchText: String? = null +} diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/DataModel.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/DataModel.kt new file mode 100644 index 000000000..35c3d6266 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/DataModel.kt @@ -0,0 +1,23 @@ +package com.pluto.plugins.preferences.ui + +import androidx.annotation.Keep +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.selector.SelectorOption +import com.pluto.utilities.views.keyvalue.KeyValuePairEditMetaData +import com.squareup.moshi.JsonClass + +@Keep +@JsonClass(generateAdapter = true) +internal data class SharedPrefFile( + val label: CharSequence, + val isDefault: Boolean +) : SelectorOption() { + override fun displayText(): CharSequence = label +} + +internal data class SharedPrefKeyValuePair( + val key: String, + val value: Any?, + val prefLabel: String?, + val isDefault: Boolean = false +) : ListItem(), KeyValuePairEditMetaData diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/KeyValueItemHolder.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/KeyValueItemHolder.kt new file mode 100644 index 000000000..c612c78ea --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/KeyValueItemHolder.kt @@ -0,0 +1,57 @@ +package com.pluto.plugins.preferences.ui + +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import com.pluto.plugins.preferences.R +import com.pluto.plugins.preferences.databinding.PlutoPrefItemSharedPrefKeyValueBinding +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.createSpan + +internal class KeyValueItemHolder( + parent: ViewGroup, + actionListener: DiffAwareAdapter.OnActionListener +) : DiffAwareHolder(parent.inflate(R.layout.pluto_pref___item_shared_pref_key_value), actionListener) { + + private val binding = PlutoPrefItemSharedPrefKeyValueBinding.bind(itemView) + private val key = binding.key + private val value = binding.value + private val file = binding.file + + override fun onBind(item: ListItem) { + if (item is SharedPrefKeyValuePair) { + key.text = item.key + file.visibility = if (item.isDefault) GONE else VISIBLE + val fileName = item.prefLabel + file.text = if (fileName != null) { + if (fileName.length > MAX_FILENAME_LENGTH) { + "${fileName.substring(0, MAX_FILENAME_LENGTH - 2)}..." + } else { + fileName + } + } else { + itemView.context.createSpan { + append(fontColor(light(italic("null")), context.color(com.pluto.plugin.R.color.pluto___text_dark_40))) + } + } + item.value?.let { value.text = it.toString() } + + itemView.setOnDebounceClickListener { + onAction("click") + } + itemView.setOnLongClickListener { + onAction("long_click") + return@setOnLongClickListener true + } + } + } + + companion object { + const val MAX_FILENAME_LENGTH = 18 + } +} diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/ListFragment.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/ListFragment.kt new file mode 100644 index 000000000..80785f22d --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/ListFragment.kt @@ -0,0 +1,140 @@ +package com.pluto.plugins.preferences.ui + +import android.os.Bundle +import android.view.View +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import com.pluto.plugin.share.Shareable +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugins.preferences.R +import com.pluto.plugins.preferences.Session +import com.pluto.plugins.preferences.databinding.PlutoPrefFragmentListBinding +import com.pluto.plugins.preferences.utils.fromEditorData +import com.pluto.plugins.preferences.utils.toEditorData +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.hideKeyboard +import com.pluto.utilities.extensions.linearLayoutManager +import com.pluto.utilities.extensions.showMoreOptions +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.selector.lazyDataSelector +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding +import com.pluto.utilities.views.keyvalue.KeyValuePairEditResult +import com.pluto.utilities.views.keyvalue.edit.KeyValuePairEditor +import com.pluto.utilities.views.keyvalue.edit.lazyKeyValuePairEditor + +internal class ListFragment : Fragment(R.layout.pluto_pref___fragment_list) { + private val binding by viewBinding(PlutoPrefFragmentListBinding::bind) + private val viewModel: SharedPrefViewModel by activityViewModels() + private val keyValuePairEditor: KeyValuePairEditor by lazyKeyValuePairEditor() + private val prefAdapter: BaseAdapter by autoClearInitializer { SharedPrefAdapter(onActionListener) } + private val contentSharer by lazyContentSharer() + private val dataSelector by lazyDataSelector() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.refresh() + binding.list.apply { + adapter = prefAdapter + addItemDecoration(CustomItemDecorator(requireContext())) + } + + binding.search.doOnTextChanged { text, _, _, _ -> + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + text?.toString()?.let { + Session.searchText = it + prefAdapter.list = filteredPrefs(it) + if (it.isEmpty()) { + binding.list.linearLayoutManager()?.scrollToPositionWithOffset(0, 0) + } + } + } + } + binding.options.setOnDebounceClickListener { + context?.showMoreOptions(it, R.menu.pluto_pref___menu_more_options) { item -> + when (item.itemId) { + R.id.filter -> openFilterView() + } + } + } + binding.filter.setOnDebounceClickListener { openFilterView() } + binding.search.setText(Session.searchText) + viewModel.preferenceList.removeObserver(sharedPrefObserver) + viewModel.preferenceList.observe(viewLifecycleOwner, sharedPrefObserver) + keyValuePairEditor.result.removeObserver(keyValuePairEditObserver) + keyValuePairEditor.result.observe(viewLifecycleOwner, keyValuePairEditObserver) + + binding.close.setOnDebounceClickListener { + activity?.finish() + } + } + + private fun openFilterView() { + dataSelector.selectMultiple( + title = getString(R.string.pluto_pref___shared_pref_filter), + list = viewModel.getPrefFiles(), + preSelected = viewModel.getSelectedPrefFiles() + ).observe(viewLifecycleOwner) { + val listOfSharePrefFiles = arrayListOf() + it.forEach { option -> + if (option is SharedPrefFile) { + listOfSharePrefFiles.add(option) + } + } + viewModel.setSelectedPrefFiles(listOfSharePrefFiles) + } + } + + private fun filteredPrefs(search: String): List { + var list = emptyList() + viewModel.preferenceList.value?.let { + list = it.filter { pref -> + pref.key.contains(search, true) + } + } + binding.noItemText.visibility = if (list.isEmpty()) View.VISIBLE else View.GONE + return list + } + + private val keyValuePairEditObserver = Observer { + it.value?.let { value -> + if (it.metaData is SharedPrefKeyValuePair) { + val pref: SharedPrefKeyValuePair = it.metaData as SharedPrefKeyValuePair + viewModel.setPrefData(pref, pref.fromEditorData(value)) + } + } + } + + private val sharedPrefObserver = Observer> { + prefAdapter.list = filteredPrefs(binding.search.text.toString()) + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is SharedPrefKeyValuePair) { + when (action) { + "click" -> activity?.let { + it.hideKeyboard(viewLifecycleOwner.lifecycleScope) { + keyValuePairEditor.edit(data.toEditorData()) + } + } + + "long_click" -> contentSharer.share( + Shareable( + content = "${data.key} : ${data.value}", + title = "Share Shared Preference", + fileName = "Preference data from Pluto" + ) + ) + } + } + } + } +} diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/SharedPrefAdapter.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/SharedPrefAdapter.kt new file mode 100644 index 000000000..337654895 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/SharedPrefAdapter.kt @@ -0,0 +1,27 @@ +package com.pluto.plugins.preferences.ui + +import android.view.ViewGroup +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class SharedPrefAdapter(private val listener: OnActionListener) : BaseAdapter() { + + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is SharedPrefKeyValuePair -> ITEM_TYPE_PAIR + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_PAIR -> KeyValueItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_PAIR = 1001 + } +} diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/SharedPrefViewModel.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/SharedPrefViewModel.kt new file mode 100644 index 000000000..3ca494bb3 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/ui/SharedPrefViewModel.kt @@ -0,0 +1,34 @@ +package com.pluto.plugins.preferences.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.pluto.plugins.preferences.utils.SharedPrefUtils + +internal class SharedPrefViewModel(application: Application) : AndroidViewModel(application) { + + val preferenceList: LiveData> + get() = _preferences + private val _preferences = MutableLiveData>() + + private val sharePrefUtils = SharedPrefUtils(application.applicationContext) + + fun refresh() { + _preferences.postValue(sharePrefUtils.get()) + } + + fun getPrefFiles(): List = sharePrefUtils.allPreferenceFiles + + fun getSelectedPrefFiles(): List = sharePrefUtils.selectedPreferenceFiles + + fun setSelectedPrefFiles(files: List) { + sharePrefUtils.selectedPreferenceFiles = files + refresh() + } + + fun setPrefData(pair: SharedPrefKeyValuePair, data: Any) { + sharePrefUtils.set(pair, data) + refresh() + } +} diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/utils/EditProcessor.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/utils/EditProcessor.kt new file mode 100644 index 000000000..76bbd7f0a --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/utils/EditProcessor.kt @@ -0,0 +1,35 @@ +package com.pluto.plugins.preferences.utils + +import com.pluto.plugins.preferences.ui.SharedPrefKeyValuePair +import com.pluto.utilities.views.keyvalue.KeyValuePairEditInputType +import com.pluto.utilities.views.keyvalue.KeyValuePairEditRequest + +internal fun SharedPrefKeyValuePair.toEditorData(): KeyValuePairEditRequest { + return KeyValuePairEditRequest( + key = key, + value = value?.toString(), + hint = when (value) { + is Int, is Long -> "12345" + is Boolean -> "true / false" + is Float -> "1234.89" + else -> "abcde 123" + }, + inputType = when (value) { + is Int, is Long -> KeyValuePairEditInputType.Integer + is Float -> KeyValuePairEditInputType.Float + is Boolean -> KeyValuePairEditInputType.Boolean + else -> KeyValuePairEditInputType.String + }, + metaData = this + ) +} + +internal fun SharedPrefKeyValuePair.fromEditorData(text: String): Any { + return when (value) { + is Int -> text.toInt() + is Long -> text.toLong() + is Float -> text.toFloat() + is Boolean -> text.toBoolean() + else -> text + } +} diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/utils/Preferences.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/utils/Preferences.kt new file mode 100644 index 000000000..5397aebfc --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/utils/Preferences.kt @@ -0,0 +1,18 @@ +package com.pluto.plugins.preferences.utils + +import android.content.Context + +internal class Preferences(context: Context) { + + private val settingsPrefs by lazy { context.preferences("_pluto_pref_settings") } + + internal var selectedPreferenceFiles: String? + get() = settingsPrefs.getString(SELECTED_PREF_FILE, null) + set(value) = settingsPrefs.edit().putString(SELECTED_PREF_FILE, value).apply() + + companion object { + private const val SELECTED_PREF_FILE = "selected_pref_file_v2" + } +} + +private fun Context.preferences(name: String, mode: Int = Context.MODE_PRIVATE) = getSharedPreferences(name, mode) diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/utils/SharedPrefUtils.kt b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/utils/SharedPrefUtils.kt new file mode 100644 index 000000000..ba00b414c --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/java/com/pluto/plugins/preferences/utils/SharedPrefUtils.kt @@ -0,0 +1,125 @@ +package com.pluto.plugins.preferences.utils + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import com.pluto.plugins.preferences.R +import com.pluto.plugins.preferences.ui.SharedPrefFile +import com.pluto.plugins.preferences.ui.SharedPrefKeyValuePair +import com.pluto.plugins.preferences.utils.SharedPrefUtils.Companion.DEFAULT +import com.pluto.plugins.preferences.utils.SharedPrefUtils.Companion.isPlutoPref +import com.pluto.utilities.DebugLog +import com.pluto.utilities.extensions.color +import com.pluto.utilities.spannable.createSpan +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.io.File + +internal class SharedPrefUtils(private val context: Context) { + + private val preferences: Preferences = Preferences(context) + private val moshi: Moshi = Moshi.Builder().build() + private val moshiAdapter: JsonAdapter?> = moshi.adapter(Types.newParameterizedType(List::class.java, String::class.java)) + internal var selectedPreferenceFiles: List = arrayListOf() + get() { + return preferences.selectedPreferenceFiles?.let { + moshiAdapter.fromJson(it)?.map { label -> context.getPrefFile(label) } + } ?: run { + selectedPreferenceFiles = context.getSharePreferencesFiles() + arrayListOf().apply { + addAll(context.getSharePreferencesFiles()) + selectedPreferenceFiles = this + } + } + } + set(value) { + preferences.selectedPreferenceFiles = moshiAdapter.toJson(value.map { it.label.toString() }) + field = value + } + + internal val allPreferenceFiles = context.getSharePreferencesFiles() + + fun get(): List { + val list = arrayListOf() + val prefFilesList = selectedPreferenceFiles + prefFilesList.forEach { + val data = context.getPrefKeyValueMap(it) + list.addAll(data.second) + } + list.sortBy { it.key } + return list + } + + fun set(pair: SharedPrefKeyValuePair, value: Any) { + val prefFile = context.getPrefFile(pair.prefLabel ?: DEFAULT) + val editor = context.getPrefManager(prefFile).edit() + when (value) { + is Int -> editor.putInt(pair.key, value).apply() + is Long -> editor.putLong(pair.key, value).apply() + is Float -> editor.putFloat(pair.key, value).apply() + is Boolean -> editor.putBoolean(pair.key, value).apply() + else -> editor.putString(pair.key, value.toString()).apply() + } + } + + companion object { + const val DEFAULT = "Default" + fun isPlutoPref(it: String): Boolean { + return it.startsWith("_pluto_pref", true) + } + } +} + +private fun Context.getSharePreferencesFiles(): ArrayList { + val prefsDir = File(applicationInfo?.dataDir, "shared_prefs") + val list = arrayListOf() + if (prefsDir.exists() && prefsDir.isDirectory) { + prefsDir.list()?.forEach { + if (!isPlutoPref(it)) { + list.add( + if (it == "${packageName}_preferences.xml") { +// SharedPrefFile(DEFAULT, true) + SharedPrefFile(createSpan { append(italic(light(fontColor(DEFAULT, color(com.pluto.plugin.R.color.pluto___text_dark_60))))) }, true) + } else { + val label = it.replace(".xml", "", true) + SharedPrefFile(label, false) + } + ) + } + } + } + return list +} + +private fun Context.getPrefManager(file: SharedPrefFile): SharedPreferences = + if (file.isDefault) { + PreferenceManager.getDefaultSharedPreferences(this) + } else { + getSharedPreferences(file.label.toString(), Context.MODE_PRIVATE) + } + +private fun Context.getPrefKeyValueMap(file: SharedPrefFile): Pair> { + val prefManager = getPrefManager(file) + val list = prefManager.list(file.label.toString(), file.isDefault) + return Pair(file.label, list) +} + +private fun SharedPreferences.list(label: String, default: Boolean): List { + val list = arrayListOf() + all.toList().forEach { + list.add(SharedPrefKeyValuePair(it.first, it.second, label, default)) + } + return list +} + +@Suppress("TooGenericExceptionCaught") +private fun Context.getPrefFile(label: String): SharedPrefFile { + try { + val prefFilesList = getSharePreferencesFiles() + return prefFilesList.first { it.label == label } + } catch (e: Exception) { + DebugLog.e("preferences", "error while fetching pref file", e) + } + return SharedPrefFile(DEFAULT, isDefault = true) +} diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___bg_shared_pref_file_badge.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___bg_shared_pref_file_badge.xml new file mode 100644 index 000000000..2e51c23a1 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___bg_shared_pref_file_badge.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_arrow_back.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_arrow_back.xml new file mode 100644 index 000000000..bd6a5a9cb --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_close.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_close.xml new file mode 100644 index 000000000..82eaa9f97 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_filter.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_filter.xml similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_filter.xml rename to pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_filter.xml diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_filter_light.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_filter_light.xml new file mode 100644 index 000000000..dcfa71067 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_filter_light.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_more.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_more.xml new file mode 100644 index 000000000..e5db6edf0 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_pref_icon.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_pref_icon.xml new file mode 100644 index 000000000..972112010 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/drawable/pluto_pref___ic_pref_icon.xml @@ -0,0 +1,12 @@ + + + + diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/layout/pluto_pref___fragment_base.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/layout/pluto_pref___fragment_base.xml new file mode 100644 index 000000000..3d189f906 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/layout/pluto_pref___fragment_base.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/layout/pluto_pref___fragment_list.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/layout/pluto_pref___fragment_list.xml new file mode 100644 index 000000000..11aff5224 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/layout/pluto_pref___fragment_list.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/layout/pluto_pref___item_shared_pref_key_value.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/layout/pluto_pref___item_shared_pref_key_value.xml new file mode 100644 index 000000000..023e28e71 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/layout/pluto_pref___item_shared_pref_key_value.xml @@ -0,0 +1,71 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/menu/pluto_pref___menu_more_options.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/menu/pluto_pref___menu_more_options.xml new file mode 100644 index 000000000..3897273a4 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/menu/pluto_pref___menu_more_options.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/navigation/pluto_pref___navigation.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/navigation/pluto_pref___navigation.xml new file mode 100644 index 000000000..35d27cca2 --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/navigation/pluto_pref___navigation.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/values/strings.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/values/strings.xml new file mode 100644 index 000000000..bc3ab6fdc --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Shared Preferences + Preferences Cleared + No Shared Preferences present.\nAdd a Shared Preference or check Filter settings. + Search Preferences + Filter Settings + Edit Preference + Share + showing preference from + Update Value + updating StringSet preferences is not supported. + \ No newline at end of file diff --git a/pluto-plugins/plugins/shared-preferences/lib/src/main/res/values/styles.xml b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/values/styles.xml new file mode 100644 index 000000000..6069c296f --- /dev/null +++ b/pluto-plugins/plugins/shared-preferences/lib/src/main/res/values/styles.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/pluto-version.properties b/pluto-version.properties deleted file mode 100644 index 94a952e23..000000000 --- a/pluto-version.properties +++ /dev/null @@ -1,5 +0,0 @@ -major=1 -minor=1 -patch=3 -build=0 -channel=release diff --git a/pluto/.gitignore b/pluto/.gitignore deleted file mode 100644 index 796b96d1c..000000000 --- a/pluto/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/pluto/build.gradle b/pluto/build.gradle deleted file mode 100644 index d2e75fe8c..000000000 --- a/pluto/build.gradle +++ /dev/null @@ -1,120 +0,0 @@ -plugins { - id 'com.android.library' - id 'kotlin-android' - id 'kotlin-parcelize' - id 'kotlin-kapt' -} - -apply from: "$project.rootDir/build-utils.gradle" -apply from: "${rootDir}/scripts/publish-module.gradle" - -def verCode, verName, verBuild, verNameShort, verPublish -(verCode, verName, verBuild, verNameShort, verPublish) = genVersion() - -ext { - PUBLISH_GROUP_ID = "com.mocklets" - PUBLISH_VERSION = verPublish - PUBLISH_ARTIFACT_ID = 'pluto' -} - -android { - compileSdkVersion rootProject.compileSdkVersion - buildToolsVersion rootProject.buildToolsVersion - - buildFeatures { - viewBinding true - } - - lintOptions { - abortOnError false - } - - defaultConfig { - minSdkVersion rootProject.minSdkVersion - targetSdkVersion rootProject.targetSdkVersion - versionCode verCode - versionName verName - - buildConfigField "String", "VERSION_NAME", "\"${verPublish}\"" - buildConfigField ("long", "VERSION_CODE", "${verCode}") - buildConfigField "String", "GIT_SHA", "\"${gitSha()}\"" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles 'consumer-rules.pro' - } - - buildTypes { - debug { - debuggable true - minifyEnabled false - shrinkResources false -// minifyEnabled true -// shrinkResources false -// zipAlignEnabled true -// multiDexEnabled false -// debuggable true -// proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - release { - debuggable true - minifyEnabled false - shrinkResources false -// minifyEnabled true -// shrinkResources false -// zipAlignEnabled true -// multiDexEnabled false -// debuggable false -// proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - } - - resourcePrefix 'pluto___' -} - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation 'androidx.appcompat:appcompat:1.3.0' - implementation "androidx.core:core-ktx:$androidXCoreVersion" - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' - implementation 'com.google.android.material:material:1.4.0' - - implementation 'androidx.lifecycle:lifecycle-common-java8:2.3.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha02' - implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - - implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' - implementation 'androidx.preference:preference-ktx:1.1.1' - implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01' - implementation 'androidx.databinding:viewbinding:4.1.2' - - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0" - - implementation 'com.google.code.gson:gson:2.8.6' - implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" - implementation 'com.squareup.okio:okio:2.4.3' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.browser:browser:1.3.0' - - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - - implementation "androidx.room:room-ktx:$roomsVersion" - kapt "androidx.room:room-compiler:$roomsVersion" -} - -task validateChanges { - dependsOn 'ktlintFormat' - dependsOn 'detekt' -} \ No newline at end of file diff --git a/pluto/consumer-rules.pro b/pluto/consumer-rules.pro deleted file mode 100644 index 8be7fe7fd..000000000 --- a/pluto/consumer-rules.pro +++ /dev/null @@ -1,20 +0,0 @@ -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - --keep class kotlin.** { *; } --keep class kotlin.Metadata { *; } --dontwarn kotlin.** --keepclassmembers class **$WhenMappings { - ; -} --keepclassmembers class kotlin.Metadata { - public ; -} --assumenosideeffects class kotlin.jvm.internal.Intrinsics { - static void checkParameterIsNotNull(java.lang.Object, java.lang.String); -} \ No newline at end of file diff --git a/pluto/lib-no-op/.gitignore b/pluto/lib-no-op/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto/lib-no-op/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto/lib-no-op/build.gradle.kts b/pluto/lib-no-op/build.gradle.kts new file mode 100644 index 000000000..702efb9ae --- /dev/null +++ b/pluto/lib-no-op/build.gradle.kts @@ -0,0 +1,96 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto" + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"$verPublish\"") + buildConfigField("long", "VERSION_CODE", "$verCode") + buildConfigField("String", "GIT_SHA", "\"$verGitSHA\"") + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto" +extra["PUBLISH_ARTIFACT_ID"] = "pluto-no-op" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Open Sourced, on-device debugger for Android apps" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + implementation(libs.androidx.core) +} diff --git a/pluto/lib-no-op/src/main/AndroidManifest.xml b/pluto/lib-no-op/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/pluto/lib-no-op/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/pluto/lib-no-op/src/main/java/com/pluto/Pluto.kt b/pluto/lib-no-op/src/main/java/com/pluto/Pluto.kt new file mode 100644 index 000000000..700f84c9b --- /dev/null +++ b/pluto/lib-no-op/src/main/java/com/pluto/Pluto.kt @@ -0,0 +1,34 @@ +package com.pluto + +import android.app.Application +import android.os.Bundle + +@SuppressWarnings("EmptyFunctionBlock", "UnusedPrivateMember") +object Pluto { + + @JvmOverloads + fun open(identifier: String? = null, bundle: Bundle? = null) { + } + + fun showNotch(state: Boolean) { + } + + @JvmOverloads + fun clearLogs(identifier: String? = null) {} + + class Installer(private val application: Application) { + + private val plugins = linkedSetOf() + + fun addPlugin(plugin: Any): Installer { + return this + } + + fun addPluginGroup(pluginGroup: Any): Installer { + return this + } + + fun install() { + } + } +} diff --git a/pluto/lib/.gitignore b/pluto/lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pluto/lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pluto/lib/build.gradle.kts b/pluto/lib/build.gradle.kts new file mode 100644 index 000000000..02e42b21c --- /dev/null +++ b/pluto/lib/build.gradle.kts @@ -0,0 +1,118 @@ +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.maven.publish) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String +val verGitSHA = version["gitSha"] as String + +android { + namespace = "com.pluto" + resourcePrefix = "pluto___" + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + buildConfig = true + viewBinding = true + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + buildConfigField("String", "VERSION_NAME", "\"$verPublish\"") + buildConfigField("long", "VERSION_CODE", "$verCode") + buildConfigField("String", "GIT_SHA", "\"$verGitSHA\"") + } + + buildTypes { + getByName("release") { +// isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + + lint { + abortOnError = false + targetSdk = libs.versions.targetSdk.get().toInt() + } +} + +extra["PUBLISH_GROUP_ID"] = "com.androidpluto" +extra["PUBLISH_ARTIFACT_ID"] = "pluto" +extra["PUBLISH_ARTIFACT_NAME"] = "Android Pluto" +extra["PUBLISH_ARTIFACT_DESCRIPTION"] = "Open Sourced, on-device debugger for Android apps" + +mavenPublishing { + coordinates( + groupId = extra["PUBLISH_GROUP_ID"] as String, + artifactId = extra["PUBLISH_ARTIFACT_ID"] as String, + version = verPublish + ) + pom { + name.set(extra["PUBLISH_ARTIFACT_NAME"] as String) + description.set(extra["PUBLISH_ARTIFACT_DESCRIPTION"] as String) + inceptionYear.set(project.findProperty("pom.inceptionYear") as? String) + url.set(project.findProperty("pom.url") as? String) + licenses { + license { + name.set(project.findProperty("pom.license.name") as? String) + url.set(project.findProperty("pom.license.url") as? String) + } + } + developers { + developer { + id.set(project.findProperty("pom.developer.id") as? String) + name.set(project.findProperty("pom.developer.name") as? String) + email.set(project.findProperty("pom.developer.email") as? String) + } + } + scm { + connection.set(project.findProperty("pom.scm.connection") as? String) + developerConnection.set(project.findProperty("pom.scm.developerConnection") as? String) + url.set(project.findProperty("pom.scm.url") as? String) + } + } + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() +} + +dependencies { + api(project(":pluto-plugins:base:lib")) + + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + + implementation(libs.retrofit) + implementation(libs.retrofit.converter.moshi) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.okhttp) + implementation(libs.okio) + + implementation(libs.moshi) + ksp(libs.moshi.codegen) + + // Test dependencies + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.mockito.core) + testImplementation(libs.androidx.core.testing) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.runner) +} diff --git a/pluto/lib/src/main/AndroidManifest.xml b/pluto/lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..181af1262 --- /dev/null +++ b/pluto/lib/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/java/com/pluto/Pluto.kt b/pluto/lib/src/main/java/com/pluto/Pluto.kt new file mode 100644 index 000000000..36ed0c815 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/Pluto.kt @@ -0,0 +1,223 @@ +package com.pluto + +import android.app.Application +import android.content.Intent +import android.os.Bundle +import com.pluto.core.Session +import com.pluto.core.applifecycle.AppLifecycle +import com.pluto.core.applifecycle.AppStateCallback +import com.pluto.core.notch.Notch +import com.pluto.core.notch.NotchStateCallback +import com.pluto.core.notification.NotificationManager +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginEntity +import com.pluto.plugin.PluginGroup +import com.pluto.plugin.PluginManager +import com.pluto.plugin.libinterface.NotificationInterface.Companion.BUNDLE_LABEL +import com.pluto.plugin.libinterface.NotificationInterface.Companion.ID_LABEL +import com.pluto.plugin.settings.SettingsPreferences +import com.pluto.settings.ResetDataCallback +import com.pluto.tool.ToolManager +import com.pluto.ui.container.PlutoActivity +import com.pluto.ui.selector.SelectorActivity +import com.pluto.ui.selector.SelectorStateCallback +import com.pluto.utilities.extensions.toast + +/** + * Main entry point for the Pluto debugging library. + * + * Pluto is a singleton object that provides access to various debugging tools and plugins. + * It must be initialized with an Application instance and a set of plugins using the [Installer] class. + * + * Example usage: + * ``` + * Pluto.Installer(application) + * .addPlugin(NetworkPlugin()) + * .addPluginGroup(DatabasePluginGroup()) + * .install() + * ``` + * + * Once initialized, Pluto can be opened using the [open] method, which will display either + * a specific plugin or the plugin selector screen. + */ +object Pluto { + + /** Activity lifecycle callback handler to track app state */ + private lateinit var appLifecycle: AppLifecycle + + /** Application instance used for context and lifecycle callbacks */ + private lateinit var application: Application + + /** Optional notch UI component that can be shown/hidden */ + private var notch: Notch? = null + + /** Manages all installed plugins */ + internal lateinit var pluginManager: PluginManager + + /** Manages debugging tools */ + internal lateinit var toolManager: ToolManager + + /** Manages notifications */ + private lateinit var notificationManager: NotificationManager + + /** Maintains the current debugging session */ + internal val session = Session() + + /** Callback for data reset operations */ + internal lateinit var resetDataCallback: ResetDataCallback + + /** Callback for app state changes (foreground/background) */ + internal lateinit var appStateCallback: AppStateCallback + + /** Callback for selector UI state changes */ + internal lateinit var selectorStateCallback: SelectorStateCallback + + /** Callback for notch state changes */ + private lateinit var notchStateCallback: NotchStateCallback + + /** + * Initializes Pluto with the application instance and a set of plugins. + * + * This method: + * 1. Initializes all callbacks + * 2. Registers activity lifecycle callbacks + * 3. Installs plugins + * 4. Initializes tools + * 5. Sets up notifications + * 6. Initializes settings + * 7. Sets up the notch UI component + * + * @param application The application instance + * @param plugins The set of plugins to install + */ + private fun init(application: Application, plugins: LinkedHashSet) { + initialiseCallbacks() + this.application = application + appLifecycle = AppLifecycle(appStateCallback) + application.registerActivityLifecycleCallbacks(appLifecycle) + pluginManager = PluginManager(application).apply { + install(plugins) + } + toolManager = ToolManager(application, appStateCallback.state).apply { + initialise() + } + notificationManager = NotificationManager(application, appStateCallback.state) + SettingsPreferences.init(application.applicationContext) + notch = Notch(application, notchStateCallback.state) + } + + /** + * Opens Pluto UI, either showing a specific plugin or the plugin selector screen. + * + * If an identifier is provided, Pluto will attempt to open the corresponding plugin. + * If the plugin is not found, a toast message will be shown. + * If no identifier is provided, the plugin selector screen will be shown. + * + * @param identifier The plugin identifier to open, or null to show the plugin selector + * @param bundle Optional bundle of data to pass to the plugin + */ + @JvmOverloads + fun open(identifier: String? = null, bundle: Bundle? = null) { + val intent: Intent? + if (identifier != null) { + pluginManager.get(identifier)?.let { + intent = Intent(application.applicationContext, PlutoActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + intent.putExtra(ID_LABEL, identifier) + intent.putExtra(BUNDLE_LABEL, bundle) + application.applicationContext.startActivity(intent) + return + } + application.applicationContext.toast("Plugin [$identifier] not installed") + } else { + intent = Intent(application.applicationContext, SelectorActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + application.applicationContext.startActivity(intent) + } + } + + /** + * Shows or hides the notch UI component. + * + * The notch is a small UI element that can be used to quickly access Pluto. + * + * @param state True to show the notch, false to hide it + */ + fun showNotch(state: Boolean) { + notch?.enable(state) + } + + /** + * Clears logs for a specific plugin or all plugins. + * + * @param identifier The plugin identifier to clear logs for, or null to clear logs for all plugins + */ + @JvmOverloads + fun clearLogs(identifier: String? = null) { + pluginManager.clearLogs(identifier) + } + + /** + * Initializes all callbacks used by Pluto. + * + * This includes: + * - Reset data callback + * - App state callback + * - Selector state callback + * - Notch state callback + */ + private fun initialiseCallbacks() { + resetDataCallback = ResetDataCallback() + appStateCallback = AppStateCallback() + selectorStateCallback = SelectorStateCallback() + notchStateCallback = NotchStateCallback(appStateCallback.state, selectorStateCallback.state) + } + + /** + * Builder class for initializing Pluto with plugins. + * + * This class provides a fluent API for adding plugins and plugin groups + * before installing Pluto. + * + * @property application The application instance to initialize Pluto with + */ + class Installer(private val application: Application) { + + private val plugins = linkedSetOf() + + /** + * Adds a plugin to be installed with Pluto. + * + * @param plugin The plugin to add + * @return This Installer instance for method chaining + */ + fun addPlugin(plugin: Plugin): Installer { + plugins.add(plugin) + return this + } + + /** + * Adds a plugin group to be installed with Pluto. + * + * A plugin group is a collection of related plugins. + * + * @param pluginGroup The plugin group to add + * @return This Installer instance for method chaining + */ + fun addPluginGroup(pluginGroup: PluginGroup): Installer { + plugins.add(pluginGroup) + return this + } + + /** + * Completes the installation process by initializing Pluto with the added plugins. + * + * This method should be called after all plugins have been added. + */ + fun install() { + init(application, plugins) + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/core/PlutoFileProvider.kt b/pluto/lib/src/main/java/com/pluto/core/PlutoFileProvider.kt new file mode 100644 index 000000000..50dd1f6da --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/PlutoFileProvider.kt @@ -0,0 +1,12 @@ +package com.pluto.core + +import androidx.core.content.FileProvider + +/** + * Custom FileProvider implementation for Pluto. + * + * This class extends Android's FileProvider to enable secure file sharing + * between Pluto and other applications. It's used for sharing logs, screenshots, + * and other files generated by Pluto's debugging tools. + */ +internal class PlutoFileProvider : FileProvider() diff --git a/pluto/lib/src/main/java/com/pluto/core/Session.kt b/pluto/lib/src/main/java/com/pluto/core/Session.kt new file mode 100644 index 000000000..2c4a6631c --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/Session.kt @@ -0,0 +1,12 @@ +package com.pluto.core + +/** + * Maintains the current debugging session state. + * + * This class tracks session-level information such as whether consent + * has been shown to the user. + */ +internal class Session { + /** Tracks whether the consent dialog has already been shown to the user */ + var isConsentAlreadyShown = false +} diff --git a/pluto/lib/src/main/java/com/pluto/core/applifecycle/AppLifecycle.kt b/pluto/lib/src/main/java/com/pluto/core/applifecycle/AppLifecycle.kt new file mode 100644 index 000000000..9ee187195 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/applifecycle/AppLifecycle.kt @@ -0,0 +1,64 @@ +package com.pluto.core.applifecycle + +import android.app.Activity +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle + +/** + * Tracks application lifecycle events to determine when the app is in foreground or background. + * + * This class implements ActivityLifecycleCallbacks to monitor activity start and stop events, + * maintaining a count of active activities to determine the overall app state. + * + * @property appStateCallback Callback to notify when app state changes between foreground and background + */ +internal class AppLifecycle(private val appStateCallback: AppStateCallback) : ActivityLifecycleCallbacks { + + /** Counter to track the number of started (visible) activities */ + private var activityCount = 0 + + /** + * Called when an activity is started. + * + * Increments the activity counter and updates app state to foreground + * when the first activity becomes visible. + * + * @param activity The activity that was started + */ + override fun onActivityStarted(activity: Activity) { + activityCount++ + if (activityCount == 1) { + appStateCallback.state.postValue(AppStateCallback.State.Foreground) + } + } + + /** + * Called when an activity is stopped. + * + * Decrements the activity counter and updates app state to background + * when no activities are visible. + * + * @param activity The activity that was stopped + */ + override fun onActivityStopped(activity: Activity) { + activityCount-- + if (activityCount == 0) { + appStateCallback.state.postValue(AppStateCallback.State.Background) + } + } + + /** Called when an activity is created. Not used in this implementation. */ + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + + /** Called when an activity is resumed. Not used in this implementation. */ + override fun onActivityResumed(activity: Activity) {} + + /** Called when an activity is paused. Not used in this implementation. */ + override fun onActivityPaused(activity: Activity) {} + + /** Called when an activity's state is saved. Not used in this implementation. */ + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + + /** Called when an activity is destroyed. Not used in this implementation. */ + override fun onActivityDestroyed(activity: Activity) {} +} diff --git a/pluto/lib/src/main/java/com/pluto/core/applifecycle/AppStateCallback.kt b/pluto/lib/src/main/java/com/pluto/core/applifecycle/AppStateCallback.kt new file mode 100644 index 000000000..1bfae0124 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/applifecycle/AppStateCallback.kt @@ -0,0 +1,28 @@ +package com.pluto.core.applifecycle + +import androidx.lifecycle.MutableLiveData + +/** + * Callback for tracking and notifying application state changes. + * + * This class provides a LiveData object that emits state changes when the application + * moves between foreground and background states. + */ +internal class AppStateCallback { + /** LiveData that emits the current application state (foreground or background) */ + val state = MutableLiveData() + + /** + * Sealed class representing possible application states. + * + * The application can be either in the foreground (at least one activity visible) + * or in the background (no activities visible). + */ + sealed class State { + /** Application is in the foreground (at least one activity is visible) */ + object Foreground : State() + + /** Application is in the background (no activities are visible) */ + object Background : State() + } +} diff --git a/pluto/lib/src/main/java/com/pluto/core/network/DataModels.kt b/pluto/lib/src/main/java/com/pluto/core/network/DataModels.kt new file mode 100644 index 000000000..30788a2aa --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/network/DataModels.kt @@ -0,0 +1,41 @@ +package com.pluto.core.network + +import com.squareup.moshi.JsonClass + +/** + * Sealed class that wraps API responses to handle both success and failure cases. + * + * This wrapper provides a type-safe way to handle API responses, ensuring that + * error handling is consistent across the application. + * + * @param T The type of data returned in case of success + */ +internal sealed class ResponseWrapper { + /** + * Represents a successful API response. + * + * @property body The response data + */ + data class Success(val body: T) : ResponseWrapper() + + /** + * Represents a failed API response. + * + * @property error The error response from the API + * @property errorString Optional additional error information + */ + data class Failure(val error: ErrorResponse, val errorString: String? = null) : + ResponseWrapper() +} + +/** + * Data class representing an error response from the API. + * + * This class is annotated with JsonClass to generate a Moshi adapter + * for JSON serialization/deserialization. + * + * @property reason Optional reason for the error + * @property error Error code or message + */ +@JsonClass(generateAdapter = true) +internal data class ErrorResponse(val reason: String?, val error: String) diff --git a/pluto/lib/src/main/java/com/pluto/core/network/Network.kt b/pluto/lib/src/main/java/com/pluto/core/network/Network.kt new file mode 100644 index 000000000..32833b5c4 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/network/Network.kt @@ -0,0 +1,76 @@ +package com.pluto.core.network + +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +/** + * Singleton object that provides network services for Pluto. + * + * This class configures and manages Retrofit and OkHttp clients for making + * network requests to Pluto's API. It provides methods to obtain service + * interfaces for different API endpoints. + */ +internal object Network { + + /** Read timeout in seconds for network requests */ + private const val READ_TIMEOUT = 30L + + /** + * Lazily initialized Retrofit instance configured with Moshi converter + * and the OkHttp client. + */ + private val retrofit: Retrofit by lazy { + Retrofit.Builder() + .baseUrl("https://api.pluto.com") + .addConverterFactory(MoshiConverterFactory.create()) + .client(okHttpClient) + .build() + } + + /** + * OkHttp client configured with appropriate timeouts and interceptors. + */ + private val okHttpClient: OkHttpClient = OkHttpClient.Builder() + .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) + .addInterceptors() + .build() + + /** + * Creates and returns a service interface for the specified class. + * + * @param cls The class of the service interface to create + * @return An implementation of the service interface + */ + fun getService(cls: Class): T { + return retrofit.create(cls) + } + + /** + * Creates and returns a lazily initialized service interface. + * + * This method uses reified type parameters to avoid having to pass + * the class explicitly. + * + * @return A lazy-initialized implementation of the service interface + */ + inline fun getService(): Lazy { + return lazy { + getService(T::class.java) + } + } +} + +/** + * Extension function to add interceptors to an OkHttpClient.Builder. + * + * Currently commented out, but can be used to add logging or other + * interceptors as needed. + * + * @return The builder with interceptors added + */ +private fun OkHttpClient.Builder.addInterceptors(): OkHttpClient.Builder { +// addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + return this +} diff --git a/pluto/lib/src/main/java/com/pluto/core/network/NetworkCalls.kt b/pluto/lib/src/main/java/com/pluto/core/network/NetworkCalls.kt new file mode 100644 index 000000000..69dd7b837 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/network/NetworkCalls.kt @@ -0,0 +1,108 @@ +package com.pluto.core.network + +import android.util.Log +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import java.io.IOException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.HttpException + +/** + * Executes a network API call and wraps the response in a ResponseWrapper. + * + * This function handles exceptions that may occur during the API call and + * converts them into appropriate error responses. It uses coroutines to + * perform the network call on the specified dispatcher. + * + * @param dispatcher The coroutine dispatcher to use for the API call (defaults to IO) + * @param apiCall The suspend function that makes the actual API call + * @return A ResponseWrapper containing either the successful result or an error + */ +@Suppress("TooGenericExceptionCaught") +internal suspend fun enqueue( + dispatcher: CoroutineDispatcher = Dispatchers.IO, + apiCall: suspend () -> T +): ResponseWrapper { + + return withContext(dispatcher) { + try { + ResponseWrapper.Success(apiCall.invoke()) + } catch (throwable: Throwable) { + Log.e("network_error", "network failure", throwable) + when (throwable) { + is IOException -> ResponseWrapper.Failure( + ErrorResponse( + "IO_Exception", throwable.message ?: DEFAULT_ERROR_MESSAGE + ) + ) + is HttpException -> ResponseWrapper.Failure(convertErrorBody(throwable)) + else -> ResponseWrapper.Failure( + ErrorResponse(CONVERSION_FAILURE, DEFAULT_ERROR_MESSAGE) + ) + } + } + } +} + +/** + * Converts an HTTP exception to an ErrorResponse object. + * + * This function attempts to parse the error body of an HTTP exception + * into an ErrorResponse object using Moshi. If parsing fails, it returns + * a default error response. + * + * @param throwable The HTTP exception to convert + * @return An ErrorResponse object representing the error + */ +@Suppress("TooGenericExceptionCaught") +private fun convertErrorBody(throwable: HttpException): ErrorResponse { + val moshiAdapter: JsonAdapter = Moshi.Builder().build().adapter(ErrorResponse::class.java) + val errorString = throwable.response()?.errorBody()?.string() + return if (!errorString.isNullOrEmpty()) { + try { + run { + val error = moshiAdapter.fromJson(errorString) + validateError(error) + error ?: ErrorResponse(VALIDATION_ERROR_MESSAGE, DEFAULT_ERROR_MESSAGE) + } + } catch (exception: Exception) { + Log.e( + "network_error", + exception.message.toString(), + exception + ) + ErrorResponse(CONVERSION_FAILURE, DEFAULT_ERROR_MESSAGE) + } + } else { + ErrorResponse(UPSTREAM_FAILURE, EMPTY_ERROR_MESSAGE) + } +} + +/** + * Validates that an ErrorResponse object has a non-null error field. + * + * @param error The ErrorResponse object to validate + * @throws KotlinNullPointerException if the error field is null + */ +private fun validateError(error: ErrorResponse?) { + if (error?.error == null) { // TODO handle deserialization issue + throw KotlinNullPointerException("response.error value null") + } +} + +/** Default error message for general errors */ +private const val DEFAULT_ERROR_MESSAGE = "Something went wrong!" + +/** Error message for empty error responses */ +private const val EMPTY_ERROR_MESSAGE = "empty error response" + +/** Error code for validation errors */ +private const val VALIDATION_ERROR_MESSAGE = "validation_error_message" + +/** Error code for upstream failures */ +private const val UPSTREAM_FAILURE = "upstream_failure" + +/** Error code for response conversion failures */ +private const val CONVERSION_FAILURE = "response_conversion_failure" diff --git a/pluto/lib/src/main/java/com/pluto/core/notch/Notch.kt b/pluto/lib/src/main/java/com/pluto/core/notch/Notch.kt new file mode 100644 index 000000000..d249624fd --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/notch/Notch.kt @@ -0,0 +1,107 @@ +package com.pluto.core.notch + +import android.app.Application +import android.app.Service +import android.view.WindowManager +import androidx.lifecycle.LiveData +import com.pluto.Pluto +import com.pluto.core.applifecycle.AppStateCallback +import com.pluto.utilities.extensions.canDrawOverlays + +/** + * Manages the floating notch UI component that provides quick access to Pluto. + * + * The notch is a small floating button that appears on top of the application UI + * and allows users to quickly open Pluto's debugging interface. It observes state + * changes to determine when it should be shown or hidden. + * + * @property application The application instance used for context + * @param shouldShowNotch LiveData that determines whether the notch should be visible + */ +internal class Notch(private val application: Application, shouldShowNotch: LiveData) { + + /** + * Initializes the notch by observing the shouldShowNotch LiveData. + * When the value changes, the notch is either added or removed accordingly. + */ + init { + shouldShowNotch.observeForever { + if (it) { + add() + } else { + remove() + } + } + } + + /** + * Listener for notch interaction events. + * Handles click events and layout parameter updates. + */ + private val interactionListener = object : OnNotchInteractionListener { + /** + * Called when the notch is clicked. + * Opens the Pluto debugging interface. + */ + override fun onClick() { + Pluto.open() + } + + /** + * Called when the notch's layout parameters are updated. + * Updates the notch's position in the window. + * + * @param params The updated window layout parameters + */ + override fun onLayoutParamsUpdated(params: WindowManager.LayoutParams) { + notchViewManager.view?.parent?.let { + windowManager.updateViewLayout(notchViewManager.view, params) + } + } + } + + /** Flag indicating whether the notch is enabled */ + private var enabled = true + + /** Manages the notch view creation and lifecycle */ + private val notchViewManager: NotchViewManager = NotchViewManager(application.applicationContext, interactionListener) + + /** Window manager used to add and remove the notch view */ + private val windowManager: WindowManager = application.applicationContext.getSystemService(Service.WINDOW_SERVICE) as WindowManager + + /** + * Adds the notch to the window if enabled and permission is granted. + */ + private fun add() { + if (enabled) { + val context = application.applicationContext + if (context.canDrawOverlays()) { + notchViewManager.addView(context, windowManager) + } + } + } + + /** + * Removes the notch from the window. + */ + private fun remove() { + notchViewManager.removeView(windowManager) + } + + /** + * Enables or disables the notch. + * + * When enabled and the app is in the foreground, the notch will be shown. + * When disabled or the app is in the background, the notch will be hidden. + * + * @param state True to enable the notch, false to disable it + */ + internal fun enable(state: Boolean) { + enabled = state + if (enabled && Pluto.appStateCallback.state.value is AppStateCallback.State.Foreground) { + add() + } else { + remove() + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/core/notch/NotchStateCallback.kt b/pluto/lib/src/main/java/com/pluto/core/notch/NotchStateCallback.kt new file mode 100644 index 000000000..14e0daac1 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/notch/NotchStateCallback.kt @@ -0,0 +1,58 @@ +package com.pluto.core.notch + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import com.pluto.core.applifecycle.AppStateCallback + +/** + * Callback that determines when the notch UI component should be visible. + * + * This class combines app state (foreground/background) and selector state (showing/hidden) + * to determine whether the notch should be visible. The notch is only shown when the app + * is in the foreground and the selector is not visible. + * + * @param appState LiveData that emits the current application state + * @param selectorState LiveData that indicates whether the selector UI is visible + */ +internal class NotchStateCallback(appState: LiveData, selectorState: LiveData) { + /** + * LiveData that emits whether the notch should be visible. + * True indicates the notch should be shown, false indicates it should be hidden. + */ + val state = MediatorLiveData() + + /** + * Initializes the state by observing both app state and selector state. + * When either changes, the notch visibility state is recalculated. + */ + init { + state.addSource(selectorState) { + state.postValue(getState(appState.value, selectorState.value)) + } + state.addSource(appState) { + state.postValue(getState(appState.value, selectorState.value)) + } + } + + /** + * Determines whether the notch should be visible based on app state and selector state. + * + * The notch is visible only when: + * 1. The app is in the foreground + * 2. The selector UI is not visible + * + * @param state The current application state (foreground/background) + * @param showing Whether the selector UI is currently visible + * @return True if the notch should be visible, false otherwise + */ + private fun getState(state: AppStateCallback.State?, showing: Boolean?): Boolean { + state?.let { + return if (it is AppStateCallback.State.Background) { + false + } else { + !(showing ?: false) + } + } + return false + } +} diff --git a/pluto/lib/src/main/java/com/pluto/core/notch/NotchViewManager.kt b/pluto/lib/src/main/java/com/pluto/core/notch/NotchViewManager.kt new file mode 100644 index 000000000..9176ae708 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/notch/NotchViewManager.kt @@ -0,0 +1,288 @@ +package com.pluto.core.notch + +import android.content.Context +import android.graphics.PixelFormat +import android.os.Build +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import com.pluto.R +import com.pluto.databinding.PlutoLayoutNotchBinding +import com.pluto.plugin.settings.SettingsPreferences +import com.pluto.utilities.device.Device +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.hapticFeedback +import com.pluto.utilities.soundFeedback +import kotlin.math.abs + +/** + * Manages the creation, display, and interaction of the notch view. + * + * This class is responsible for creating the notch view, handling touch events, + * managing its position on screen, and applying the appropriate styling based on + * the current theme settings. + * + * @property context The context used to create and style the view + * @property listener Listener for notch interaction events + */ +internal class NotchViewManager( + context: Context, + private val listener: OnNotchInteractionListener +) { + /** Device information used to calculate screen dimensions and limits */ + private val device = Device(context) + + /** Upper limit for vertical dragging of the notch */ + private val dragUpLimit = device.screen.heightPx * DRAG_UP_THRESHOLD + + /** Lower limit for vertical dragging of the notch */ + private val dragDownLimit = device.screen.heightPx * DRAG_DOWN_THRESHOLD + + /** The notch view instance */ + var view: View? = null + + /** Layout parameters for positioning the notch on screen */ + val layoutParams = getInitialLayoutParams(context) + + /** + * Initializes the notch view with touch listeners and styling. + * + * Sets up touch handling for click and drag operations, and configures + * the view's appearance based on the current theme settings. + * + * @param context The context used to access resources and settings + * @param view The notch view to initialize + */ + private fun initView(context: Context, view: View) { + /** + * Touch listener that handles click and drag operations on the notch. + * + * Detects: + * - Click events (ACTION_DOWN followed by ACTION_UP without movement) + * - Drag events (ACTION_MOVE) to reposition the notch vertically + */ + view.setOnTouchListener(object : View.OnTouchListener { + /** Tracks the last motion event action to detect clicks */ + private var lastAction = 0 + + /** Initial X position of the notch before dragging */ + private var initialX = 0 + + /** Initial Y position of the notch before dragging */ + private var initialY = 0 + + /** Initial X touch position when dragging starts */ + private var initialTouchX = 0f + + /** Initial Y touch position when dragging starts */ + private var initialTouchY = 0f + + /** + * Handles touch events on the notch view. + * + * @param v The view being touched + * @param event The motion event + * @return True if the event was handled, false otherwise + */ + override fun onTouch(v: View?, event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + // remember the initial position. + initialX = layoutParams.x + initialY = layoutParams.y + // get the touch location + initialTouchX = event.rawX + initialTouchY = event.rawY + lastAction = event.action + return true + } + + MotionEvent.ACTION_UP -> { + if (lastAction == MotionEvent.ACTION_DOWN) { + view.hapticFeedback(true) + view.soundFeedback() + listener.onClick() + } + lastAction = event.action + return true + } + + MotionEvent.ACTION_MOVE -> { + val movementX = event.rawX - initialTouchX + val movementY = event.rawY - initialTouchY + + if (abs(movementX) > 1 || abs(movementY) > 1) { +// layoutParams.x = initialX + movementX.toInt() + val currentY = initialY + (event.rawY - initialTouchY).toInt() + if (currentY > dragUpLimit && currentY < dragDownLimit) { + layoutParams.y = currentY + + listener.onLayoutParamsUpdated(layoutParams) + lastAction = event.action + return true + } + return false + } + return false + } + } + return false + } + }) + + /** + * Listener that applies styling and positioning when the view is attached to the window. + * Configures colors based on the current theme settings and positions the notch + * according to user preferences. + */ + view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + /** + * Called when the view is attached to the window. + * Applies theme-specific styling and positioning. + * + * @param v The attached view + */ + override fun onViewAttachedToWindow(v: View) { + PlutoLayoutNotchBinding.bind(v).apply { + card.setCardBackgroundColor( + context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___notch_bg_dark + } else { + com.pluto.plugin.R.color.pluto___notch_bg_light + } + ) + ) + left.setTextColor( + context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___white_80 + } else { + com.pluto.plugin.R.color.pluto___text_dark_80 + } + ) + ) + right.setTextColor( + context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___white_80 + } else { + com.pluto.plugin.R.color.pluto___text_dark_80 + } + ) + ) + bottom.setBackgroundColor( + context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___notch_accent_dark + } else { + com.pluto.plugin.R.color.pluto___notch_accent_light + } + ) + ) + } + val gravityHorizontal = + if (SettingsPreferences.isRightHandedAccessPopup) Gravity.END else Gravity.START + layoutParams.gravity = gravityHorizontal or Gravity.TOP + listener.onLayoutParamsUpdated(layoutParams) + } + + /** Called when the view is detached from the window. Not used in this implementation. */ + override fun onViewDetachedFromWindow(v: View) { + } + }) + } + + /** + * Creates and configures the initial layout parameters for the notch view. + * + * Sets up the window type, flags, gravity, and initial position based on + * device characteristics and user preferences. + * + * @param context The context used to access resources and settings + * @return The configured WindowManager.LayoutParams + */ + private fun getInitialLayoutParams(context: Context): WindowManager.LayoutParams { + val params: WindowManager.LayoutParams + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + params = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_PHONE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH + or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT + ) + } else { + params = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH + or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT + ) + } + + val gravityHorizontal = + if (SettingsPreferences.isRightHandedAccessPopup) Gravity.END else Gravity.START + params.gravity = gravityHorizontal or Gravity.TOP + params.x = + (context.resources.getDimension(com.pluto.plugin.R.dimen.pluto___popup_bubble_width) * INIT_THRESHOLD_X).toInt() + params.y = (device.screen.heightPx * INIT_THRESHOLD_Y).toInt() + + return params + } + + /** + * Adds the notch view to the window if it doesn't already exist. + * + * Creates a new notch view, initializes it, and adds it to the window manager. + * + * @param context The context used to create the view + * @param windowManager The window manager to add the view to + */ + fun addView(context: Context, windowManager: WindowManager) { + if (view == null) { + view = context.inflate(R.layout.pluto___layout_notch) + view?.let { + initView(context, it) + if (it.parent == null) { + windowManager.addView(it, layoutParams) + } + } + } + } + + /** + * Removes the notch view from the window if it exists. + * + * @param windowManager The window manager to remove the view from + */ + fun removeView(windowManager: WindowManager) { + view?.parent?.let { + windowManager.removeView(view) + view = null + } + } + + companion object { + /** Threshold for the upper limit of vertical dragging (3% of screen height) */ + const val DRAG_UP_THRESHOLD = 0.03 + + /** Threshold for the lower limit of vertical dragging (90% of screen height) */ + const val DRAG_DOWN_THRESHOLD = 0.9 + + /** Initial horizontal position threshold (-55% of bubble width) */ + const val INIT_THRESHOLD_X = -0.55 + + /** Initial vertical position threshold (65% of screen height) */ + const val INIT_THRESHOLD_Y = 0.65 + } +} diff --git a/pluto/lib/src/main/java/com/pluto/core/notch/OnNotchInteractionListener.kt b/pluto/lib/src/main/java/com/pluto/core/notch/OnNotchInteractionListener.kt new file mode 100644 index 000000000..ba135d44d --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/notch/OnNotchInteractionListener.kt @@ -0,0 +1,29 @@ +package com.pluto.core.notch + +import android.view.WindowManager + +/** + * Interface for handling user interactions with the notch UI component. + * + * This interface defines callbacks for click events and layout parameter updates + * that occur when the user interacts with the notch. + */ +internal interface OnNotchInteractionListener { + /** + * Called when the notch is clicked. + * + * Implementations should handle the click event, typically by opening + * the Pluto debugging interface. + */ + fun onClick() + + /** + * Called when the notch's layout parameters are updated. + * + * This happens when the notch is moved to a new position on the screen. + * Implementations should update the notch's position in the window. + * + * @param params The updated window layout parameters + */ + fun onLayoutParamsUpdated(params: WindowManager.LayoutParams) +} diff --git a/pluto/lib/src/main/java/com/pluto/core/notification/DebugNotification.kt b/pluto/lib/src/main/java/com/pluto/core/notification/DebugNotification.kt new file mode 100644 index 000000000..d6f9a1e81 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/notification/DebugNotification.kt @@ -0,0 +1,115 @@ +package com.pluto.core.notification + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import com.pluto.R +import com.pluto.ui.selector.SelectorActivity +import com.pluto.utilities.device.Device + +/** + * Manages the debug notification shown in the notification drawer. + * + * This class handles creating, showing, and removing the notification that + * provides quick access to Pluto's debugging interface. It handles compatibility + * across different Android versions, including notification channels for Android O+. + * + * @property context The context used to create and manage notifications + */ +internal class DebugNotification(private val context: Context) { + + /** The system notification manager used to show and hide notifications */ + private val manager: NotificationManager? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + context.getSystemService(NotificationManager::class.java) + } else { + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? + } + + /** Device information used to get app name for the notification */ + private val device = Device(context) + + /** + * Creates and shows the debug notification. + * + * The notification includes the app name and a message indicating that + * Pluto is active. Clicking the notification opens the Pluto selector activity. + */ + fun add() { + val notificationIntent = Intent(context, SelectorActivity::class.java) + val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) + } else { + PendingIntent.getActivity(context, 0, notificationIntent, 0) + } + createChannel() + val notification: Notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentTitle(context.getString(R.string.pluto___notification_title, device.app.name)) + .setContentText(context.getString(R.string.pluto___notification_subtitle)) + .setSmallIcon(R.drawable.pluto___ic_launcher) + .setContentIntent(pendingIntent) + .setOngoing(false) + .setOnlyAlertOnce(true) + .setAutoCancel(false) + .setSilent(true) + .setSound(null) + .build() + manager?.notify(NOTIFICATION_ID, notification) + } + + /** + * Removes the debug notification from the notification drawer. + */ + fun remove() { + manager?.cancel(NOTIFICATION_ID) + } + + /** + * Creates the notification channel for Android O and above. + * + * This is required for notifications to appear on Android O+. + * For earlier versions, this method has no effect. + */ + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ) + channel.setShowBadge(false) + createNotificationChannel(channel) + } + } + + /** + * Creates a notification channel with the system notification manager. + * + * @param channel The notification channel to create + */ + private fun createNotificationChannel(channel: NotificationChannel) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager?.createNotificationChannel(channel) + } + } + + companion object { + /** Unique ID for the debug notification */ + const val NOTIFICATION_ID = 1011 + + /** ID for the notification channel */ + const val CHANNEL_ID = "pluto_notifications" + + /** ID for the notification group */ + const val GROUP_ID = "pluto_notifications_group" + + /** Human-readable name for the notification channel */ + const val CHANNEL_NAME = "Pluto Notifications" + } +} diff --git a/pluto/lib/src/main/java/com/pluto/core/notification/NotificationManager.kt b/pluto/lib/src/main/java/com/pluto/core/notification/NotificationManager.kt new file mode 100644 index 000000000..988ae5273 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/core/notification/NotificationManager.kt @@ -0,0 +1,37 @@ +package com.pluto.core.notification + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import com.pluto.core.applifecycle.AppStateCallback + +/** + * Manages debug notifications for Pluto. + * + * This class observes application state changes and shows or hides + * the debug notification accordingly. The notification is shown when + * the app is in the foreground and hidden when it's in the background. + * + * @param application The application instance used for context + * @param state LiveData that emits application state changes + */ +@SuppressWarnings("UseDataClass") +internal class NotificationManager(application: Application, state: MutableLiveData) { + + /** The debug notification that will be shown in the notification drawer */ + private val debugNotification = DebugNotification(application.applicationContext) + + /** + * Initializes the notification manager by observing app state changes. + * Shows the notification when the app is in the foreground and + * hides it when the app is in the background. + */ + init { + state.observeForever { + if (it is AppStateCallback.State.Foreground) { + debugNotification.add() + } else { + debugNotification.remove() + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/maven/DataModels.kt b/pluto/lib/src/main/java/com/pluto/maven/DataModels.kt new file mode 100644 index 000000000..cf38c1024 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/maven/DataModels.kt @@ -0,0 +1,20 @@ +package com.pluto.maven + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class MavenData( + val response: MavenResponse +) + +@JsonClass(generateAdapter = true) +internal data class MavenResponse( + val numFound: Int, + val docs: List +) + +@JsonClass(generateAdapter = true) +internal data class MavenArtifactDetails( + val id: String, + val latestVersion: String +) diff --git a/pluto/lib/src/main/java/com/pluto/maven/MavenApiService.kt b/pluto/lib/src/main/java/com/pluto/maven/MavenApiService.kt new file mode 100644 index 000000000..41455cff5 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/maven/MavenApiService.kt @@ -0,0 +1,9 @@ +package com.pluto.maven + +import retrofit2.http.GET + +internal interface MavenApiService { + + @GET("https://search.maven.org/solrsearch/select?q=g:com.androidpluto+AND+a:pluto") + suspend fun getLatestVersion(): MavenData +} diff --git a/pluto/lib/src/main/java/com/pluto/maven/MavenSession.kt b/pluto/lib/src/main/java/com/pluto/maven/MavenSession.kt new file mode 100644 index 000000000..0b9ec1eac --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/maven/MavenSession.kt @@ -0,0 +1,8 @@ +package com.pluto.maven + +internal object MavenSession { + var alreadySynced: Boolean = false + var latestVersion: String? = null + val releaseUrl: String? + get() = latestVersion?.let { "https://github.com/androidPluto/pluto/releases/tag/v$it" } ?: run { null } +} diff --git a/pluto/lib/src/main/java/com/pluto/maven/MavenViewModel.kt b/pluto/lib/src/main/java/com/pluto/maven/MavenViewModel.kt new file mode 100644 index 000000000..4b681cf2b --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/maven/MavenViewModel.kt @@ -0,0 +1,42 @@ +package com.pluto.maven + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pluto.BuildConfig +import com.pluto.core.network.Network +import com.pluto.core.network.ResponseWrapper +import com.pluto.core.network.enqueue +import com.pluto.utilities.SingleLiveEvent +import kotlinx.coroutines.launch + +class MavenViewModel : ViewModel() { + + private val apiService: MavenApiService by Network.getService() + val latestVersion: LiveData + get() = _latestVersion + private val _latestVersion = SingleLiveEvent() + + fun getLatestVersion() { + viewModelScope.launch { + if (!MavenSession.alreadySynced) { + MavenSession.latestVersion = when (val auth = enqueue { apiService.getLatestVersion() }) { + is ResponseWrapper.Success -> retrieveLatestResponse(auth.body.response) + is ResponseWrapper.Failure -> null + } + MavenSession.alreadySynced = true + } + MavenSession.latestVersion?.let { + _latestVersion.postValue(it) + } + } + } + + private fun retrieveLatestResponse(response: MavenResponse): String? { + return if (response.docs.isNotEmpty() && BuildConfig.VERSION_NAME != response.docs[0].latestVersion) { + response.docs[0].latestVersion + } else { + null + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/plugin/PluginManager.kt b/pluto/lib/src/main/java/com/pluto/plugin/PluginManager.kt new file mode 100644 index 000000000..e111bcde9 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/plugin/PluginManager.kt @@ -0,0 +1,108 @@ +package com.pluto.plugin + +import android.app.Application +import com.pluto.plugin.libinterface.PlutoInterface +import com.pluto.ui.container.PlutoActivity +import com.pluto.ui.selector.SelectorActivity + +/** + * Manages the installation and interaction with Pluto plugins. + * + * This class is responsible for installing plugins, retrieving plugins by identifier, + * and clearing plugin logs. It maintains a registry of all installed plugins and + * plugin groups. + * + * @property application The application instance used for plugin installation + */ +internal class PluginManager(private val application: Application) { + + /** Set of all installed plugins and plugin groups */ + private var plugins: LinkedHashSet = linkedSetOf() + + /** + * List of all installed plugins and plugin groups. + * + * This property returns a copy of the internal plugins set as a list, + * ensuring that the original set cannot be modified externally. + */ + internal val installedPlugins: List + get() { + val list = arrayListOf() + list.addAll(plugins) + return list + } + + /** + * Initializes the plugin manager by creating the Pluto interface. + * + * The Pluto interface provides a bridge between plugins and the main Pluto library. + */ + init { + PlutoInterface.create( + application = application, + pluginActivityClass = PlutoActivity::class.java, + selectorActivityClass = SelectorActivity::class.java + ) + } + + /** + * Installs a set of plugins or plugin groups. + * + * Each plugin or plugin group is installed by calling its install method + * and then added to the internal registry of plugins. + * + * @param plugins The set of plugins or plugin groups to install + */ + fun install(plugins: LinkedHashSet) { + plugins.forEach { + it.install(application) + this.plugins.add(it) + } + } + + /** + * Retrieves a plugin by its identifier. + * + * This method searches through all installed plugins and plugin groups + * to find a plugin with the specified identifier. + * + * @param identifier The unique identifier of the plugin to retrieve + * @return The plugin with the specified identifier, or null if not found + */ + fun get(identifier: String): Plugin? { + plugins.forEach { + when (it) { + is Plugin -> if (it.identifier == identifier) return it + is PluginGroup -> return it.installedPlugins.firstOrNull { plugin -> plugin.identifier == identifier } + } + } + return null + } + + /** + * Clears logs for a specific plugin or all plugins. + * + * If an identifier is provided, only the logs for that plugin are cleared. + * If no identifier is provided, logs for all plugins are cleared. + * + * @param identifier The identifier of the plugin to clear logs for, or null to clear all logs + */ + fun clearLogs(identifier: String? = null) { + identifier?.let { get(identifier)?.onPluginDataCleared() } ?: run { clearAllLogs() } + } + + /** + * Clears logs for all installed plugins and plugin groups. + * + * This method iterates through all installed plugins and plugin groups + * and calls their onPluginDataCleared method. + */ + private fun clearAllLogs() { + installedPlugins.forEach { + when (it) { + is Plugin -> it.onPluginDataCleared() + is PluginGroup -> it.installedPlugins.forEach { plugin -> plugin.onPluginDataCleared() } + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/plugin/PluginsViewModel.kt b/pluto/lib/src/main/java/com/pluto/plugin/PluginsViewModel.kt new file mode 100644 index 000000000..0ba2aecb0 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/plugin/PluginsViewModel.kt @@ -0,0 +1,18 @@ +package com.pluto.plugin + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.pluto.Pluto + +internal class PluginsViewModel(application: Application) : AndroidViewModel(application) { + + val plugins: LiveData> + get() = _plugins + private val _plugins = MutableLiveData>() + + init { + _plugins.postValue(Pluto.pluginManager.installedPlugins) + } +} diff --git a/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginAdapter.kt b/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginAdapter.kt new file mode 100644 index 000000000..c99ea6b79 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginAdapter.kt @@ -0,0 +1,32 @@ +package com.pluto.plugin.selector + +import android.view.ViewGroup +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginGroup +import com.pluto.ui.ListWrapper +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class PluginAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when { + item is ListWrapper<*> && item.get() is Plugin -> ITEM_TYPE_PLUGIN + item is ListWrapper<*> && item.get() is PluginGroup -> ITEM_TYPE_PLUGIN_GROUP + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_PLUGIN -> PluginItemHolder(parent, listener) + ITEM_TYPE_PLUGIN_GROUP -> PluginGroupItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_PLUGIN = 1000 + const val ITEM_TYPE_PLUGIN_GROUP = 1001 + } +} diff --git a/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginGroupAdapter.kt b/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginGroupAdapter.kt new file mode 100644 index 000000000..837a76b83 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginGroupAdapter.kt @@ -0,0 +1,28 @@ +package com.pluto.plugin.selector + +import android.view.ViewGroup +import com.pluto.plugin.Plugin +import com.pluto.ui.ListWrapper +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class PluginGroupAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when { + item is ListWrapper<*> && item.get() is Plugin -> ITEM_TYPE_PLUGIN + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_PLUGIN -> PluginGroupChildItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_PLUGIN = 1000 + } +} diff --git a/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginGroupChildItemHolder.kt b/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginGroupChildItemHolder.kt new file mode 100644 index 000000000..e63b2563f --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginGroupChildItemHolder.kt @@ -0,0 +1,36 @@ +package com.pluto.plugin.selector + +import android.view.ViewGroup +import com.pluto.R +import com.pluto.databinding.PlutoItemPluginGroupChildBinding +import com.pluto.plugin.Plugin +import com.pluto.ui.ListWrapper +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class PluginGroupChildItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto___item_plugin_group_child), actionListener) { + + private val binding = PlutoItemPluginGroupChildBinding.bind(itemView) + private val name = binding.name + private val icon = binding.icon + + override fun onBind(item: ListItem) { + if (item is ListWrapper<*> && item.get() is Plugin) { + val plugin: Plugin = item.get() as Plugin + icon.setImageResource(plugin.getConfig().icon) + name.text = plugin.getConfig().name + + binding.root.setOnDebounceClickListener { + onAction("click") + } + binding.root.setOnLongClickListener { + onAction("long_click") + return@setOnLongClickListener true + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginGroupItemHolder.kt b/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginGroupItemHolder.kt new file mode 100644 index 000000000..0d90f2ba3 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginGroupItemHolder.kt @@ -0,0 +1,31 @@ +package com.pluto.plugin.selector + +import android.view.ViewGroup +import com.pluto.R +import com.pluto.databinding.PlutoItemPluginGroupBinding +import com.pluto.plugin.PluginGroup +import com.pluto.ui.ListWrapper +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class PluginGroupItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto___item_plugin_group), actionListener) { + + private val binding = PlutoItemPluginGroupBinding.bind(itemView) + private val name = binding.name + private val icon = binding.icon + + override fun onBind(item: ListItem) { + if (item is ListWrapper<*> && item.get() is PluginGroup) { + val group = item.get() as PluginGroup + icon.setImageResource(group.getConfig().icon) + name.text = group.getConfig().name + binding.root.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginItemHolder.kt b/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginItemHolder.kt new file mode 100644 index 000000000..df08db358 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/plugin/selector/PluginItemHolder.kt @@ -0,0 +1,50 @@ +package com.pluto.plugin.selector + +import android.view.ViewGroup +import android.view.animation.OvershootInterpolator +import com.pluto.R +import com.pluto.databinding.PlutoItemPluginBinding +import com.pluto.plugin.Plugin +import com.pluto.ui.ListWrapper +import com.pluto.ui.selector.SelectorActivity.Companion.ANIMATION_DURATION +import com.pluto.ui.selector.loadAnimation +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.extensions.setListener +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class PluginItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto___item_plugin), actionListener) { + + private val binding = PlutoItemPluginBinding.bind(itemView) + private val name = binding.name + private val icon = binding.icon + + override fun onBind(item: ListItem) { + if (item is ListWrapper<*> && item.get() is Plugin) { + val plugin: Plugin = item.get() as Plugin + icon.setImageResource(plugin.getConfig().icon) + name.text = plugin.getConfig().name + binding.root.setOnDebounceClickListener(haptic = true) { + val scale = context.loadAnimation(R.anim.pluto___click_bounce) + scale.duration = ANIMATION_DURATION + scale.interpolator = OvershootInterpolator() + scale.setListener { + onAnimationStart { + } + onAnimationEnd { + onAction("click") + } + } + it.startAnimation(scale) + } + + binding.root.setOnLongClickListener { + onAction("long_click") + return@setOnLongClickListener true + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/settings/DataModel.kt b/pluto/lib/src/main/java/com/pluto/settings/DataModel.kt new file mode 100644 index 000000000..f7780551c --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/DataModel.kt @@ -0,0 +1,53 @@ +package com.pluto.settings + +import com.pluto.utilities.list.ListItem + +/** + * Data model representing the Easy Access setting item in the settings list. + * This entity is used to display and manage the easy access overlay permission setting. + * + * @property label The identifier label for this setting, defaults to "easy_access" + */ +internal data class SettingsEasyAccessEntity( + val label: String = "easy_access" +) : ListItem() + +/** + * Data model representing the Easy Access Popup Appearance setting item in the settings list. + * This entity is used to display and manage the appearance of the easy access popup. + * + * @property type The type of appearance setting, used to determine the specific appearance option + */ +internal data class SettingsEasyAccessPopupAppearanceEntity( + val type: String +) : ListItem() + +/** + * Data model representing the Theme setting item in the settings list. + * This entity is used to display and manage the theme setting (light/dark). + * + * @property label The identifier label for this setting, defaults to "theme" + */ +internal data class SettingsThemeEntity( + val label: String = "theme" +) : ListItem() + +/** + * Data model representing the Grid Size setting item in the settings list. + * This entity is used to display and manage the grid size setting. + * + * @property label The identifier label for this setting, defaults to "grid" + */ +internal data class SettingsGridSizeEntity( + val label: String = "grid" +) : ListItem() + +/** + * Data model representing the Reset All setting item in the settings list. + * This entity is used to display and manage the reset all settings option. + * + * @property type The type identifier for this setting, defaults to "rest all" + */ +internal data class SettingsResetAllEntity( + val type: String = "rest all" +) : ListItem() diff --git a/pluto/lib/src/main/java/com/pluto/settings/OverConsentFragment.kt b/pluto/lib/src/main/java/com/pluto/settings/OverConsentFragment.kt new file mode 100644 index 000000000..0f5c313f4 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/OverConsentFragment.kt @@ -0,0 +1,52 @@ +package com.pluto.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.Pluto +import com.pluto.R +import com.pluto.databinding.PlutoFragmentOverlayConsentBinding +import com.pluto.utilities.extensions.openOverlaySettings +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.viewBinding + +/** + * Fragment that displays a consent dialog for enabling overlay permissions. + * + * This fragment is shown to the user to request permission to draw over other apps, + * which is required for certain Pluto features like the floating debug tools. + * It presents information about why the permission is needed and provides a button + * to navigate to the system settings screen where the user can grant the permission. + */ +internal class OverConsentFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoFragmentOverlayConsentBinding::bind) + + /** + * Creates and returns the view hierarchy associated with the fragment. + */ + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto___fragment_overlay_consent, container, false) + + /** + * Returns the theme to be used for this fragment. + */ + override fun getTheme(): Int = R.style.PlutoBottomSheetDialogTheme + + /** + * Called immediately after onCreateView() has returned, but before any saved state has been restored. + * This is where most initialization should go. + * + * Sets up the click listener for the call-to-action button that opens the system overlay settings. + * Also marks that the consent dialog has been shown to prevent showing it again unnecessarily. + */ + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Pluto.session.isConsentAlreadyShown = true + binding.cta.setOnDebounceClickListener { + context?.openOverlaySettings() + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/settings/ResetDataCallback.kt b/pluto/lib/src/main/java/com/pluto/settings/ResetDataCallback.kt new file mode 100644 index 000000000..4e37db5cb --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/ResetDataCallback.kt @@ -0,0 +1,17 @@ +package com.pluto.settings + +import com.pluto.utilities.SingleLiveEvent + +/** + * Callback class for handling data reset operations in the settings module. + * + * This class provides a mechanism to notify observers when a data reset operation + * is triggered. It uses a SingleLiveEvent to ensure the reset event is only handled once. + */ +internal class ResetDataCallback { + /** + * A SingleLiveEvent that represents the state of the reset operation. + * When set to true, it indicates that a reset operation has been requested. + */ + val state: SingleLiveEvent = SingleLiveEvent() +} diff --git a/pluto/lib/src/main/java/com/pluto/settings/SettingsAdapter.kt b/pluto/lib/src/main/java/com/pluto/settings/SettingsAdapter.kt new file mode 100644 index 000000000..4598d9d96 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/SettingsAdapter.kt @@ -0,0 +1,75 @@ +package com.pluto.settings + +import android.view.ViewGroup +import com.pluto.settings.holders.SettingsEasyAccessHolder +import com.pluto.settings.holders.SettingsEasyAccessPopupAppearanceHolder +import com.pluto.settings.holders.SettingsGridSizeHolder +import com.pluto.settings.holders.SettingsResetAllHolder +import com.pluto.settings.holders.SettingsThemeHolder +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +/** + * Adapter for the settings list that handles different types of setting items. + * + * This adapter is responsible for creating the appropriate view holders for each type of setting item + * and binding them to the corresponding data. It supports various setting types including easy access, + * appearance, theme, grid size, and reset all options. + * + * @param listener The action listener that will handle interactions with the settings items + */ +internal class SettingsAdapter(private val listener: OnActionListener) : BaseAdapter() { + /** + * Determines the view type for a given list item. + * This is used to create the appropriate view holder for each type of setting. + * + * @param item The list item to determine the view type for + * @return The integer view type code, or null if the item type is not supported + */ + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is SettingsEasyAccessEntity -> ITEM_TYPE_EASY_ACCESS + is SettingsEasyAccessPopupAppearanceEntity -> ITEM_TYPE_EASY_ACCESS_APPEARANCE + is SettingsThemeEntity -> ITEM_TYPE_THEME + is SettingsGridSizeEntity -> ITEM_TYPE_GRID_SIZE + is SettingsResetAllEntity -> ITEM_TYPE_RESET_ALL + else -> null + } + } + + /** + * Creates the appropriate view holder for the given view type. + * + * @param parent The parent view group that will contain the view holder + * @param viewType The view type code determined by getItemViewType + * @return The created view holder, or null if the view type is not supported + */ + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_EASY_ACCESS -> SettingsEasyAccessHolder(parent, listener) + ITEM_TYPE_EASY_ACCESS_APPEARANCE -> SettingsEasyAccessPopupAppearanceHolder(parent, listener) + ITEM_TYPE_THEME -> SettingsThemeHolder(parent, listener) + ITEM_TYPE_GRID_SIZE -> SettingsGridSizeHolder(parent, listener) + ITEM_TYPE_RESET_ALL -> SettingsResetAllHolder(parent, listener) + else -> null + } + } + + companion object { + /** View type constant for the easy access setting item */ + const val ITEM_TYPE_EASY_ACCESS = 1000 + + /** View type constant for the easy access popup appearance setting item */ + const val ITEM_TYPE_EASY_ACCESS_APPEARANCE = 1001 + + /** View type constant for the theme setting item */ + const val ITEM_TYPE_THEME = 1002 + + /** View type constant for the grid size setting item */ + const val ITEM_TYPE_GRID_SIZE = 1003 + + /** View type constant for the reset all setting item */ + const val ITEM_TYPE_RESET_ALL = 1004 + } +} diff --git a/pluto/lib/src/main/java/com/pluto/settings/SettingsFragment.kt b/pluto/lib/src/main/java/com/pluto/settings/SettingsFragment.kt new file mode 100644 index 000000000..fe3294f81 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/SettingsFragment.kt @@ -0,0 +1,131 @@ +package com.pluto.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.BuildConfig +import com.pluto.R +import com.pluto.databinding.PlutoFragmentSettingsBinding +import com.pluto.plugin.settings.SettingsPreferences +import com.pluto.settings.holders.SettingsGridSizeHolder.Companion.DEC_SIZE +import com.pluto.settings.holders.SettingsGridSizeHolder.Companion.INC_SIZE +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.openOverlaySettings +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.viewBinding + +/** + * Fragment that displays the settings UI as a bottom sheet dialog. + * + * This fragment presents various Pluto settings options to the user, including: + * - Easy access overlay permission + * - Easy access popup appearance + * - Theme selection (light/dark) + * - Grid size configuration + * - Reset all settings option + */ +internal class SettingsFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoFragmentSettingsBinding::bind) + private val settingsAdapter: BaseAdapter by autoClearInitializer { SettingsAdapter(onActionListener) } + private val viewModel: SettingsViewModel by activityViewModels() + + /** + * Creates and returns the view hierarchy associated with the fragment. + */ + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto___fragment_settings, container, false) + + /** + * Returns the theme to be used for this fragment. + */ + override fun getTheme(): Int = R.style.PlutoBottomSheetDialogTheme + + /** + * Called immediately after onCreateView() has returned, but before any saved state has been restored. + * This is where most initialization should go. + */ + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.list.apply { + adapter = settingsAdapter + addItemDecoration(CustomItemDecorator(context, DECORATOR_DIVIDER_PADDING)) + } + viewModel.list.removeObserver(settingsObserver) + viewModel.list.observe(viewLifecycleOwner, settingsObserver) + } + + /** + * Observer that updates the settings adapter when the list of settings items changes. + */ + private val settingsObserver = Observer> { + settingsAdapter.list = it + } + + /** + * Listener that handles actions performed on settings items. + * This includes toggling settings, adjusting values, and triggering the reset operation. + */ + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + when (data) { + is SettingsEasyAccessEntity -> { + context?.openOverlaySettings() + requireActivity().finish() + } + + is SettingsEasyAccessPopupAppearanceEntity -> { + when (data.type) { + "handed" -> { + val current = SettingsPreferences.isRightHandedAccessPopup + SettingsPreferences.isRightHandedAccessPopup = !current + context?.toast(context!!.getString(R.string.pluto___notch_settings_updated)) + } + + else -> check(!BuildConfig.DEBUG) { + "unsupported appearance type" + } + } + settingsAdapter.notifyItemChanged(holder.layoutPosition) + } + + is SettingsGridSizeEntity -> { + when (action) { + INC_SIZE -> SettingsPreferences.gridSize++ + DEC_SIZE -> SettingsPreferences.gridSize-- + } + settingsAdapter.notifyItemChanged(holder.layoutPosition) + } + + is SettingsThemeEntity -> { + val current = SettingsPreferences.isDarkThemeEnabled + SettingsPreferences.isDarkThemeEnabled = !current + context?.toast(context!!.getString(R.string.pluto___setting_theme_updated)) + settingsAdapter.notifyItemChanged(holder.layoutPosition) + } + + is SettingsResetAllEntity -> { + viewModel.resetAll() + context?.toast(context!!.getString(R.string.pluto___reset_all_requested)) + } + } + } + } + + private companion object { + /** + * Padding value for the divider between settings items in the list. + */ + val DECORATOR_DIVIDER_PADDING = 16f.dp.toInt() + } +} diff --git a/pluto/lib/src/main/java/com/pluto/settings/SettingsViewModel.kt b/pluto/lib/src/main/java/com/pluto/settings/SettingsViewModel.kt new file mode 100644 index 000000000..99a0ab3ef --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/SettingsViewModel.kt @@ -0,0 +1,68 @@ +package com.pluto.settings + +import android.app.Application +import android.content.Context +import android.os.Build +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.pluto.utilities.SingleLiveEvent +import com.pluto.utilities.list.ListItem + +/** + * ViewModel for the Settings screen that manages the list of setting options and handles reset operations. + * + * This ViewModel is responsible for generating the list of settings items to be displayed + * in the settings UI and handling the reset all settings operation. + * + * @param application The application instance used to access application context + */ +internal class SettingsViewModel(application: Application) : AndroidViewModel(application) { + + /** + * LiveData containing the list of setting items to be displayed in the UI. + * This is exposed as a read-only LiveData to prevent modification from outside. + */ + val list: LiveData> + get() = _list + private val _list = MutableLiveData>() + + /** + * SingleLiveEvent that signals when all settings should be reset. + * Using SingleLiveEvent ensures the reset event is only handled once. + */ + val resetAll = SingleLiveEvent() + + init { + generate(getApplication()) + } + + /** + * Generates the list of settings items based on device capabilities and requirements. + * + * @param context The context used to access resources and system information + */ + private fun generate(context: Context?) { + context?.apply { + val list = arrayListOf() + + val isOSAboveM = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + if (isOSAboveM) { + list.add(SettingsEasyAccessEntity()) + } + list.add(SettingsEasyAccessPopupAppearanceEntity("handed")) + list.add(SettingsThemeEntity()) + list.add(SettingsGridSizeEntity()) + list.add(SettingsResetAllEntity()) + _list.postValue(list) + } + } + + /** + * Triggers a reset of all settings by posting a true value to the resetAll SingleLiveEvent. + * This will notify all observers that a reset operation has been requested. + */ + fun resetAll() { + resetAll.postValue(true) + } +} diff --git a/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsEasyAccessHolder.kt b/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsEasyAccessHolder.kt new file mode 100644 index 000000000..756f7c509 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsEasyAccessHolder.kt @@ -0,0 +1,28 @@ +package com.pluto.settings.holders + +import android.view.ViewGroup +import com.pluto.R +import com.pluto.databinding.PlutoItemSettingsEasyAccessBinding +import com.pluto.settings.SettingsEasyAccessEntity +import com.pluto.utilities.extensions.canDrawOverlays +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class SettingsEasyAccessHolder(parent: ViewGroup, listener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_easy_access), listener) { + + private val binding = PlutoItemSettingsEasyAccessBinding.bind(itemView) + private val checkbox = binding.checkbox + + override fun onBind(item: ListItem) { + if (item is SettingsEasyAccessEntity) { + checkbox.isSelected = itemView.context.canDrawOverlays() + itemView.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsEasyAccessPopupAppearanceHolder.kt b/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsEasyAccessPopupAppearanceHolder.kt new file mode 100644 index 000000000..2e2c8c512 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsEasyAccessPopupAppearanceHolder.kt @@ -0,0 +1,47 @@ +package com.pluto.settings.holders + +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import com.pluto.R +import com.pluto.databinding.PlutoItemSettingsEasyAccessAppearanceBinding +import com.pluto.plugin.settings.SettingsPreferences +import com.pluto.settings.SettingsEasyAccessPopupAppearanceEntity +import com.pluto.utilities.extensions.canDrawOverlays +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class SettingsEasyAccessPopupAppearanceHolder(parent: ViewGroup, listener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_easy_access_appearance), listener) { + + private val binding = PlutoItemSettingsEasyAccessAppearanceBinding.bind(itemView) + private val title = binding.title + private val checkbox = binding.checkbox + private val disableOverlay = binding.disableOverlay + + override fun onBind(item: ListItem) { + if (item is SettingsEasyAccessPopupAppearanceEntity) { + disableOverlay.visibility = if (itemView.context.canDrawOverlays()) GONE else VISIBLE + title.text = context.getString( + when (item.type) { + "handed" -> R.string.pluto___settings_easy_access_appearance_handed_title + else -> error("unsupported appearance type") + } + ) + checkbox.isSelected = + when (item.type) { + "handed" -> SettingsPreferences.isRightHandedAccessPopup + else -> error("unsupported appearance type") + } + + if (itemView.context.canDrawOverlays()) { + itemView.setOnDebounceClickListener { + onAction("click") + } + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsGridSizeHolder.kt b/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsGridSizeHolder.kt new file mode 100644 index 000000000..c00384f70 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsGridSizeHolder.kt @@ -0,0 +1,73 @@ +package com.pluto.settings.holders + +import android.content.res.ColorStateList +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.widget.ImageViewCompat +import com.pluto.R +import com.pluto.databinding.PlutoItemSettingsGridSizeBinding +import com.pluto.plugin.settings.SettingsPreferences +import com.pluto.settings.SettingsGridSizeEntity +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class SettingsGridSizeHolder(parent: ViewGroup, listener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_grid_size), listener) { + + private val binding = PlutoItemSettingsGridSizeBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is SettingsGridSizeEntity) { + if (SettingsPreferences.gridSize > SMALLEST_GRID_DIMEN_ALLOWED) { + binding.decSizeCta.setOnDebounceClickListener { + onAction(DEC_SIZE) + } + ImageViewCompat.setImageTintList( + binding.decSizeCta, + ColorStateList.valueOf( + ContextCompat.getColor(context, com.pluto.plugin.R.color.pluto___dark_40) + ) + ) + } else { + binding.decSizeCta.setOnDebounceClickListener(action = null) + ImageViewCompat.setImageTintList( + binding.decSizeCta, + ColorStateList.valueOf( + ContextCompat.getColor(context, com.pluto.plugin.R.color.pluto___dark_10) + ) + ) + } + if (SettingsPreferences.gridSize < LARGEST_GRID_DIMEN_ALLOWED) { + binding.incSizeCta.setOnDebounceClickListener { + onAction(INC_SIZE) + } + ImageViewCompat.setImageTintList( + binding.incSizeCta, + ColorStateList.valueOf( + ContextCompat.getColor(context, com.pluto.plugin.R.color.pluto___dark_40) + ) + ) + } else { + binding.incSizeCta.setOnDebounceClickListener(action = null) + ImageViewCompat.setImageTintList( + binding.incSizeCta, + ColorStateList.valueOf( + ContextCompat.getColor(context, com.pluto.plugin.R.color.pluto___dark_10) + ) + ) + } + + binding.sizeValue.text = "${SettingsPreferences.gridSize} dp" + } + } + + companion object { + const val INC_SIZE = "inc_size" + const val DEC_SIZE = "dec_size" + const val SMALLEST_GRID_DIMEN_ALLOWED = 4 + const val LARGEST_GRID_DIMEN_ALLOWED = 20 + } +} diff --git a/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsResetAllHolder.kt b/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsResetAllHolder.kt new file mode 100644 index 000000000..bdd53544b --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsResetAllHolder.kt @@ -0,0 +1,25 @@ +package com.pluto.settings.holders + +import android.view.ViewGroup +import com.pluto.R +import com.pluto.databinding.PlutoItemSettingsResetAllBinding +import com.pluto.settings.SettingsResetAllEntity +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class SettingsResetAllHolder(parent: ViewGroup, listener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_reset_all), listener) { + + private val binding = PlutoItemSettingsResetAllBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is SettingsResetAllEntity) { + binding.root.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsThemeHolder.kt b/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsThemeHolder.kt new file mode 100644 index 000000000..ebae29aa0 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/settings/holders/SettingsThemeHolder.kt @@ -0,0 +1,43 @@ +package com.pluto.settings.holders + +import android.view.ViewGroup +import com.pluto.R +import com.pluto.databinding.PlutoItemSettingsThemeBinding +import com.pluto.plugin.settings.SettingsPreferences +import com.pluto.settings.SettingsThemeEntity +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +/** + * ViewHolder for the theme setting item in the settings list. + * + * This holder displays a checkbox that indicates whether dark theme is enabled or disabled, + * and allows the user to toggle between light and dark themes by clicking on the item. + * + * @param parent The parent ViewGroup that this holder will be attached to + * @param listener The action listener that will handle click events on this item + */ +internal class SettingsThemeHolder(parent: ViewGroup, listener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_theme), listener) { + + private val binding = PlutoItemSettingsThemeBinding.bind(itemView) + private val checkbox = binding.checkbox + + /** + * Binds the view holder to the provided list item. + * Updates the checkbox state based on the current theme setting and sets up the click listener. + * + * @param item The list item to bind to this holder, expected to be a SettingsThemeEntity + */ + override fun onBind(item: ListItem) { + if (item is SettingsThemeEntity) { + checkbox.isSelected = SettingsPreferences.isDarkThemeEnabled + itemView.setOnDebounceClickListener { + onAction("click") + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/PlutoTool.kt b/pluto/lib/src/main/java/com/pluto/tool/PlutoTool.kt new file mode 100644 index 000000000..927408e01 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/PlutoTool.kt @@ -0,0 +1,99 @@ +package com.pluto.tool + +import android.app.Application +import com.pluto.utilities.list.ListItem + +/** + * Base class for all Pluto tools. + * + * This abstract class provides the foundation for creating debugging tools in Pluto. + * Tools are utility features that can be activated directly from the UI, such as + * rulers, grids, and screen information displays. + * + * To create a new tool, extend this class and implement the required abstract methods. + * + * @property id A unique string identifier for the tool + */ +internal abstract class PlutoTool(val id: String) : ListItem() { + + /** + * Returns the tool configuration. + * + * This method should provide a ToolConfiguration object that defines + * the tool's name and icon. + * + * @return The tool configuration + */ + abstract fun getConfig(): ToolConfiguration + + /** + * Called when the tool is initialized. + * + * This method is called during the tool initialization process. + * It should be used to set up any resources needed by the tool. + */ + abstract fun onToolInitialised() + + /** + * Called when the tool is selected by the user. + * + * This method is called when the user activates the tool. + * It should be used to show the tool's UI or start its functionality. + */ + abstract fun onToolSelected() + + /** + * Called when the tool is unselected by the user. + * + * This method is called when the user deactivates the tool or selects another tool. + * It should be used to hide the tool's UI or stop its functionality. + */ + abstract fun onToolUnselected() + + /** + * Determines whether the tool is enabled. + * + * This method should return true if the tool is available for use, + * or false if it is disabled. + * + * @return True if the tool is enabled, false otherwise + */ + abstract fun isEnabled(): Boolean + + /** + * The application instance. + * + * This property provides access to the application instance for the tool. + * It throws an IllegalStateException if accessed before the tool is initialized. + */ + val application: Application + get() = returnApplication() + + /** The internal application instance, set during initialization */ + private var _application: Application? = null + + /** + * Returns the application instance. + * + * @throws IllegalStateException if the tool is not initialized + * @return The application instance + */ + private fun returnApplication(): Application { + _application?.let { + return it + } + throw IllegalStateException("${this.javaClass.name} plugin is not installed yet.") + } + + /** + * Initializes the tool with the provided application instance. + * + * This method sets the application instance and calls onToolInitialised(). + * + * @param application The application instance to use for initialization + */ + fun initialise(application: Application) { + this._application = application + onToolInitialised() + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/ToolConfiguration.kt b/pluto/lib/src/main/java/com/pluto/tool/ToolConfiguration.kt new file mode 100644 index 000000000..6c3386017 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/ToolConfiguration.kt @@ -0,0 +1,49 @@ +package com.pluto.tool + +import androidx.annotation.DrawableRes +import com.pluto.utilities.list.ListItem + +/** + * Data class containing configuration for a tool. + * + * This class defines the visual properties of a tool, such as its name and icon. + * It also generates a unique identifier based on the name. + * + * @property name The display name of the tool + * @property icon The resource ID of the tool's icon + */ +internal data class ToolConfiguration( + val name: String, + @DrawableRes val icon: Int +) : ListItem() { + /** + * Unique identifier for the tool, generated from the name. + * + * The identifier is created by converting the name to lowercase and + * replacing spaces with underscores. + */ + val identifier = name.lowercase().replace(" ", "_", true) + + /** + * Compares this tool configuration with another object for equality. + * + * Tool configurations are considered equal if they have the same identifier. + * + * @param other The object to compare with + * @return True if the objects are equal, false otherwise + */ + override fun equals(other: Any?): Boolean { + return other is ToolConfiguration && identifier == other.identifier + } + + /** + * Returns a hash code value for this tool configuration. + * + * The hash code is based on the identifier to ensure consistency with equals. + * + * @return The hash code value + */ + override fun hashCode(): Int { + return identifier.hashCode() + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/ToolManager.kt b/pluto/lib/src/main/java/com/pluto/tool/ToolManager.kt new file mode 100644 index 000000000..b3cca43b9 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/ToolManager.kt @@ -0,0 +1,82 @@ +package com.pluto.tool + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import com.pluto.core.applifecycle.AppStateCallback +import com.pluto.tool.modules.currentScreen.CurrentScreenTool +import com.pluto.tool.modules.grid.GridViewTool +import com.pluto.tool.modules.ruler.RulerTool + +/** + * Manages the initialization and interaction with Pluto tools. + * + * This class is responsible for initializing tools, retrieving tools by identifier, + * and handling tool selection. It maintains a registry of all available tools. + * + * @property application The application instance used for tool initialization + * @param state LiveData that emits application state changes + */ +internal class ToolManager(private val application: Application, state: MutableLiveData) { + + /** + * Set of all available tools. + * + * This set contains all the tools that are available in Pluto. + * Tools are initialized when the ToolManager is initialized. + */ + val tools: LinkedHashSet = linkedSetOf().apply { + add(RulerTool()) + add(GridViewTool()) + add(CurrentScreenTool()) +// add(ScreenHistoryTool()) + } + + /** + * Initializes the tool manager by observing app state changes. + * + * When the app goes to the background, all tools are unselected. + */ + init { + state.observeForever { + if (it is AppStateCallback.State.Background) { + tools.forEach { tool -> + tool.onToolUnselected() + } + } + } + } + + /** + * Initializes all tools with the application instance. + * + * This method is called during Pluto initialization to set up all tools. + */ + fun initialise() { + tools.forEach { + it.initialise(application) + } + } + + /** + * Retrieves a tool by its identifier. + * + * @param identifier The unique identifier of the tool to retrieve + * @return The tool with the specified identifier, or null if not found + */ + fun get(identifier: String): PlutoTool? { + return tools.firstOrNull { + it.id == identifier + } + } + + /** + * Selects a tool by its identifier. + * + * This method calls the onToolSelected method of the specified tool. + * + * @param id The identifier of the tool to select + */ + fun select(id: String) { + get(id)?.onToolSelected() + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/ToolsViewModel.kt b/pluto/lib/src/main/java/com/pluto/tool/ToolsViewModel.kt new file mode 100644 index 000000000..5192de5c1 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/ToolsViewModel.kt @@ -0,0 +1,46 @@ +package com.pluto.tool + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.pluto.Pluto + +/** + * ViewModel for managing and exposing Pluto tools to the UI. + * + * This class provides a LiveData object that contains the list of all available + * Pluto tools. It is used by the UI to display the list of tools to the user. + * + * @param application The application instance + */ +internal class ToolsViewModel(application: Application) : AndroidViewModel(application) { + + /** + * LiveData containing the list of all available tools. + * + * This property provides a read-only view of the tools list for observers. + */ + val tools: LiveData> + get() = _tools + + /** + * Mutable LiveData containing the list of all available tools. + * + * This property is used internally to update the tools list. + */ + private val _tools = MutableLiveData>() + + /** + * Initializes the ViewModel by loading the list of tools from the ToolManager. + * + * This method is called when the ViewModel is created. + */ + init { + _tools.postValue( + arrayListOf().apply { + addAll(Pluto.toolManager.tools) + } + ) + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/AppLifecycleListener.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/AppLifecycleListener.kt new file mode 100644 index 000000000..6a073e55f --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/AppLifecycleListener.kt @@ -0,0 +1,103 @@ +package com.pluto.tool.modules.currentScreen + +import android.app.Activity +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager + +/** + * Activity lifecycle callback that tracks the current activity and its fragments. + * + * This class implements ActivityLifecycleCallbacks to monitor activity lifecycle events + * and register fragment lifecycle callbacks when an activity is resumed. + * + * @property screenUpdateCallback Callback to notify when the current screen changes + */ +internal class AppLifecycleListener(private val screenUpdateCallback: OnCurrentScreenUpdateListener) : ActivityLifecycleCallbacks { + + /** Fragment lifecycle callback to track fragment changes */ + private val fragmentLifecycleCallbacks = FragmentLifecycleListener(screenUpdateCallback) + + /** + * Called when an activity is resumed. + * + * Updates the current activity name and registers fragment lifecycle callbacks. + * + * @param activity The activity that was resumed + */ + override fun onActivityResumed(activity: Activity) { + screenUpdateCallback.onUpdate(null, activity::class.java.name) + fragmentLifecycleCallbacks.activity = activity + activity.registerFragmentLifecycle(fragmentLifecycleCallbacks) + } + + /** + * Called when an activity is paused. + * + * Unregisters fragment lifecycle callbacks. + * + * @param activity The activity that was paused + */ + override fun onActivityPaused(activity: Activity) { + activity.unregisterFragmentLifecycle(fragmentLifecycleCallbacks) + } + + /** Called when an activity is created. Not used in this implementation. */ + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + + /** Called when an activity is started. Not used in this implementation. */ + override fun onActivityStarted(activity: Activity) {} + + /** Called when an activity is stopped. Not used in this implementation. */ + override fun onActivityStopped(activity: Activity) {} + + /** Called when an activity's state is saved. Not used in this implementation. */ + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + + /** + * Called when an activity is destroyed. + * + * Clears the reference to the activity in the fragment lifecycle callbacks. + * + * @param activity The activity that was destroyed + */ + override fun onActivityDestroyed(activity: Activity) { + fragmentLifecycleCallbacks.activity = null + } +} + +/** + * Registers fragment lifecycle callbacks for an activity. + * + * This extension function registers the provided callback with the activity's + * fragment manager if the activity is a FragmentActivity or AppCompatActivity. + * + * @param callback The fragment lifecycle callback to register + */ +private fun Activity.registerFragmentLifecycle(callback: FragmentManager.FragmentLifecycleCallbacks) { + if (this is FragmentActivity) { + supportFragmentManager.registerFragmentLifecycleCallbacks(callback, true) + } + if (this is AppCompatActivity) { + supportFragmentManager.registerFragmentLifecycleCallbacks(callback, true) + } +} + +/** + * Unregisters fragment lifecycle callbacks for an activity. + * + * This extension function unregisters the provided callback from the activity's + * fragment manager if the activity is a FragmentActivity or AppCompatActivity. + * + * @param callback The fragment lifecycle callback to unregister + */ +private fun Activity.unregisterFragmentLifecycle(callback: FragmentManager.FragmentLifecycleCallbacks) { + if (this is FragmentActivity) { + supportFragmentManager.unregisterFragmentLifecycleCallbacks(callback) + } + if (this is AppCompatActivity) { + supportFragmentManager.unregisterFragmentLifecycleCallbacks(callback) + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/CurrentScreenTool.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/CurrentScreenTool.kt new file mode 100644 index 000000000..e7e82e963 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/CurrentScreenTool.kt @@ -0,0 +1,163 @@ +package com.pluto.tool.modules.currentScreen + +import android.graphics.PixelFormat +import android.os.Build +import android.view.Gravity +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.core.view.ViewCompat +import com.pluto.R +import com.pluto.tool.PlutoTool +import com.pluto.tool.ToolConfiguration +import com.pluto.utilities.extensions.addViewToWindow +import com.pluto.utilities.extensions.canDrawOverlays +import com.pluto.utilities.extensions.removeViewFromWindow + +/** + * Tool that displays the current activity and fragment names on screen. + * + * This tool shows an overlay at the bottom of the screen that displays + * the name of the currently visible activity and fragment. It's useful + * for developers to quickly identify which screen they're looking at. + */ +internal class CurrentScreenTool : PlutoTool("currentScreen") { + + /** The view that displays the current screen information */ + private var gridView: CurrentScreenView? = null + + /** + * Listener that receives updates when the current activity or fragment changes. + * + * This listener updates the displayed text in the overlay view. + */ + private val onCurrentViewUpdateListener = object : OnCurrentScreenUpdateListener { + /** + * Called when the current activity or fragment changes. + * + * @param fragment The name of the current fragment, or null if none + * @param activity The name of the current activity, or null if none + */ + override fun onUpdate(fragment: String?, activity: String?) { + gridView?.updateText(activity, fragment) + } + } + + /** + * Returns the configuration for this tool. + * + * @return The tool configuration with name and icon + */ + override fun getConfig(): ToolConfiguration = ToolConfiguration( + name = application.getString(R.string.pluto___tool_current_screen_name), + icon = R.drawable.pluto___tool_ic_current_screen_logo, + ) + + /** + * Called when the tool is initialized. + * + * Registers activity lifecycle callbacks to track activity and fragment changes. + */ + override fun onToolInitialised() { + application.registerActivityLifecycleCallbacks(AppLifecycleListener(onCurrentViewUpdateListener)) + } + + /** + * Called when the tool is selected. + * + * Toggles the visibility of the current screen overlay. + */ + override fun onToolSelected() { + toggle() + } + + /** + * Called when the tool is unselected. + * + * Hides the current screen overlay. + */ + override fun onToolUnselected() { + hideView() + } + + /** + * Determines whether the tool is enabled. + * + * The tool is enabled if the app has permission to draw overlays. + * + * @return True if the tool is enabled, false otherwise + */ + override fun isEnabled(): Boolean = application.applicationContext.canDrawOverlays() + + /** + * Toggles the visibility of the current screen overlay. + * + * If the overlay is visible, it will be hidden. + * If the overlay is hidden, it will be shown. + */ + private fun toggle() { + gridView?.let { + if (isShowing(it)) { + hideView() + } else { + showView() + } + } ?: run { + showView() + } + } + + /** + * Shows the current screen overlay. + * + * Creates the overlay view if it doesn't exist and adds it to the window. + */ + private fun showView() { + if (gridView == null) { + gridView = CurrentScreenView(application) + } + gridView?.let { + application.addViewToWindow(it, layoutParams()) + } + } + + /** + * Hides the current screen overlay. + * + * Removes the overlay view from the window and nullifies the reference. + */ + private fun hideView() { + gridView?.parent?.let { + application.removeViewFromWindow(gridView!!) + gridView = null + } + } + + /** + * Creates layout parameters for the overlay view. + * + * @return The WindowManager.LayoutParams for the overlay view + */ + private fun layoutParams(): WindowManager.LayoutParams { + val params = WindowManager.LayoutParams() + params.width = FrameLayout.LayoutParams.MATCH_PARENT + params.height = FrameLayout.LayoutParams.WRAP_CONTENT + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + params.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT + } else { + params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + } + params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + params.format = PixelFormat.TRANSLUCENT + params.gravity = Gravity.START or Gravity.BOTTOM + return params + } + + /** + * Determines whether the view is currently showing. + * + * @param view The view to check + * @return True if the view is attached to the window, false otherwise + */ + private fun isShowing(view: View) = ViewCompat.isAttachedToWindow(view) +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/CurrentScreenView.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/CurrentScreenView.kt new file mode 100644 index 000000000..c4a8a920d --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/CurrentScreenView.kt @@ -0,0 +1,104 @@ +package com.pluto.tool.modules.currentScreen + +import android.content.Context +import android.text.TextUtils +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import com.pluto.R +import com.pluto.databinding.PlutoToolCurrentScreenViewBinding +import com.pluto.utilities.extensions.color +import com.pluto.utilities.spannable.createSpan + +/** + * View that displays the current activity and fragment names. + * + * This view is used by the CurrentScreenTool to show an overlay with + * the names of the current activity and fragment. + * + * @param context The context used to inflate the view + */ +internal class CurrentScreenView(context: Context) : ConstraintLayout(context) { + + /** View binding for the current screen view layout */ + val binding = PlutoToolCurrentScreenViewBinding.inflate(LayoutInflater.from(context), this, true) + + /** Stores the last activity name to handle cases where the new value is empty */ + private var lastActivityName: CharSequence? = null + + /** Stores the last fragment name to handle cases where the new value is empty */ + private var lastFragmentName: CharSequence? = null + + /** + * Updates the displayed activity and fragment names. + * + * If the activity is from the Pluto package, it shows a special message + * and hides the fragment name. + * + * @param activity The name of the current activity, or null if none + * @param fragment The name of the current fragment, or null if none + */ + fun updateText(activity: CharSequence?, fragment: CharSequence?) { + if ((activity ?: "").startsWith(PLUTO_PKG_PREFIX, true)) { + updateActivity( + context.createSpan { + append(light(italic(fontColor("~ Pluto Screen ~", context.color(com.pluto.plugin.R.color.pluto___white_60))))) + } + ) + updateFragment(null) + } else { + updateActivity(activity) + updateFragment(fragment) + } + } + + /** + * Updates the displayed fragment name. + * + * If the fragment name is not null, it shows the fragment group and updates the text. + * If the fragment name is null, it hides the fragment group. + * + * @param fragment The name of the current fragment, or null if none + */ + private fun updateFragment(fragment: CharSequence?) { + fragment?.let { + binding.fragmentGroup.visibility = VISIBLE + var value: CharSequence? = context.createSpan { append(it) } + if (!TextUtils.isEmpty(value)) { + lastFragmentName = binding.fragment.text + } else { + value = lastFragmentName + } + binding.fragment.text = value + } ?: run { + binding.fragmentGroup.visibility = GONE + } + } + + /** + * Updates the displayed activity name. + * + * If the activity name is not null, it shows the activity group and updates the text. + * If the activity name is null, it hides the activity group. + * + * @param activity The name of the current activity, or null if none + */ + private fun updateActivity(activity: CharSequence?) { + activity?.let { + binding.activityGroup.visibility = VISIBLE + var value: CharSequence? = context.createSpan { append(it) } + if (!TextUtils.isEmpty(value)) { + lastActivityName = binding.activity.text + } else { + value = lastActivityName + } + binding.activity.text = value + } ?: run { + binding.activityGroup.visibility = GONE + } + } + + companion object { + /** Prefix used to identify Pluto's own screens */ + private const val PLUTO_PKG_PREFIX = "com.pluto" + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/FragmentLifecycleListener.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/FragmentLifecycleListener.kt new file mode 100644 index 000000000..ec3262efc --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/FragmentLifecycleListener.kt @@ -0,0 +1,75 @@ +package com.pluto.tool.modules.currentScreen + +import android.app.Activity +import android.content.Context +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager + +/** + * Fragment lifecycle callback that tracks the current fragment. + * + * This class implements FragmentManager.FragmentLifecycleCallbacks to monitor + * fragment lifecycle events and update the current screen information when + * a fragment is resumed. + * + * @property screenUpdateCallback Callback to notify when the current screen changes + */ +internal class FragmentLifecycleListener(private val screenUpdateCallback: OnCurrentScreenUpdateListener) : FragmentManager.FragmentLifecycleCallbacks() { + /** + * Reference to the current activity. + * + * This property is set by the AppLifecycleListener when an activity is resumed + * and cleared when the activity is destroyed. + */ + var activity: Activity? = null + + /** Called when a fragment is created. Not used in this implementation. */ + override fun onFragmentCreated(manager: FragmentManager, fragment: Fragment, savedInstanceState: Bundle?) { + } + + /** Called when a fragment is attached to its context. Not used in this implementation. */ + override fun onFragmentAttached(manager: FragmentManager, fragment: Fragment, context: Context) { + } + + /** Called when a fragment is started. Not used in this implementation. */ + override fun onFragmentStarted(manager: FragmentManager, fragment: Fragment) { + } + + /** + * Called when a fragment is resumed. + * + * Updates the current screen information with the fragment and activity names. + * + * @param manager The fragment manager + * @param fragment The fragment that was resumed + */ + override fun onFragmentResumed(manager: FragmentManager, fragment: Fragment) { + screenUpdateCallback.onUpdate(fragment::class.java.name, activity?.let { it::class.java.name } ?: run { null }) + } + + /** Called when a fragment is paused. Not used in this implementation. */ + override fun onFragmentPaused(manager: FragmentManager, fragment: Fragment) { + } + + /** Called when a fragment is stopped. Not used in this implementation. */ + override fun onFragmentStopped(manager: FragmentManager, fragment: Fragment) { + } + + /** Called when a fragment's view is created. Not used in this implementation. */ + override fun onFragmentViewCreated(manager: FragmentManager, fragment: Fragment, v: View, state: Bundle?) { + } + + /** Called when a fragment's view is destroyed. Not used in this implementation. */ + override fun onFragmentViewDestroyed(manager: FragmentManager, fragment: Fragment) { + } + + /** Called when a fragment is detached from its context. Not used in this implementation. */ + override fun onFragmentDetached(manager: FragmentManager, fragment: Fragment) { + } + + /** Called when a fragment is destroyed. Not used in this implementation. */ + override fun onFragmentDestroyed(manager: FragmentManager, fragment: Fragment) { + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/OnCurrentScreenUpdateListener.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/OnCurrentScreenUpdateListener.kt new file mode 100644 index 000000000..a5e9adb12 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/currentScreen/OnCurrentScreenUpdateListener.kt @@ -0,0 +1,18 @@ +package com.pluto.tool.modules.currentScreen + +/** + * Interface for receiving updates about the current screen. + * + * This interface is used to notify listeners when the current activity + * or fragment changes. Implementations can use this information to + * display or log the current screen information. + */ +internal interface OnCurrentScreenUpdateListener { + /** + * Called when the current screen changes. + * + * @param fragment The name of the current fragment, or null if none + * @param activity The name of the current activity, or null if none + */ + fun onUpdate(fragment: String?, activity: String?) +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/grid/GridView.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/grid/GridView.kt new file mode 100644 index 000000000..28e75489a --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/grid/GridView.kt @@ -0,0 +1,64 @@ +package com.pluto.tool.modules.grid + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.view.View +import com.pluto.R +import com.pluto.plugin.settings.SettingsPreferences +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp2px + +/** + * A custom view that draws a grid pattern on the screen. + * + * This view draws horizontal and vertical lines at regular intervals to create a grid overlay. + * The grid spacing is determined by the SettingsPreferences.gridSize value, and the color + * of the grid lines adapts based on whether dark theme is enabled. + * + * @param context The context used to access resources and settings + */ +internal class GridView(context: Context) : View(context) { + /** + * Paint object used to draw the grid lines. + * The color is determined by the current theme setting (light or dark). + */ + private val gridPaint = object : Paint(ANTI_ALIAS_FLAG) { + init { + color = context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___red_40 + } else { + com.pluto.plugin.R.color.pluto___orange_40 + } + ) + style = Style.FILL + strokeWidth = 1f.dp2px + } + } + + /** + * Draws the grid pattern on the canvas. + * + * This method draws vertical and horizontal lines at intervals specified by + * SettingsPreferences.gridSize to create a grid overlay on the screen. + * + * @param canvas The canvas on which the grid will be drawn + */ + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + // Draw vertical lines + var startX = 0 + while (startX < measuredWidth) { + canvas.drawLine(startX.toFloat().dp2px, 0f, startX.toFloat().dp2px, measuredHeight.toFloat(), gridPaint) + startX += SettingsPreferences.gridSize + } + + // Draw horizontal lines + var startY = 0 + while (startY < measuredHeight) { + canvas.drawLine(0f, startY.toFloat().dp2px, measuredWidth.toFloat(), startY.toFloat().dp2px, gridPaint) + startY += SettingsPreferences.gridSize + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/grid/GridViewTool.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/grid/GridViewTool.kt new file mode 100644 index 000000000..414ceb205 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/grid/GridViewTool.kt @@ -0,0 +1,133 @@ +package com.pluto.tool.modules.grid + +import android.graphics.PixelFormat +import android.os.Build +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.core.view.ViewCompat +import com.pluto.R +import com.pluto.tool.PlutoTool +import com.pluto.tool.ToolConfiguration +import com.pluto.utilities.extensions.addViewToWindow +import com.pluto.utilities.extensions.canDrawOverlays +import com.pluto.utilities.extensions.removeViewFromWindow + +/** + * A tool that displays a grid overlay on the screen to help with UI alignment and measurement. + * + * This tool creates a transparent overlay with a grid pattern that can be toggled on and off. + * The grid helps developers visualize layout alignment, spacing, and proportions during app development. + * It requires the SYSTEM_ALERT_WINDOW permission (draw over other apps) to function. + */ +internal class GridViewTool : PlutoTool("grid") { + + private var gridView: GridView? = null + + /** + * Provides the configuration for this tool, including its name and icon. + * + * @return A ToolConfiguration object with the tool's display properties + */ + override fun getConfig(): ToolConfiguration = ToolConfiguration( + name = application.getString(R.string.pluto___tool_grid_name), + icon = R.drawable.pluto___tool_ic_grid_logo, + ) + + /** + * Called when the tool is initialized. + * No specific initialization is needed for this tool. + */ + override fun onToolInitialised() { + } + + /** + * Called when the tool is selected by the user. + * Toggles the grid visibility. + */ + override fun onToolSelected() { + toggle() + } + + /** + * Called when the tool is unselected by the user. + * Hides the grid if it's currently visible. + */ + override fun onToolUnselected() { + hideGrid() + } + + /** + * Determines if this tool is enabled based on whether the app has permission to draw over other apps. + * + * @return true if the app has the SYSTEM_ALERT_WINDOW permission, false otherwise + */ + override fun isEnabled(): Boolean = application.applicationContext.canDrawOverlays() + + /** + * Toggles the grid visibility - shows it if it's hidden, hides it if it's showing. + */ + private fun toggle() { + gridView?.let { + if (isShowing(it)) { + hideGrid() + } else { + showGrid() + } + } ?: run { + showGrid() + } + } + + /** + * Shows the grid overlay on the screen. + * Creates a new GridView if one doesn't exist and adds it to the window. + */ + private fun showGrid() { + if (gridView == null) { + gridView = GridView(application) + } + gridView?.let { + application.addViewToWindow(it, layoutParams()) + } + } + + /** + * Hides the grid overlay if it's currently visible. + * Removes the GridView from the window and nullifies the reference. + */ + private fun hideGrid() { + gridView?.parent?.let { + application.removeViewFromWindow(gridView!!) + gridView = null + } + } + + /** + * Creates the layout parameters for the grid overlay window. + * Sets up a full-screen, non-touchable, translucent overlay. + * + * @return WindowManager.LayoutParams configured for the grid overlay + */ + private fun layoutParams(): WindowManager.LayoutParams { + val params = WindowManager.LayoutParams() + params.width = FrameLayout.LayoutParams.MATCH_PARENT + params.height = FrameLayout.LayoutParams.MATCH_PARENT + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + params.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT + } else { + params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + } + params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + params.format = PixelFormat.TRANSLUCENT + return params + } + + /** + * Checks if the given view is currently attached to a window (visible). + * + * @param view The view to check + * @return true if the view is attached to a window, false otherwise + */ + private fun isShowing(view: View) = ViewCompat.isAttachedToWindow(view) +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/RulerActivity.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/RulerActivity.kt new file mode 100644 index 000000000..6a6201d0f --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/RulerActivity.kt @@ -0,0 +1,109 @@ +package com.pluto.tool.modules.ruler + +import android.os.Bundle +import android.view.View.GONE +import android.view.View.VISIBLE +import androidx.appcompat.app.AppCompatActivity +import com.pluto.R +import com.pluto.databinding.PlutoToolRulerActivityBinding +import com.pluto.tool.modules.ruler.internal.ControlsWidget +import com.pluto.tool.modules.ruler.internal.RulerFragment +import com.pluto.tool.modules.ruler.internal.control.ControlCta +import com.pluto.tool.modules.ruler.internal.hint.HintFragment + +/** + * Activity that displays the ruler interface for measuring UI elements. + * + * This activity hosts the ruler view and control widgets that allow the user to + * measure distances and sizes on the screen. It provides controls for showing hints, + * closing the ruler, and moving the control panel between the left and right sides + * of the screen for easier one-handed operation. + */ +class RulerActivity : AppCompatActivity() { + + private lateinit var binding: PlutoToolRulerActivityBinding + + /** + * Initializes the activity, sets up the ruler view and control widgets. + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = PlutoToolRulerActivityBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Add the ruler fragment to the container + supportFragmentManager.beginTransaction().apply { + this.add(R.id.container, RulerFragment()).commit() + } + + // Initialize the left control panel with close, hint, and move right buttons + binding.leftControls.initialise( + listOf( + ControlCta(ID_CLOSE, R.drawable.pluto___tool_ic_ruler_control_close), + ControlCta(ID_HINT, R.drawable.pluto___tool_ic_ruler_control_hint), + ControlCta(ID_MOVE_RIGHT, R.drawable.pluto___tool_ic_ruler_control_move_right) + ), + onControlCtaListener + ) + + // Initialize the right control panel with move left, hint, and close buttons + binding.rightControls.initialise( + listOf( + ControlCta(ID_MOVE_LEFT, R.drawable.pluto___tool_ic_ruler_control_move_left), + ControlCta(ID_HINT, R.drawable.pluto___tool_ic_ruler_control_hint), + ControlCta(ID_CLOSE, R.drawable.pluto___tool_ic_ruler_control_close) + ), + onControlCtaListener + ) + + // Start with the left controls hidden (right controls visible) + binding.leftControls.visibility = GONE + } + + /** + * Listener for control button clicks that handles the various control actions. + */ + private val onControlCtaListener = object : ControlsWidget.OnClickListener { + override fun onClick(id: String) { + when (id) { + ID_MOVE_RIGHT -> { + // Move controls to the right side + binding.leftControls.visibility = GONE + binding.rightControls.visibility = VISIBLE + } + + ID_MOVE_LEFT -> { + // Move controls to the left side + binding.leftControls.visibility = VISIBLE + binding.rightControls.visibility = GONE + } + + ID_CLOSE -> finish() // Close the ruler activity + ID_HINT -> HintFragment().show(supportFragmentManager, "hint") // Show the hint dialog + } + } + } + + /** + * Called when the activity is no longer visible to the user. + * Finishes the activity to ensure it doesn't remain in the background. + */ + override fun onStop() { + super.onStop() + finish() + } + + private companion object { + /** ID for the close button */ + const val ID_CLOSE = "close" + + /** ID for the hint button */ + const val ID_HINT = "hint" + + /** ID for the move left button */ + const val ID_MOVE_LEFT = "moveToLeft" + + /** ID for the move right button */ + const val ID_MOVE_RIGHT = "moveToRight" + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/RulerTool.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/RulerTool.kt new file mode 100644 index 000000000..cd95c9306 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/RulerTool.kt @@ -0,0 +1,57 @@ +package com.pluto.tool.modules.ruler + +import android.content.Intent +import com.pluto.R +import com.pluto.tool.PlutoTool +import com.pluto.tool.ToolConfiguration + +/** + * A tool that provides a ruler interface for measuring UI elements on the screen. + * + * This tool launches a dedicated activity (RulerActivity) that displays a ruler interface + * with measurement capabilities. The ruler helps developers measure distances, sizes, and + * alignments of UI elements during app development. + */ +internal class RulerTool : PlutoTool("ruler") { + /** + * Provides the configuration for this tool, including its name and icon. + * + * @return A ToolConfiguration object with the tool's display properties + */ + override fun getConfig(): ToolConfiguration = ToolConfiguration( + name = application.getString(R.string.pluto___tool_ruler_name), + icon = R.drawable.pluto___tool_ic_ruler_logo, + ) + + /** + * Called when the tool is initialized. + * No specific initialization is needed for this tool. + */ + override fun onToolInitialised() { + } + + /** + * Called when the tool is selected by the user. + * Launches the RulerActivity to display the ruler interface. + */ + override fun onToolSelected() { + val intent = Intent(application.applicationContext, RulerActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + application.applicationContext.startActivity(intent) + } + + /** + * Called when the tool is unselected by the user. + * No specific cleanup is needed for this tool as the activity handles its own lifecycle. + */ + override fun onToolUnselected() { + } + + /** + * Determines if this tool is enabled. + * The ruler tool is always enabled as it doesn't require special permissions. + * + * @return Always returns true as this tool is always enabled + */ + override fun isEnabled(): Boolean = true +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/ControlsWidget.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/ControlsWidget.kt new file mode 100644 index 000000000..b6d80f20c --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/ControlsWidget.kt @@ -0,0 +1,53 @@ +package com.pluto.tool.modules.ruler.internal + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DividerItemDecoration +import com.pluto.R +import com.pluto.databinding.PlutoToolRulerControlsBinding +import com.pluto.tool.modules.ruler.internal.control.ControlCta +import com.pluto.tool.modules.ruler.internal.control.ControlCtaAdapter +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class ControlsWidget : ConstraintLayout { + + private val binding = PlutoToolRulerControlsBinding.inflate(LayoutInflater.from(context), this, true) + private val pluginAdapter: BaseAdapter by lazy { ControlCtaAdapter(onActionListener) } + private var mListener: OnClickListener? = null + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) + constructor(context: Context) : super(context, null, 0) + + fun initialise(ctas: List, listener: OnClickListener? = null) { + mListener = listener + binding.list.apply { + adapter = pluginAdapter + addItemDecoration( + DividerItemDecoration(context, LinearLayout.HORIZONTAL).apply { + setDrawable(ContextCompat.getDrawable(context, R.drawable.pluto___tool_item_divider)!!) + } + ) + } + pluginAdapter.list = ctas + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + if (data is ControlCta) { + mListener?.onClick(data.id) + } + } + } + + interface OnClickListener { + fun onClick(id: String) + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/DataModel.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/DataModel.kt new file mode 100644 index 000000000..68dbe8247 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/DataModel.kt @@ -0,0 +1,29 @@ +package com.pluto.tool.modules.ruler.internal + +/** + * Data class that represents a pair of x and y coordinates. + * + * This class is used throughout the ruler tool to track various coordinate points + * such as touch positions, click positions, and movement positions. + */ +internal class CoordinatePair { + /** The x-coordinate value */ + var x = 0f + + /** The y-coordinate value */ + var y = 0f +} + +/** + * Data class that represents the dimensions of the screen. + * + * This class is used to store the height and width of the screen in density-independent pixels (dp), + * which is useful for calculating ruler measurements that are consistent across different device densities. + */ +internal class ScreenMeasurement { + /** The height of the screen in dp */ + var height = 0 + + /** The width of the screen in dp */ + var width = 0 +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/FullScreenTool.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/FullScreenTool.kt new file mode 100644 index 000000000..e003cbd2a --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/FullScreenTool.kt @@ -0,0 +1,69 @@ +package com.pluto.tool.modules.ruler.internal + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.fragment.app.Fragment + +internal abstract class FullScreenTool : Fragment() { + + abstract fun getToolView(): View + abstract fun onHintClicked() + abstract fun onCloseClicked() + + private lateinit var parent: ConstraintLayout + private lateinit var controls: ControlsWidget + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + parent = ConstraintLayout(requireContext()) + controls = generateControls(requireContext()) + return installControls(getToolView().apply { id = View.generateViewId() }) + } + + private fun generateControls(context: Context): ControlsWidget { + return ControlsWidget(context).apply { + id = View.generateViewId() +// initialise(onControlInteractionListener) + } + } + + private fun installControls(view: View): View { + val rlParams = ConstraintLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + parent.addView(view, rlParams) + parent.addView(controls) + positionControls(true) + return parent + } + + private fun positionControls(isRightAligned: Boolean) { + clearConstraints() + val set = ConstraintSet() + set.clone(parent) + val constraintAlignment = if (isRightAligned) ConstraintSet.RIGHT else ConstraintSet.LEFT + set.connect(controls.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP, CONTROL_TOP_MARGIN) + set.connect(controls.id, constraintAlignment, ConstraintSet.PARENT_ID, constraintAlignment, CONTROL_HORIZONTAL_MARGIN) + set.applyTo(parent) + } + + private fun clearConstraints() { + val set = ConstraintSet() + set.clone(parent) + set.clear(controls.id, ConstraintSet.RIGHT) + set.clear(controls.id, ConstraintSet.LEFT) + set.applyTo(parent) + } + + interface OnControlClickListener { + fun onHint() + fun onClose() + } + + private companion object { + const val CONTROL_TOP_MARGIN = 60 + const val CONTROL_HORIZONTAL_MARGIN = 60 + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/PaintType.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/PaintType.kt new file mode 100644 index 000000000..e128340c5 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/PaintType.kt @@ -0,0 +1,112 @@ +package com.pluto.tool.modules.ruler.internal + +import android.content.Context +import android.graphics.DashPathEffect +import android.graphics.Paint +import androidx.core.content.res.ResourcesCompat +import com.pluto.R +import com.pluto.plugin.settings.SettingsPreferences +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.dp2px + +/** + * Class that provides various Paint objects for drawing the ruler components. + * + * This class encapsulates the different Paint configurations needed for the ruler tool, + * including paints for the scale lines, markers, previous scale position, measurements, + * and boundary. Each paint is configured with appropriate colors, styles, and effects + * based on the current theme setting. + * + * @param context The context used to access resources and settings + */ +internal data class PaintType(val context: Context) { + + /** + * Paint for the main scale lines of the ruler. + * The color adapts based on the current theme setting. + */ + val scale: Paint = object : Paint(ANTI_ALIAS_FLAG) { + init { + color = context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___red_dark + } else { + com.pluto.plugin.R.color.pluto___orange + } + ) + style = Style.FILL + strokeWidth = 1f.dp2px + } + } + + /** + * Paint for the scale markers (ticks) on the ruler. + * These are the small lines that indicate measurement units. + */ + val scaleMarker: Paint = object : Paint(ANTI_ALIAS_FLAG) { + init { + color = context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___red_80 + } else { + com.pluto.plugin.R.color.pluto___orange_80 + } + ) + style = Style.FILL + strokeWidth = 1f.dp2px + } + } + + /** + * Paint for the previous scale position, shown as a dashed line. + * This helps users see where the scale was before moving it. + */ + val prevScale: Paint = object : Paint(ANTI_ALIAS_FLAG) { + init { + color = context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___red_60 + } else { + com.pluto.plugin.R.color.pluto___orange_60 + } + ) + style = Style.STROKE + strokeWidth = 1f.dp2px + pathEffect = DashPathEffect(floatArrayOf(3f.dp, 2f.dp), 0f) + } + } + + /** + * Paint for drawing measurement text and lines. + * This is used to display the actual measurement values and the measurement line. + */ + val measurement: Paint = object : Paint(ANTI_ALIAS_FLAG) { + init { + color = context.color( + if (SettingsPreferences.isDarkThemeEnabled) { + com.pluto.plugin.R.color.pluto___blue + } else { + com.pluto.plugin.R.color.pluto___teal + } + ) + style = Style.FILL + strokeWidth = 4f.dp2px + textSize = 14f.dp2px + typeface = ResourcesCompat.getFont(context, com.pluto.plugin.R.font.muli_semibold) + flags = FAKE_BOLD_TEXT_FLAG + } + } + + /** + * Paint for drawing the boundary of the ruler view. + * This helps visually define the edges of the ruler area. + */ + val boundary: Paint = object : Paint(ANTI_ALIAS_FLAG) { + init { + color = context.color(com.pluto.plugin.R.color.pluto___emerald) + strokeWidth = 4f.dp2px + style = Style.STROKE + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/RulerFragment.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/RulerFragment.kt new file mode 100644 index 000000000..0518fc4ea --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/RulerFragment.kt @@ -0,0 +1,32 @@ +package com.pluto.tool.modules.ruler.internal + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment + +/** + * Fragment that hosts the ruler scale view. + * + * This fragment is responsible for creating and displaying the RulerScaleView, + * which provides the actual ruler functionality for measuring UI elements. + * It's a simple container fragment that creates the view with a unique ID. + */ +internal class RulerFragment : Fragment() { + + /** + * Creates and returns the view hierarchy associated with the fragment. + * In this case, it creates a new RulerScaleView with a generated ID. + * + * @param inflater The LayoutInflater object that can be used to inflate views + * @param container The parent view that the fragment's UI should be attached to + * @param savedInstanceState If non-null, this fragment is being re-constructed from a previous saved state + * @return The created RulerScaleView + */ + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return RulerScaleView(requireContext()).apply { + id = View.generateViewId() + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/RulerScaleView.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/RulerScaleView.kt new file mode 100644 index 000000000..0c550b262 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/RulerScaleView.kt @@ -0,0 +1,318 @@ +package com.pluto.tool.modules.ruler.internal + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.extensions.dp2px +import com.pluto.utilities.extensions.px2dp +import com.pluto.utilities.extensions.twoDecimal +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * A custom view that implements the ruler scale functionality. + * + * This view provides an interactive ruler that allows users to measure distances on the screen. + * It handles touch events to track coordinates, displays measurement lines and values, + * and supports both horizontal and vertical measurements. The ruler includes scale markers + * at regular intervals and displays the measurement value in density-independent pixels (dp). + * + * @param context The context used to access resources and system services + */ +internal class RulerScaleView(context: Context) : View(context) { + + /** + * The minimum distance that the user's finger must move to be considered a drag operation. + * This helps distinguish between taps and drags. + */ + private val touchSlop: Int + + /** + * Coordinates where the ACTION_DOWN event occurred. + */ + private var downCoordinate = CoordinatePair() + + /** + * Current touch coordinates during a touch event. + */ + private var lastTouchCoordinate = CoordinatePair() + + /** + * Coordinates where the user clicked to place the ruler. + */ + private var clickCoordinate = CoordinatePair() + + /** + * Coordinates of the previous ruler position before the current movement. + */ + private var prevCoordinate = CoordinatePair() + + /** + * Coordinates where a movement operation started. + */ + private var moveStartCoordinate = CoordinatePair() + + /** + * Dimensions of the screen in dp. + */ + private var screen = ScreenMeasurement() + + /** + * Collection of Paint objects used for drawing the ruler components. + */ + private val paintType = PaintType(context) + + /** + * Current direction of ruler movement (Idle, Horizontal, or Vertical). + */ + private var direction: Direction = Direction.Idle + + init { + setLayerType(LAYER_TYPE_SOFTWARE, null) + val vc = ViewConfiguration.get(context) + touchSlop = vc.scaledTouchSlop + } + + /** + * Called to determine the size requirements for this view and its children. + * Updates the screen measurement values based on the measured dimensions. + */ + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + screen.height = measuredHeight.toFloat().px2dp.toInt() + screen.width = measuredWidth.toFloat().px2dp.toInt() + } + + /** + * Handles touch events to implement the ruler's interactive behavior. + * Processes ACTION_DOWN, ACTION_MOVE, and ACTION_UP events to track coordinates + * and update the ruler position and measurements. + * + * @param event The motion event containing touch information + * @return true if the event was handled, false otherwise + */ + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + handleActionDown(event) + super.onTouchEvent(event) + return true + } + MotionEvent.ACTION_MOVE -> handleActionMove(event) + MotionEvent.ACTION_UP -> handleActionUp(event) + } + return super.onTouchEvent(event) + } + + /** + * Draws the ruler components on the canvas. + * This includes the initial scale, scroll indicators, and previous scale position. + * + * @param canvas The canvas on which to draw the ruler + */ + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + drawInitialScale(canvas, screen) + drawScroll(canvas) + drawPreviousScale(canvas) + } + + /** + * Draws the initial ruler scale with boundary and scale markers. + * + * @param canvas The canvas on which to draw + * @param screen The screen measurement information + */ + private fun drawInitialScale(canvas: Canvas, screen: ScreenMeasurement) { + canvas.drawRect(0f, 0f, measuredWidth.toFloat(), measuredHeight.toFloat(), paintType.boundary) + + // init + if (clickCoordinate.y > 0) { + canvas.drawLine(0f, clickCoordinate.y, measuredWidth.toFloat(), clickCoordinate.y, paintType.scale) + } + if (clickCoordinate.x > 0) { + canvas.drawLine(clickCoordinate.x, 0f, clickCoordinate.x, measuredHeight.toFloat(), paintType.scale) + } + + // scale + var i = 0 + while (i < screen.height) { + canvas.drawLine(clickCoordinate.x, i.toFloat().dp2px, clickCoordinate.x + getMarkerHeight(i), i.toFloat().dp2px, paintType.scaleMarker) + i += SCALE_GAP + } + var j = 0 + while (j < screen.width) { + canvas.drawLine(j.toFloat().dp2px, clickCoordinate.y, j.toFloat().dp2px, clickCoordinate.y + getMarkerHeight(j), paintType.scaleMarker) + j += SCALE_GAP + } + } + + /** + * Draws the previous scale position as dashed lines. + * + * @param canvas The canvas on which to draw + */ + private fun drawPreviousScale(canvas: Canvas) { + if (prevCoordinate.x > 0) { + canvas.drawLine(prevCoordinate.x, 0f, prevCoordinate.x, measuredHeight.toFloat(), paintType.prevScale) + } + if (prevCoordinate.y > 0) { + canvas.drawLine(0f, prevCoordinate.y, measuredWidth.toFloat(), prevCoordinate.y, paintType.prevScale) + } + } + + /** + * Draws the measurement lines and text during a scroll/drag operation. + * Shows different UI based on whether the movement is horizontal or vertical. + * + * @param canvas The canvas on which to draw + */ + private fun drawScroll(canvas: Canvas) { + if (direction == Direction.Horizontal) { + canvas.drawLine( + clickCoordinate.x + lastTouchCoordinate.x - moveStartCoordinate.x, + 0f, + clickCoordinate.x + lastTouchCoordinate.x - moveStartCoordinate.x, + measuredHeight.toFloat(), + paintType.scale + ) + val dis = lastTouchCoordinate.x - moveStartCoordinate.x + canvas.drawLine(clickCoordinate.x, clickCoordinate.y, clickCoordinate.x + dis, clickCoordinate.y, paintType.measurement) + paintType.measurement.textAlign = Paint.Align.CENTER + canvas.drawText("${dis.px2dp.twoDecimal} dp", clickCoordinate.x + dis / 2, clickCoordinate.y - 12f.dp, paintType.measurement) + } else if (direction == Direction.Vertical) { + canvas.drawLine( + 0f, + clickCoordinate.y + lastTouchCoordinate.y - moveStartCoordinate.y, + measuredWidth.toFloat(), + clickCoordinate.y + lastTouchCoordinate.y - moveStartCoordinate.y, + paintType.scale + ) + val dis = lastTouchCoordinate.y - moveStartCoordinate.y + canvas.drawLine(clickCoordinate.x, clickCoordinate.y, clickCoordinate.x, clickCoordinate.y + dis, paintType.measurement) + paintType.measurement.textAlign = Paint.Align.LEFT + canvas.drawText("${dis.px2dp.twoDecimal} dp", clickCoordinate.x + 12f.dp, clickCoordinate.y + dis / 2, paintType.measurement) + } + } + + /** + * Handles the ACTION_UP touch event. + * Updates the ruler position based on the touch event and current direction. + * + * @param event The motion event containing touch information + */ + private fun handleActionUp(event: MotionEvent) { + if (direction == Direction.Idle) { + prevCoordinate.y = 0f + prevCoordinate.x = prevCoordinate.y + clickCoordinate.x = event.x + clickCoordinate.y = event.y + } else { + if (direction == Direction.Horizontal) { + prevCoordinate.x = clickCoordinate.x + clickCoordinate.x += event.x - moveStartCoordinate.x + } else if (direction == Direction.Vertical) { + prevCoordinate.y = clickCoordinate.y + clickCoordinate.y += event.y - moveStartCoordinate.y + } + direction = Direction.Idle + } + invalidate() + } + + /** + * Handles the ACTION_MOVE touch event. + * Determines the direction of movement and updates coordinates accordingly. + * + * @param event The motion event containing touch information + */ + private fun handleActionMove(event: MotionEvent) { + lastTouchCoordinate.x = event.x + lastTouchCoordinate.y = event.y + val dx = lastTouchCoordinate.x - downCoordinate.x + val dy = lastTouchCoordinate.y - downCoordinate.y + if (direction == Direction.Idle) { + if (abs(dx) > touchSlop) { + direction = Direction.Horizontal + moveStartCoordinate.x = lastTouchCoordinate.x + prevCoordinate.x = clickCoordinate.x + if (clickCoordinate.y <= 0) { + clickCoordinate.y = lastTouchCoordinate.y + } + } else if (abs(dy) > touchSlop) { + direction = Direction.Vertical + moveStartCoordinate.y = lastTouchCoordinate.y + prevCoordinate.y = clickCoordinate.y + if (clickCoordinate.x <= 0) { + clickCoordinate.x = lastTouchCoordinate.x + } + } + } + if (direction != Direction.Idle) { + invalidate() + } + } + + /** + * Handles the ACTION_DOWN touch event. + * Records the initial touch coordinates. + * + * @param event The motion event containing touch information + */ + private fun handleActionDown(event: MotionEvent) { + lastTouchCoordinate.x = event.x + downCoordinate.x = lastTouchCoordinate.x + lastTouchCoordinate.y = event.y + downCoordinate.y = lastTouchCoordinate.y + } + + /** + * Determines the height of a scale marker based on its position. + * Creates a pattern of different sized markers to improve readability. + * + * @param position The position along the scale + * @return The height of the marker in pixels + */ + private fun getMarkerHeight(position: Int): Int { + return when { + position / SCALE_GAP % (MARKER_SPIKE_INDICATOR_INDEX * 2) == 0 -> MID_MARKER_HEIGHT.roundToInt() + position / SCALE_GAP % MARKER_SPIKE_INDICATOR_INDEX == 0 -> LARGE_MARKER_HEIGHT.roundToInt() + else -> MARKER_HEIGHT.roundToInt() + } + } + + /** + * Sealed class representing the possible directions of ruler movement. + */ + private sealed class Direction { + /** No movement is occurring */ + object Idle : Direction() + + /** Horizontal movement is occurring */ + object Horizontal : Direction() + + /** Vertical movement is occurring */ + object Vertical : Direction() + } + + companion object { + /** Index used to determine which markers should be larger */ + private const val MARKER_SPIKE_INDICATOR_INDEX = 5 + + /** Gap between scale markers in dp */ + const val SCALE_GAP = 5 + + /** Height of standard scale markers */ + private val MARKER_HEIGHT = 4f.dp2px + + /** Height of medium scale markers */ + private val MID_MARKER_HEIGHT = MARKER_HEIGHT * 1.6 + + /** Height of large scale markers */ + private val LARGE_MARKER_HEIGHT = MARKER_HEIGHT * 2.2 + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/control/ControlCta.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/control/ControlCta.kt new file mode 100644 index 000000000..bb1e33d19 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/control/ControlCta.kt @@ -0,0 +1,9 @@ +package com.pluto.tool.modules.ruler.internal.control + +import androidx.annotation.DrawableRes +import com.pluto.utilities.list.ListItem + +internal data class ControlCta( + val id: String, + @DrawableRes val icon: Int, +) : ListItem() diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/control/ControlCtaAdapter.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/control/ControlCtaAdapter.kt new file mode 100644 index 000000000..198724efc --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/control/ControlCtaAdapter.kt @@ -0,0 +1,26 @@ +package com.pluto.tool.modules.ruler.internal.control + +import android.view.ViewGroup +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class ControlCtaAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is ControlCta -> ITEM_TYPE_CONTROL_CTA + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_CONTROL_CTA -> ControlCtaItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_CONTROL_CTA = 1000 + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/control/ControlCtaItemHolder.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/control/ControlCtaItemHolder.kt new file mode 100644 index 000000000..48342ae57 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/control/ControlCtaItemHolder.kt @@ -0,0 +1,30 @@ +package com.pluto.tool.modules.ruler.internal.control + +import android.view.ViewGroup +import com.pluto.R +import com.pluto.databinding.PlutoToolItemControlCtaBinding +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class ControlCtaItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto___tool_item_control_cta), actionListener) { + + private val binding = PlutoToolItemControlCtaBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is ControlCta) { + binding.icon.setImageResource(item.icon) + binding.root.setOnDebounceClickListener { + onAction(item.id) + } + binding.root.setOnLongClickListener { + context.toast(item.id) + true + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintAdapter.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintAdapter.kt new file mode 100644 index 000000000..bb4379318 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintAdapter.kt @@ -0,0 +1,30 @@ +package com.pluto.tool.modules.ruler.internal.hint + +import android.view.ViewGroup +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class HintAdapter : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is HintItem -> ITEM_TYPE_HINT + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_HINT -> HintItemHolder(parent) + else -> null + } + } + + companion object { + const val ITEM_TYPE_HINT = 1000 + } +} + +data class HintItem( + val text: String +) : ListItem() diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintFragment.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintFragment.kt new file mode 100644 index 000000000..8f7d174d8 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintFragment.kt @@ -0,0 +1,42 @@ +package com.pluto.tool.modules.ruler.internal.hint + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.R +import com.pluto.databinding.PlutoToolRulerHintFragmentBinding +import com.pluto.utilities.autoClearInitializer +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.viewBinding + +internal class HintFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoToolRulerHintFragmentBinding::bind) + private val settingsAdapter: BaseAdapter by autoClearInitializer { HintAdapter() } + private val viewModel: HintViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto___tool_ruler_hint_fragment, container, false) + + override fun getTheme(): Int = R.style.PlutoBottomSheetDialogTheme + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.list.apply { + adapter = settingsAdapter + addItemDecoration(CustomItemDecorator(context, 16f.dp.toInt())) + } + viewModel.list.removeObserver(settingsObserver) + viewModel.list.observe(viewLifecycleOwner, settingsObserver) + } + + private val settingsObserver = Observer> { + settingsAdapter.list = it + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintItemHolder.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintItemHolder.kt new file mode 100644 index 000000000..bf6c027a6 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintItemHolder.kt @@ -0,0 +1,21 @@ +package com.pluto.tool.modules.ruler.internal.hint + +import android.view.ViewGroup +import com.pluto.R +import com.pluto.databinding.PlutoToolItemHintBinding +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +internal class HintItemHolder(parent: ViewGroup, listener: DiffAwareAdapter.OnActionListener? = null) : + DiffAwareHolder(parent.inflate(R.layout.pluto___tool_item_hint), listener) { + + private val binding = PlutoToolItemHintBinding.bind(itemView) + + override fun onBind(item: ListItem) { + if (item is HintItem) { + binding.text.text = "${layoutPosition + 1}.\t${item.text}" + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintViewModel.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintViewModel.kt new file mode 100644 index 000000000..d443fc185 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/ruler/internal/hint/HintViewModel.kt @@ -0,0 +1,30 @@ +package com.pluto.tool.modules.ruler.internal.hint + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.pluto.R +import com.pluto.tool.modules.ruler.internal.RulerScaleView.Companion.SCALE_GAP + +internal class HintViewModel(application: Application) : AndroidViewModel(application) { + + val list: LiveData> + get() = _list + private val _list = MutableLiveData>() + + init { + generate(getApplication()) + } + + private fun generate(context: Context?) { + context?.apply { + val list = arrayListOf() + list.add(HintItem(getString(R.string.pluto___tool_ruler_hint_reset_position))) + list.add(HintItem(getString(R.string.pluto___tool_ruler_hint_measure))) + list.add(HintItem(getString(R.string.pluto___tool_ruler_hint_scale_gap, SCALE_GAP))) + _list.postValue(list) + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/modules/screenHistory/ScreenHistoryTool.kt b/pluto/lib/src/main/java/com/pluto/tool/modules/screenHistory/ScreenHistoryTool.kt new file mode 100644 index 000000000..3179b9c50 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/modules/screenHistory/ScreenHistoryTool.kt @@ -0,0 +1,58 @@ +package com.pluto.tool.modules.screenHistory + +import com.pluto.R +import com.pluto.tool.PlutoTool +import com.pluto.tool.ToolConfiguration + +/** + * A tool that provides screen history tracking functionality. + * + * This tool is designed to track and display the history of screens (activities and fragments) + * that the user has navigated through in the application. It helps developers understand + * the navigation flow and debug navigation-related issues. + * + * Note: This appears to be a placeholder implementation with minimal functionality. + * The actual screen history tracking logic would need to be implemented in the + * onToolSelected method. + */ +internal class ScreenHistoryTool : PlutoTool("screenHistory") { + /** + * Provides the configuration for this tool, including its name and icon. + * + * @return A ToolConfiguration object with the tool's display properties + */ + override fun getConfig(): ToolConfiguration = ToolConfiguration( + name = application.getString(R.string.pluto___tool_screen_history_name), + icon = R.drawable.pluto___tool_ic_screen_history_logo + ) + + /** + * Called when the tool is initialized. + * No specific initialization is implemented for this tool. + */ + override fun onToolInitialised() { + } + + /** + * Called when the tool is selected by the user. + * This would be where the screen history display would be triggered, + * but the implementation is currently empty. + */ + override fun onToolSelected() { + } + + /** + * Called when the tool is unselected by the user. + * No specific cleanup is implemented for this tool. + */ + override fun onToolUnselected() { + } + + /** + * Determines if this tool is enabled. + * The screen history tool is always enabled as it doesn't require special permissions. + * + * @return Always returns true as this tool is always enabled + */ + override fun isEnabled(): Boolean = true +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/selector/ToolAdapter.kt b/pluto/lib/src/main/java/com/pluto/tool/selector/ToolAdapter.kt new file mode 100644 index 000000000..dbd3c3d61 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/selector/ToolAdapter.kt @@ -0,0 +1,27 @@ +package com.pluto.tool.selector + +import android.view.ViewGroup +import com.pluto.tool.PlutoTool +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem + +class ToolAdapter(private val listener: OnActionListener) : BaseAdapter() { + override fun getItemViewType(item: ListItem): Int? { + return when (item) { + is PlutoTool -> ITEM_TYPE_PLUGIN + else -> null + } + } + + override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { + return when (viewType) { + ITEM_TYPE_PLUGIN -> ToolItemHolder(parent, listener) + else -> null + } + } + + companion object { + const val ITEM_TYPE_PLUGIN = 1000 + } +} diff --git a/pluto/lib/src/main/java/com/pluto/tool/selector/ToolItemHolder.kt b/pluto/lib/src/main/java/com/pluto/tool/selector/ToolItemHolder.kt new file mode 100644 index 000000000..b44382abd --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/tool/selector/ToolItemHolder.kt @@ -0,0 +1,53 @@ +package com.pluto.tool.selector + +import android.view.ViewGroup +import android.view.animation.OvershootInterpolator +import com.pluto.R +import com.pluto.databinding.PlutoItemToolBinding +import com.pluto.tool.PlutoTool +import com.pluto.ui.selector.SelectorActivity.Companion.ANIMATION_DURATION +import com.pluto.ui.selector.loadAnimation +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.inflate +import com.pluto.utilities.extensions.setListener +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener + +internal class ToolItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : + DiffAwareHolder(parent.inflate(R.layout.pluto___item_tool), actionListener) { + + private val binding = PlutoItemToolBinding.bind(itemView) + private val name = binding.name + private val icon = binding.icon + private val iconCard = binding.iconCard + + override fun onBind(item: ListItem) { + if (item is PlutoTool) { + icon.setImageResource(item.getConfig().icon) + name.text = item.getConfig().name + if (item.isEnabled()) { + iconCard.setCardBackgroundColor(context.color(com.pluto.plugin.R.color.pluto___white)) + name.setTextColor(context.color(com.pluto.plugin.R.color.pluto___white)) + binding.root.setOnDebounceClickListener(haptic = true) { + val scale = context.loadAnimation(R.anim.pluto___click_bounce) + scale.duration = ANIMATION_DURATION + scale.interpolator = OvershootInterpolator() + scale.setListener { + onAnimationStart { + } + onAnimationEnd { + onAction("click") + } + } + it.startAnimation(scale) + } + } else { + iconCard.setCardBackgroundColor(context.color(com.pluto.plugin.R.color.pluto___white_40)) + name.setTextColor(context.color(com.pluto.plugin.R.color.pluto___white_60)) + binding.root.setOnDebounceClickListener(action = null) + } + } + } +} diff --git a/pluto/lib/src/main/java/com/pluto/ui/ListWrapper.kt b/pluto/lib/src/main/java/com/pluto/ui/ListWrapper.kt new file mode 100644 index 000000000..65318302f --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/ui/ListWrapper.kt @@ -0,0 +1,7 @@ +package com.pluto.ui + +import com.pluto.utilities.list.ListItem + +internal class ListWrapper(private val data: T) : ListItem() { + fun get(): T = data +} diff --git a/pluto/lib/src/main/java/com/pluto/ui/RoundedFrameLayout.kt b/pluto/lib/src/main/java/com/pluto/ui/RoundedFrameLayout.kt new file mode 100644 index 000000000..63640e2f6 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/ui/RoundedFrameLayout.kt @@ -0,0 +1,116 @@ +package com.pluto.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Path +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import com.pluto.R + +/** + * A not-so-efficient rounded-rectangle-clipped FrameLayout, until Android-L's clipping Outlines are + * widely available. + * + * NOTE: This doesn't support anti-aliasing, if you just need a rounded ImageView use the more + * efficient BitmapDrawable method which does: http://evel.io/2013/07/21/rounded-avatars-in-android/ + * + * In my case the rounded corner was to mask an image as you scrolled them in a ViewPager, so by + * combining this with a RoundedImageView it appears anti-aliased unless actively scrolling. + * + * Created by richardleggett on 04/09/2014. + */ +internal class RoundedFrameLayout : FrameLayout { + + private var path: Path? = null + private var rect: RectF? = null + private var radiiArray = floatArrayOf(DEF_RADIUS, DEF_RADIUS, DEF_RADIUS, DEF_RADIUS, DEF_RADIUS, DEF_RADIUS, DEF_RADIUS, DEF_RADIUS) + + constructor(context: Context) : super(context, null, 0) { + initView(null) + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs, 0) { + initView(attrs) + } + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super( + context, + attrs, + defStyle + ) { + initView(attrs) + } + + @SuppressLint("CustomViewStyleable") + private fun initView(attributes: AttributeSet?) { + if (attributes != null) { + val array = context.obtainStyledAttributes( + attributes, + R.styleable.pluto___RoundedFrameLayout + ) + val mCornerRadius = + array.getDimension(R.styleable.pluto___RoundedFrameLayout_rfl_corner_radius, -1f) + if (mCornerRadius != -1f) { + for (i in 0 until NO_OF_CORNERS) { + radiiArray[i] = mCornerRadius + } + } else { + val topLeft = + array.getDimension(R.styleable.pluto___RoundedFrameLayout_rfl_corner_radius_left_top, DEF_RADIUS) + val topRight = + array.getDimension(R.styleable.pluto___RoundedFrameLayout_rfl_corner_radius_right_top, DEF_RADIUS) + val bottomRight = + array.getDimension( + R.styleable.pluto___RoundedFrameLayout_rfl_corner_radius_right_bottom, + DEF_RADIUS + ) + val bottomLeft = + array.getDimension(R.styleable.pluto___RoundedFrameLayout_rfl_corner_radius_left_bottom, DEF_RADIUS) + radiiArray = floatArrayOf( + topLeft, + topLeft, + topRight, + topRight, + bottomRight, + bottomRight, + bottomLeft, + bottomLeft + ) + } + array.recycle() + } + + path = Path() + rect = RectF() + + setWillNotDraw(false) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + path?.let { path -> + path.reset() + rect?.let { rect -> + rect.set(0f, 0f, w.toFloat(), h.toFloat()) + path.addRoundRect(rect, radiiArray, Path.Direction.CCW) + } + path.close() + } + } + + override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean { + val count = canvas.save() + canvas.clipPath(path!!) + val result = super.drawChild(canvas, child, drawingTime) + canvas.restoreToCount(count) + return result + } + + private companion object { + const val DEF_RADIUS = 0f + const val NO_OF_CORNERS = 8 + } +} diff --git a/pluto/lib/src/main/java/com/pluto/ui/container/PlutoActivity.kt b/pluto/lib/src/main/java/com/pluto/ui/container/PlutoActivity.kt new file mode 100644 index 000000000..4444351cb --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/ui/container/PlutoActivity.kt @@ -0,0 +1,122 @@ +package com.pluto.ui.container + +import android.content.Intent +import android.os.Bundle +import android.view.WindowManager +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.lifecycle.Observer +import com.pluto.Pluto +import com.pluto.R +import com.pluto.databinding.PlutoActivityPlutoBinding +import com.pluto.plugin.libinterface.NotificationInterface.Companion.ID_LABEL +import com.pluto.plugin.share.ContentShareViewModel +import com.pluto.plugin.share.ShareAction +import com.pluto.plugin.share.ShareFragment +import com.pluto.plugin.share.copyToClipboard +import com.pluto.plugin.share.lazyContentSharer +import com.pluto.plugin.share.share +import com.pluto.plugin.share.shareFile +import com.pluto.utilities.extensions.toast +import com.pluto.utilities.selector.lazyDataSelector +import com.pluto.utilities.selector.ui.DataSelectorDialog +import com.pluto.utilities.views.keyvalue.edit.KeyValuePairEditDialog +import com.pluto.utilities.views.keyvalue.edit.KeyValuePairEditor +import com.pluto.utilities.views.keyvalue.edit.lazyKeyValuePairEditor + +class PlutoActivity : AppCompatActivity() { + + private val sharer: ContentShareViewModel by lazyContentSharer() + private val keyValuePairEditor: KeyValuePairEditor by lazyKeyValuePairEditor() + private val selector by lazyDataSelector() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = PlutoActivityPlutoBinding.inflate(layoutInflater) + setContentView(binding.root) + WindowCompat.setDecorFitsSystemWindows(window, false) + window.setFlags( + WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION, + WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION + ) + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + window.statusBarColor = ContextCompat.getColor(this, com.pluto.plugin.R.color.pluto___dark) + sharer.data.observe(this) { + val shareFragment = ShareFragment() + shareFragment.arguments = Bundle().apply { + putString("title", it.title) + putString("content", it.content) + putString("fileName", it.fileName) + } + shareFragment.show(supportFragmentManager, "bottomSheetFragment") + } + keyValuePairEditor.data.observe(this) { + KeyValuePairEditDialog().apply { + show(supportFragmentManager, "keyValuePairEditor") + } + } + + selector.data.observe(this) { + DataSelectorDialog().apply { show(supportFragmentManager, "selector") } + } + + sharer.action.observe(this) { + when (it) { + is ShareAction.ShareAsText -> share(it.shareable.processedContent, it.shareable.title) + is ShareAction.ShareAsFile -> shareFile(it.shareable.processedContent, it.shareable.title, it.shareable.fileName, it.type) + is ShareAction.ShareAsCopy -> copyContent(it.shareable.content, it.shareable.title) + } + } + handleIntent(intent) + Pluto.resetDataCallback.state.removeObserver(uiStateListener) + Pluto.resetDataCallback.state.observe(this, uiStateListener) + } + + private fun copyContent(content: String, title: String) { + copyToClipboard(content, title) + toast("Content copied to Clipboard") + } + + private fun handleIntent(intent: Intent?) { + intent?.getStringExtra(ID_LABEL)?.let { id -> + Pluto.pluginManager.get(id)?.let { + val fragment = it.getView() + supportFragmentManager.beginTransaction().apply { + this.runOnCommit { + it.onPluginViewCreated(it.savedInstance) + } + this.replace(R.id.container, fragment).commit() + } + return + } + applicationContext.toast("Plugin [$id] not installed") + finish() + } + } + + private val uiStateListener = Observer { + if (it) { + finish() + } + } + +// override fun onResume() { +// super.onResume() +// if (!Pluto.session.isConsentAlreadyShown && Pluto.notch?.enabled == true && !canDrawOverlays()) { +// lifecycleScope.delayedLaunchWhenResumed(CONSENT_SHOW_DELAY) { +// OverConsentFragment().show(supportFragmentManager, CONSENT_SHOW_TAG) +// } +// } +// } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + handleIntent(intent) + } + + private companion object { + const val CONSENT_SHOW_DELAY = 400L + const val CONSENT_SHOW_TAG = "overlay_consent" + } +} diff --git a/pluto/lib/src/main/java/com/pluto/ui/selector/DevDetailsFragment.kt b/pluto/lib/src/main/java/com/pluto/ui/selector/DevDetailsFragment.kt new file mode 100644 index 000000000..4967b4307 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/ui/selector/DevDetailsFragment.kt @@ -0,0 +1,59 @@ +package com.pluto.ui.selector + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.R +import com.pluto.databinding.PlutoFragmentDevDetailsBinding +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan +import com.pluto.utilities.viewBinding + +internal class DevDetailsFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoFragmentDevDetailsBinding::bind) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto___fragment_dev_details, container, false) + + override fun getTheme(): Int = R.style.PlutoBottomSheetDialogTheme + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + arguments?.getInt("icon")?.let { binding.icon.setImageResource(it) } + arguments?.getString("name")?.let { binding.name.text = it } + arguments?.getString("version")?.let { + binding.version.setSpan { + append(light("ver ")) + append(semiBold(it)) + } + } + arguments?.getString("website")?.let { + binding.website.text = it + binding.website.setOnDebounceClickListener { _ -> + openUrl(it) + } + } + arguments?.getString("vcs")?.let { + binding.vcsLink.text = it + binding.vcsLink.setOnDebounceClickListener { _ -> + openUrl(it) + } + } + arguments?.getString("twitter")?.let { + binding.twitterLink.text = it + binding.twitterLink.setOnDebounceClickListener { _ -> + openUrl(it) + } + } + } + + private fun openUrl(it: String) { + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(it)) + requireContext().startActivity(browserIntent) + } +} diff --git a/pluto/lib/src/main/java/com/pluto/ui/selector/GroupSelectorFragment.kt b/pluto/lib/src/main/java/com/pluto/ui/selector/GroupSelectorFragment.kt new file mode 100644 index 000000000..73561d882 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/ui/selector/GroupSelectorFragment.kt @@ -0,0 +1,69 @@ +package com.pluto.ui.selector + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.R +import com.pluto.databinding.PlutoFragmentGroupSelectorBinding +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginGroup +import com.pluto.plugin.selector.PluginGroupAdapter +import com.pluto.ui.ListWrapper +import com.pluto.utilities.extensions.dp +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.CustomItemDecorator +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.viewBinding + +internal class GroupSelectorFragment : BottomSheetDialogFragment() { + + private val binding by viewBinding(PlutoFragmentGroupSelectorBinding::bind) + private val pluginsGroupViewModel by activityViewModels() + private val pluginAdapter: BaseAdapter by lazy { PluginGroupAdapter(onActionListener) } + private lateinit var selectorUtils: SelectorUtils + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + inflater.inflate(R.layout.pluto___fragment_group_selector, container, false) + + override fun getTheme(): Int = R.style.PlutoBottomSheetDialogTheme + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + selectorUtils = SelectorUtils(this) + + binding.list.apply { + adapter = pluginAdapter + layoutManager = LinearLayoutManager(context) + addItemDecoration(CustomItemDecorator(context, DECORATOR_DIVIDER_PADDING)) + } + + pluginsGroupViewModel.current.removeObserver(pluginGroupObserver) + pluginsGroupViewModel.current.observe(this, pluginGroupObserver) + } + + private val pluginGroupObserver = Observer { + binding.title.text = it.getConfig().name + pluginAdapter.list = arrayListOf>().apply { + it.installedPlugins.forEach { plugin -> + add(ListWrapper(plugin)) + } + } + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + selectorUtils.onSelect(action, data) + } + } + + private companion object { + val DECORATOR_DIVIDER_PADDING = 16f.dp.toInt() + } +} diff --git a/pluto/lib/src/main/java/com/pluto/ui/selector/PluginsGroupViewModel.kt b/pluto/lib/src/main/java/com/pluto/ui/selector/PluginsGroupViewModel.kt new file mode 100644 index 000000000..2e8a848a8 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/ui/selector/PluginsGroupViewModel.kt @@ -0,0 +1,18 @@ +package com.pluto.ui.selector + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import com.pluto.plugin.PluginGroup +import com.pluto.utilities.SingleLiveEvent + +internal class PluginsGroupViewModel(application: Application) : AndroidViewModel(application) { + + val current: LiveData + get() = _current + private val _current = SingleLiveEvent() + + fun set(group: PluginGroup) { + _current.postValue(group) + } +} diff --git a/pluto/lib/src/main/java/com/pluto/ui/selector/SelectorActivity.kt b/pluto/lib/src/main/java/com/pluto/ui/selector/SelectorActivity.kt new file mode 100644 index 000000000..d0da40fca --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/ui/selector/SelectorActivity.kt @@ -0,0 +1,166 @@ +package com.pluto.ui.selector + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.activity.viewModels +import androidx.annotation.AnimRes +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.GridLayoutManager +import com.pluto.Pluto +import com.pluto.R +import com.pluto.core.applifecycle.AppStateCallback +import com.pluto.databinding.PlutoActivityPluginSelectorBinding +import com.pluto.maven.MavenSession +import com.pluto.maven.MavenViewModel +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginEntity +import com.pluto.plugin.PluginGroup +import com.pluto.plugin.PluginsViewModel +import com.pluto.plugin.selector.PluginAdapter +import com.pluto.settings.SettingsFragment +import com.pluto.settings.SettingsViewModel +import com.pluto.tool.PlutoTool +import com.pluto.tool.ToolsViewModel +import com.pluto.tool.selector.ToolAdapter +import com.pluto.ui.ListWrapper +import com.pluto.utilities.extensions.canDrawOverlays +import com.pluto.utilities.extensions.color +import com.pluto.utilities.extensions.openOverlaySettings +import com.pluto.utilities.list.BaseAdapter +import com.pluto.utilities.list.DiffAwareAdapter +import com.pluto.utilities.list.DiffAwareHolder +import com.pluto.utilities.list.ListItem +import com.pluto.utilities.setOnDebounceClickListener +import com.pluto.utilities.spannable.setSpan + +class SelectorActivity : FragmentActivity() { + + private val pluginsViewModel by viewModels() + private val pluginAdapter: BaseAdapter by lazy { PluginAdapter(onActionListener) } + private val toolsViewModel by viewModels() + private val toolAdapter: BaseAdapter by lazy { ToolAdapter(onActionListener) } + private val settingsViewModel by viewModels() + private val mavenViewModel by viewModels() + private lateinit var binding: PlutoActivityPluginSelectorBinding + private lateinit var selectorUtils: SelectorUtils + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = PlutoActivityPluginSelectorBinding.inflate(layoutInflater) + setContentView(binding.root) + overridePendingTransition(R.anim.pluto___slide_in_bottom, R.anim.pluto___slide_out_bottom) + selectorUtils = SelectorUtils(this) + + binding.list.apply { + adapter = pluginAdapter + layoutManager = GridLayoutManager(context, GRID_SPAN_COUNT) + } + + binding.toolsList.apply { + adapter = toolAdapter + layoutManager = GridLayoutManager(context, GRID_SPAN_COUNT) + } + + binding.root.setOnDebounceClickListener { + finish() + } + + binding.version.setSpan { + append(fontColor(light("v"), context.color(com.pluto.plugin.R.color.pluto___white_40))) + append(regular(com.pluto.BuildConfig.VERSION_NAME)) + } + + binding.settings.setOnDebounceClickListener { + SettingsFragment().show(supportFragmentManager, "settings") + } + + binding.overlaySetting.visibility = if (canDrawOverlays()) GONE else VISIBLE + binding.overlaySetting.setOnDebounceClickListener { + openOverlaySettings() + } + binding.mavenVersion.setOnDebounceClickListener { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(MavenSession.releaseUrl))) + } + + Pluto.appStateCallback.state.removeObserver(appStateListener) + Pluto.appStateCallback.state.observe(this, appStateListener) + + pluginsViewModel.plugins.removeObserver(pluginListObserver) + pluginsViewModel.plugins.observe(this, pluginListObserver) + toolsViewModel.tools.removeObserver(toolListObserver) + toolsViewModel.tools.observe(this, toolListObserver) + mavenViewModel.latestVersion.removeObserver(mavenVersionObserver) + mavenViewModel.latestVersion.observe(this, mavenVersionObserver) + + settingsViewModel.resetAll.observe(this) { + Pluto.pluginManager.installedPlugins.forEach { + when (it) { + is Plugin -> it.onPluginDataCleared() + is PluginGroup -> it.installedPlugins.forEach { plugin -> plugin.onPluginDataCleared() } + } + } + Pluto.resetDataCallback.state.postValue(true) + } + + mavenViewModel.getLatestVersion() + } + + private val pluginListObserver = Observer> { + pluginAdapter.list = arrayListOf>().apply { + it.forEach { entity -> + add(ListWrapper(entity)) + } + } + if (it.isNullOrEmpty()) { + binding.noPluginView.visibility = if (it.isNullOrEmpty()) VISIBLE else GONE + } + } + + private val toolListObserver = Observer> { + toolAdapter.list = it + } + + private val mavenVersionObserver = Observer { + binding.mavenVersion.isVisible = !it.isNullOrEmpty() + binding.mavenVersion.text = String.format(getString(R.string.pluto___new_version_available_text), it) + } + + private val appStateListener = Observer { + if (it is AppStateCallback.State.Background) { + finish() + } + } + + private val onActionListener = object : DiffAwareAdapter.OnActionListener { + override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder) { + selectorUtils.onSelect(action, data) + } + } + + override fun onStart() { + super.onStart() + Pluto.selectorStateCallback.state.postValue(true) + } + + override fun onStop() { + super.onStop() + Pluto.selectorStateCallback.state.postValue(false) + } + + companion object { + private const val GRID_SPAN_COUNT = 4 + const val ANIMATION_DURATION = 250L + } +} + +fun Context.loadAnimation(@AnimRes id: Int): Animation { + return AnimationUtils.loadAnimation(this, id) +} diff --git a/pluto/lib/src/main/java/com/pluto/ui/selector/SelectorStateCallback.kt b/pluto/lib/src/main/java/com/pluto/ui/selector/SelectorStateCallback.kt new file mode 100644 index 000000000..11ceb4392 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/ui/selector/SelectorStateCallback.kt @@ -0,0 +1,7 @@ +package com.pluto.ui.selector + +import androidx.lifecycle.MutableLiveData + +internal class SelectorStateCallback { + val state = MutableLiveData() +} diff --git a/pluto/lib/src/main/java/com/pluto/ui/selector/SelectorUtils.kt b/pluto/lib/src/main/java/com/pluto/ui/selector/SelectorUtils.kt new file mode 100644 index 000000000..cda9c3f82 --- /dev/null +++ b/pluto/lib/src/main/java/com/pluto/ui/selector/SelectorUtils.kt @@ -0,0 +1,73 @@ +package com.pluto.ui.selector + +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import com.pluto.Pluto +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginGroup +import com.pluto.tool.PlutoTool +import com.pluto.ui.ListWrapper +import com.pluto.utilities.list.ListItem + +internal class SelectorUtils { + + private val containerActivity: FragmentActivity + private lateinit var pluginsGroupViewModel: PluginsGroupViewModel + + constructor(activity: FragmentActivity) { + containerActivity = activity + init() + } + + constructor(fragment: Fragment) { + containerActivity = fragment.requireActivity() + init() + } + + private fun init() { + pluginsGroupViewModel = ViewModelProvider( + containerActivity, + ViewModelProvider.AndroidViewModelFactory(containerActivity.application) + ).get(PluginsGroupViewModel::class.java) + } + + fun onSelect(action: String, data: ListItem) { + when { + data is ListWrapper<*> && data.get() is Plugin -> { + val plugin = data.get() as Plugin + when (action) { + "click" -> { + Pluto.open(plugin.identifier) + containerActivity.finish() + } + + "long_click" -> { + val devDetailsFragment = DevDetailsFragment() + devDetailsFragment.arguments = Bundle().apply { + putString("name", plugin.getConfig().name) + putInt("icon", plugin.getConfig().icon) + putString("version", plugin.getConfig().version) + putString("website", plugin.getDeveloperDetails()?.website) + putString("vcs", plugin.getDeveloperDetails()?.vcsLink) + putString("twitter", plugin.getDeveloperDetails()?.twitter) + } + devDetailsFragment.show(containerActivity.supportFragmentManager, "devDetails") + } + } + } + + data is ListWrapper<*> && data.get() is PluginGroup -> { + pluginsGroupViewModel.set(data.get() as PluginGroup) + val groupSelectorFragment = GroupSelectorFragment() + groupSelectorFragment.show(containerActivity.supportFragmentManager, "groupSelector") + } + + data is PlutoTool -> { + Pluto.toolManager.select(data.id) + containerActivity.finish() + } + } + } +} diff --git a/pluto/lib/src/main/res/anim/pluto___click_bounce.xml b/pluto/lib/src/main/res/anim/pluto___click_bounce.xml new file mode 100644 index 000000000..cc34ba93c --- /dev/null +++ b/pluto/lib/src/main/res/anim/pluto___click_bounce.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/pluto/lib/src/main/res/anim/pluto___fade_in_dialog.xml b/pluto/lib/src/main/res/anim/pluto___fade_in_dialog.xml new file mode 100644 index 000000000..3a1a72d43 --- /dev/null +++ b/pluto/lib/src/main/res/anim/pluto___fade_in_dialog.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/anim/pluto___fade_out_dialog.xml b/pluto/lib/src/main/res/anim/pluto___fade_out_dialog.xml new file mode 100644 index 000000000..9e462d2b6 --- /dev/null +++ b/pluto/lib/src/main/res/anim/pluto___fade_out_dialog.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/anim/pluto___slide_in_bottom.xml b/pluto/lib/src/main/res/anim/pluto___slide_in_bottom.xml new file mode 100644 index 000000000..d9700b09c --- /dev/null +++ b/pluto/lib/src/main/res/anim/pluto___slide_in_bottom.xml @@ -0,0 +1,5 @@ + + diff --git a/pluto/lib/src/main/res/anim/pluto___slide_out_bottom.xml b/pluto/lib/src/main/res/anim/pluto___slide_out_bottom.xml new file mode 100644 index 000000000..1540acf35 --- /dev/null +++ b/pluto/lib/src/main/res/anim/pluto___slide_out_bottom.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_about.xml b/pluto/lib/src/main/res/drawable/pluto___ic_about.xml new file mode 100644 index 000000000..8bdea57c5 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_about.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_back.xml b/pluto/lib/src/main/res/drawable/pluto___ic_back.xml new file mode 100644 index 000000000..7b9304567 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_check_box.xml b/pluto/lib/src/main/res/drawable/pluto___ic_check_box.xml new file mode 100644 index 000000000..0dd9417a4 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_check_box.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_check_box_selected.xml b/pluto/lib/src/main/res/drawable/pluto___ic_check_box_selected.xml new file mode 100644 index 000000000..b0465d9c3 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_check_box_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_check_box_unselected.xml b/pluto/lib/src/main/res/drawable/pluto___ic_check_box_unselected.xml new file mode 100644 index 000000000..8489e8f84 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_check_box_unselected.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_chevron_right.xml b/pluto/lib/src/main/res/drawable/pluto___ic_chevron_right.xml new file mode 100644 index 000000000..c5907132a --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_easy_access.xml b/pluto/lib/src/main/res/drawable/pluto___ic_easy_access.xml similarity index 86% rename from pluto/src/main/res/drawable/pluto___ic_easy_access.xml rename to pluto/lib/src/main/res/drawable/pluto___ic_easy_access.xml index 12cf0d790..7d6a5bf5e 100644 --- a/pluto/src/main/res/drawable/pluto___ic_easy_access.xml +++ b/pluto/lib/src/main/res/drawable/pluto___ic_easy_access.xml @@ -1,9 +1,9 @@ diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_error.xml b/pluto/lib/src/main/res/drawable/pluto___ic_error.xml new file mode 100644 index 000000000..d13af702b --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_grid.xml b/pluto/lib/src/main/res/drawable/pluto___ic_grid.xml new file mode 100644 index 000000000..cab8add31 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_grid.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_grid_dark.xml b/pluto/lib/src/main/res/drawable/pluto___ic_grid_dark.xml new file mode 100644 index 000000000..8996a64fc --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_grid_dark.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/src/main/res/drawable/pluto___ic_launcher.webp b/pluto/lib/src/main/res/drawable/pluto___ic_launcher.webp similarity index 100% rename from pluto/src/main/res/drawable/pluto___ic_launcher.webp rename to pluto/lib/src/main/res/drawable/pluto___ic_launcher.webp diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_more.xml b/pluto/lib/src/main/res/drawable/pluto___ic_more.xml new file mode 100644 index 000000000..e5db6edf0 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_settings.xml b/pluto/lib/src/main/res/drawable/pluto___ic_settings.xml new file mode 100644 index 000000000..b62256671 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_settings_grid_size_dec.xml b/pluto/lib/src/main/res/drawable/pluto___ic_settings_grid_size_dec.xml new file mode 100644 index 000000000..2af9c9620 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_settings_grid_size_dec.xml @@ -0,0 +1,10 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_settings_grid_size_inc.xml b/pluto/lib/src/main/res/drawable/pluto___ic_settings_grid_size_inc.xml new file mode 100644 index 000000000..17af43bf8 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_settings_grid_size_inc.xml @@ -0,0 +1,10 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_twitter.xml b/pluto/lib/src/main/res/drawable/pluto___ic_twitter.xml new file mode 100644 index 000000000..9b90f1899 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_twitter.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_vcs.xml b/pluto/lib/src/main/res/drawable/pluto___ic_vcs.xml new file mode 100644 index 000000000..ac018e324 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_vcs.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___ic_website.xml b/pluto/lib/src/main/res/drawable/pluto___ic_website.xml new file mode 100644 index 000000000..1ebff6020 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___ic_website.xml @@ -0,0 +1,14 @@ + + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___tool_ic_current_screen_logo.xml b/pluto/lib/src/main/res/drawable/pluto___tool_ic_current_screen_logo.xml new file mode 100644 index 000000000..5d523e069 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___tool_ic_current_screen_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___tool_ic_grid_logo.xml b/pluto/lib/src/main/res/drawable/pluto___tool_ic_grid_logo.xml new file mode 100644 index 000000000..7fb83b06f --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___tool_ic_grid_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_close.xml b/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_close.xml new file mode 100644 index 000000000..ae90a27e1 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_hint.xml b/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_hint.xml new file mode 100644 index 000000000..333d22a71 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_hint.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_move_left.xml b/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_move_left.xml new file mode 100644 index 000000000..a9750778e --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_move_left.xml @@ -0,0 +1,14 @@ + + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_move_right.xml b/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_move_right.xml new file mode 100644 index 000000000..07d93aca2 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_control_move_right.xml @@ -0,0 +1,14 @@ + + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_logo.xml b/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_logo.xml new file mode 100644 index 000000000..3c4a8a7e4 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___tool_ic_ruler_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___tool_ic_screen_history_logo.xml b/pluto/lib/src/main/res/drawable/pluto___tool_ic_screen_history_logo.xml new file mode 100644 index 000000000..b7d591e36 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___tool_ic_screen_history_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/pluto/lib/src/main/res/drawable/pluto___tool_item_divider.xml b/pluto/lib/src/main/res/drawable/pluto___tool_item_divider.xml new file mode 100644 index 000000000..8e82856f1 --- /dev/null +++ b/pluto/lib/src/main/res/drawable/pluto___tool_item_divider.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___activity_plugin_selector.xml b/pluto/lib/src/main/res/layout/pluto___activity_plugin_selector.xml new file mode 100644 index 000000000..491857821 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___activity_plugin_selector.xml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___activity_pluto.xml b/pluto/lib/src/main/res/layout/pluto___activity_pluto.xml new file mode 100644 index 000000000..dc7d3a2c5 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___activity_pluto.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___fragment_dev_details.xml b/pluto/lib/src/main/res/layout/pluto___fragment_dev_details.xml new file mode 100644 index 000000000..be67dca9e --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___fragment_dev_details.xml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___fragment_group_selector.xml b/pluto/lib/src/main/res/layout/pluto___fragment_group_selector.xml new file mode 100644 index 000000000..ec854b314 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___fragment_group_selector.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___fragment_overlay_consent.xml b/pluto/lib/src/main/res/layout/pluto___fragment_overlay_consent.xml new file mode 100644 index 000000000..23f0e6f02 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___fragment_overlay_consent.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___fragment_settings.xml b/pluto/lib/src/main/res/layout/pluto___fragment_settings.xml new file mode 100644 index 000000000..cd7c3d044 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___fragment_settings.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___item_plugin.xml b/pluto/lib/src/main/res/layout/pluto___item_plugin.xml new file mode 100644 index 000000000..402d4ddb1 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___item_plugin.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___item_plugin_group.xml b/pluto/lib/src/main/res/layout/pluto___item_plugin_group.xml new file mode 100644 index 000000000..f8588e9cf --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___item_plugin_group.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___item_plugin_group_child.xml b/pluto/lib/src/main/res/layout/pluto___item_plugin_group_child.xml new file mode 100644 index 000000000..05acec18d --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___item_plugin_group_child.xml @@ -0,0 +1,38 @@ + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___item_plugin_option.xml b/pluto/lib/src/main/res/layout/pluto___item_plugin_option.xml new file mode 100644 index 000000000..ef9cdac6e --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___item_plugin_option.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_settings_easy_access.xml b/pluto/lib/src/main/res/layout/pluto___item_settings_easy_access.xml similarity index 97% rename from pluto/src/main/res/layout/pluto___item_settings_easy_access.xml rename to pluto/lib/src/main/res/layout/pluto___item_settings_easy_access.xml index 0730424a8..ae330073c 100644 --- a/pluto/src/main/res/layout/pluto___item_settings_easy_access.xml +++ b/pluto/lib/src/main/res/layout/pluto___item_settings_easy_access.xml @@ -17,7 +17,7 @@ android:fontFamily="@font/muli_semibold" android:text="@string/pluto___settings_easy_access_title" android:textColor="@color/pluto___text_dark" - android:textSize="@dimen/pluto___text_xmedium" + android:textSize="@dimen/pluto___text_small" app:layout_constraintEnd_toStartOf="@+id/checkbox" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -32,7 +32,7 @@ android:fontFamily="@font/muli" android:text="@string/pluto___easy_access_setup_description" android:textColor="@color/pluto___text_dark_40" - android:textSize="@dimen/pluto___text_small" + android:textSize="@dimen/pluto___text_xxsmall" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="@+id/title" app:layout_constraintHorizontal_bias="0" diff --git a/pluto/src/main/res/layout/pluto___item_settings_easy_access_appearance.xml b/pluto/lib/src/main/res/layout/pluto___item_settings_easy_access_appearance.xml similarity index 97% rename from pluto/src/main/res/layout/pluto___item_settings_easy_access_appearance.xml rename to pluto/lib/src/main/res/layout/pluto___item_settings_easy_access_appearance.xml index 6c7cdf0da..a9664550d 100644 --- a/pluto/src/main/res/layout/pluto___item_settings_easy_access_appearance.xml +++ b/pluto/lib/src/main/res/layout/pluto___item_settings_easy_access_appearance.xml @@ -15,7 +15,7 @@ android:layout_marginVertical="@dimen/pluto___margin_large" android:fontFamily="@font/muli" android:textColor="@color/pluto___text_dark" - android:textSize="@dimen/pluto___text_xmedium" + android:textSize="@dimen/pluto___text_small" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/checkbox" app:layout_constraintStart_toStartOf="parent" diff --git a/pluto/lib/src/main/res/layout/pluto___item_settings_grid_size.xml b/pluto/lib/src/main/res/layout/pluto___item_settings_grid_size.xml new file mode 100644 index 000000000..904a91f94 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___item_settings_grid_size.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_settings_reset_all.xml b/pluto/lib/src/main/res/layout/pluto___item_settings_reset_all.xml similarity index 98% rename from pluto/src/main/res/layout/pluto___item_settings_reset_all.xml rename to pluto/lib/src/main/res/layout/pluto___item_settings_reset_all.xml index f7c40a17f..b4996329a 100644 --- a/pluto/src/main/res/layout/pluto___item_settings_reset_all.xml +++ b/pluto/lib/src/main/res/layout/pluto___item_settings_reset_all.xml @@ -20,7 +20,7 @@ android:gravity="center_vertical" android:text="@string/pluto___settings_reset_all_title" android:textColor="@color/pluto___text_dark" - android:textSize="@dimen/pluto___text_xmedium" + android:textSize="@dimen/pluto___text_small" app:layout_constraintEnd_toStartOf="@+id/cta" app:layout_constraintStart_toStartOf="parent" android:drawableLeft="@drawable/pluto___ic_error" @@ -36,7 +36,7 @@ android:fontFamily="@font/muli" android:text="@string/pluto___settings_reset_all_description" android:textColor="@color/pluto___text_dark_40" - android:textSize="@dimen/pluto___text_small" + android:textSize="@dimen/pluto___text_xxsmall" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="@+id/title" app:layout_constraintHorizontal_bias="0" diff --git a/pluto/lib/src/main/res/layout/pluto___item_settings_theme.xml b/pluto/lib/src/main/res/layout/pluto___item_settings_theme.xml new file mode 100644 index 000000000..5857a0fd7 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___item_settings_theme.xml @@ -0,0 +1,54 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___item_tool.xml b/pluto/lib/src/main/res/layout/pluto___item_tool.xml new file mode 100644 index 000000000..d9fb89fe8 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___item_tool.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___layout_notch.xml b/pluto/lib/src/main/res/layout/pluto___layout_notch.xml new file mode 100644 index 000000000..323daa71b --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___layout_notch.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___layout_plugin_options_dialog.xml b/pluto/lib/src/main/res/layout/pluto___layout_plugin_options_dialog.xml new file mode 100644 index 000000000..98753ca80 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___layout_plugin_options_dialog.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___tool_current_screen_view.xml b/pluto/lib/src/main/res/layout/pluto___tool_current_screen_view.xml new file mode 100644 index 000000000..293d40234 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___tool_current_screen_view.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___tool_item_control_cta.xml b/pluto/lib/src/main/res/layout/pluto___tool_item_control_cta.xml new file mode 100644 index 000000000..5956fd8fc --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___tool_item_control_cta.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___tool_item_hint.xml b/pluto/lib/src/main/res/layout/pluto___tool_item_hint.xml new file mode 100644 index 000000000..f7c1c7bd0 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___tool_item_hint.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___tool_ruler_activity.xml b/pluto/lib/src/main/res/layout/pluto___tool_ruler_activity.xml new file mode 100644 index 000000000..ad40e26fc --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___tool_ruler_activity.xml @@ -0,0 +1,30 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___tool_ruler_controls.xml b/pluto/lib/src/main/res/layout/pluto___tool_ruler_controls.xml new file mode 100644 index 000000000..1c42ca22c --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___tool_ruler_controls.xml @@ -0,0 +1,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/layout/pluto___tool_ruler_hint_fragment.xml b/pluto/lib/src/main/res/layout/pluto___tool_ruler_hint_fragment.xml new file mode 100644 index 000000000..c68566013 --- /dev/null +++ b/pluto/lib/src/main/res/layout/pluto___tool_ruler_hint_fragment.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/values/attr.xml b/pluto/lib/src/main/res/values/attr.xml new file mode 100644 index 000000000..f9008f6b6 --- /dev/null +++ b/pluto/lib/src/main/res/values/attr.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/lib/src/main/res/values/dimens.xml b/pluto/lib/src/main/res/values/dimens.xml new file mode 100644 index 000000000..dc91451e0 --- /dev/null +++ b/pluto/lib/src/main/res/values/dimens.xml @@ -0,0 +1,4 @@ + + + 30dp + \ No newline at end of file diff --git a/pluto/lib/src/main/res/values/strings.xml b/pluto/lib/src/main/res/values/strings.xml new file mode 100644 index 000000000..79ed6bd95 --- /dev/null +++ b/pluto/lib/src/main/res/values/strings.xml @@ -0,0 +1,52 @@ + + Plugins + Settings + About + Pluto Plugins + Developer Details + + Pluto is debugging %s + Tap here to view complete debug report + Close Pluto + no options available + Plugin Details + your website url goes here + your vcs url goes here + Enable Right handed mode for Access popup + Plugin data reset requested + Enable Notch + Show a Notch over your application to access the library. + Reset all data + This is clear all the data and app state. Choose this if the library is stuck in an unexpected error. + Reset + Notch settings updated + Enable Notch + Enable Draw over other Apps settings to show the notch for easy access to library. + Never ask again + Enable Now + Open + Pluto. + No Plugins installed yet. + Plugin Name goes here + plugin version goes here + plugin website goes here + plugin vcs link goes here + plugin twitter link goes here + Need Draw over other apps to enable all the tools. + + Tools + Ruler + Grid + Current Screen + Screen History + How to use + Click on the screen to set start position. + Drag your finger to measure the distance. + Scales are %d dp apart. + Enable Dark theme + Choose Pluto theme to control color schema. + Pluto theme updated + Modify Grid dimensions + Manage the size of grid blocks of Ruler, GridView & other layout plugins. + New version v%s available + \ No newline at end of file diff --git a/pluto/lib/src/main/res/values/styles.xml b/pluto/lib/src/main/res/values/styles.xml new file mode 100644 index 000000000..e7765cdd6 --- /dev/null +++ b/pluto/lib/src/main/res/values/styles.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pluto/src/main/res/xml/pluto___file_provider_paths.xml b/pluto/lib/src/main/res/xml/pluto___file_provider_paths.xml similarity index 100% rename from pluto/src/main/res/xml/pluto___file_provider_paths.xml rename to pluto/lib/src/main/res/xml/pluto___file_provider_paths.xml diff --git a/pluto/lib/src/test/java/com/pluto/PlutoTest.kt b/pluto/lib/src/test/java/com/pluto/PlutoTest.kt new file mode 100644 index 000000000..b5e9a03a6 --- /dev/null +++ b/pluto/lib/src/test/java/com/pluto/PlutoTest.kt @@ -0,0 +1,219 @@ +package com.pluto + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.os.Bundle +import com.pluto.core.notch.Notch +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginGroup +import com.pluto.plugin.PluginManager +import com.pluto.plugin.libinterface.NotificationInterface.Companion.BUNDLE_LABEL +import com.pluto.plugin.libinterface.NotificationInterface.Companion.ID_LABEL +import com.pluto.ui.container.PlutoActivity +import com.pluto.ui.selector.SelectorActivity +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class PlutoTest { + + @Mock + private lateinit var mockApplication: Application + + @Mock + private lateinit var mockContext: Context + + @Mock + private lateinit var mockPluginManager: PluginManager + + @Mock + private lateinit var mockPlugin: Plugin + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + `when`(mockApplication.applicationContext).thenReturn(mockContext) + + // Initialize Pluto with required fields for testing + // Since Pluto is an object (singleton), we need to use reflection to set its state + + // First initialize the callbacks + val initCallbacksMethod = Pluto::class.java.getDeclaredMethod("initialiseCallbacks") + initCallbacksMethod.isAccessible = true + initCallbacksMethod.invoke(Pluto) + + // Set the application field + val applicationField = Pluto::class.java.getDeclaredField("application") + applicationField.isAccessible = true + applicationField.set(Pluto, mockApplication) + + // Set the pluginManager field + val pluginManagerField = Pluto::class.java.getDeclaredField("pluginManager") + pluginManagerField.isAccessible = true + pluginManagerField.set(Pluto, mockPluginManager) + } + + @Test + fun `installer should add plugins and install Pluto`() { + // Given + val installer = Pluto.Installer(mockApplication) + val mockPlugin = mock(Plugin::class.java) + val mockPluginGroup = mock(PluginGroup::class.java) + + // We need to access the init method since it's called by the installer + val initMethod = Pluto::class.java.getDeclaredMethod("init", Application::class.java, LinkedHashSet::class.java) + initMethod.isAccessible = true + + // When + installer.addPlugin(mockPlugin) + .addPluginGroup(mockPluginGroup) + + // Instead of calling install() which would call the real init method, + // we'll verify that the plugins were added correctly + val pluginsField = installer.javaClass.getDeclaredField("plugins") + pluginsField.isAccessible = true + val plugins = pluginsField.get(installer) as LinkedHashSet<*> + + // Then + assert(plugins.size == 2) + assert(plugins.contains(mockPlugin)) + assert(plugins.contains(mockPluginGroup)) + } + + @Test + fun `open should start SelectorActivity when identifier is null`() { + // Given + val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) + + // When + Pluto.open() + + // Then + verify(mockContext).startActivity(intentCaptor.capture()) + val capturedIntent = intentCaptor.value + assert(capturedIntent.component?.className == SelectorActivity::class.java.name) + assert(capturedIntent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + } + + @Test + fun `open should start PlutoActivity when valid identifier is provided`() { + // Given + val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) + val testIdentifier = "test_plugin" + val testBundle = Bundle() + `when`(mockPluginManager.get(testIdentifier)).thenReturn(mockPlugin) + + // When + Pluto.open(testIdentifier, testBundle) + + // Then + verify(mockContext).startActivity(intentCaptor.capture()) + val capturedIntent = intentCaptor.value + assert(capturedIntent.component?.className == PlutoActivity::class.java.name) + assert(capturedIntent.getStringExtra(ID_LABEL) == testIdentifier) + assert(capturedIntent.getBundleExtra(BUNDLE_LABEL) == testBundle) + assert(capturedIntent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + assert(capturedIntent.flags and Intent.FLAG_ACTIVITY_CLEAR_TOP != 0) + assert(capturedIntent.flags and Intent.FLAG_ACTIVITY_MULTIPLE_TASK != 0) + } + + @Test + fun `open should show toast when invalid identifier is provided`() { + // Given + val testIdentifier = "invalid_plugin" + `when`(mockPluginManager.get(testIdentifier)).thenReturn(null) + + // When + Pluto.open(testIdentifier) + + // Then + // We can't easily verify toast messages in Robolectric tests, + // but we can verify that startActivity was not called + verify(mockContext, never()).startActivity(any()) + } + + @Test + fun `clearLogs should call pluginManager clearLogs with null when no identifier is provided`() { + // When + Pluto.clearLogs() + + // Then + verify(mockPluginManager).clearLogs(null) + } + + @Test + fun `clearLogs should call pluginManager clearLogs with identifier when provided`() { + // Given + val testIdentifier = "test_plugin" + + // When + Pluto.clearLogs(testIdentifier) + + // Then + verify(mockPluginManager).clearLogs(testIdentifier) + } + + @Test + fun `showNotch should enable or disable notch based on state parameter`() { + // Given + val mockNotch = mock(Notch::class.java) + val field = Pluto::class.java.getDeclaredField("notch") + field.isAccessible = true + field.set(Pluto, mockNotch) + + // When - enable notch + Pluto.showNotch(true) + + // Then + verify(mockNotch).enable(true) + + // When - disable notch + Pluto.showNotch(false) + + // Then + verify(mockNotch).enable(false) + } + + @Test + fun `initialiseCallbacks should initialize all required callbacks`() { + // Given + // Use reflection to access the private method + val method = Pluto::class.java.getDeclaredMethod("initialiseCallbacks") + method.isAccessible = true + + // When + method.invoke(Pluto) + + // Then + // Verify that all callbacks are initialized by checking they're not null + val resetDataCallbackField = Pluto::class.java.getDeclaredField("resetDataCallback") + resetDataCallbackField.isAccessible = true + assert(resetDataCallbackField.get(Pluto) != null) + + val appStateCallbackField = Pluto::class.java.getDeclaredField("appStateCallback") + appStateCallbackField.isAccessible = true + assert(appStateCallbackField.get(Pluto) != null) + + val selectorStateCallbackField = Pluto::class.java.getDeclaredField("selectorStateCallback") + selectorStateCallbackField.isAccessible = true + assert(selectorStateCallbackField.get(Pluto) != null) + + val notchStateCallbackField = Pluto::class.java.getDeclaredField("notchStateCallback") + notchStateCallbackField.isAccessible = true + assert(notchStateCallbackField.get(Pluto) != null) + } +} diff --git a/pluto/lib/src/test/java/com/pluto/core/applifecycle/AppLifecycleTest.kt b/pluto/lib/src/test/java/com/pluto/core/applifecycle/AppLifecycleTest.kt new file mode 100644 index 000000000..3275e4f7d --- /dev/null +++ b/pluto/lib/src/test/java/com/pluto/core/applifecycle/AppLifecycleTest.kt @@ -0,0 +1,126 @@ +package com.pluto.core.applifecycle + +import android.app.Activity +import android.os.Bundle +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AppLifecycleTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var appLifecycle: AppLifecycle + private lateinit var appStateCallback: AppStateCallback + + @Mock + private lateinit var stateObserver: Observer + + @Mock + private lateinit var mockActivity: Activity + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + appStateCallback = AppStateCallback() + appStateCallback.state.observeForever(stateObserver) + appLifecycle = AppLifecycle(appStateCallback) + } + + @Test + fun `when first activity starts, app state should change to Foreground`() { + // When + appLifecycle.onActivityStarted(mockActivity) + + // Then + verify(stateObserver).onChanged(AppStateCallback.State.Foreground) + } + + @Test + fun `when second activity starts, app state should remain Foreground without additional updates`() { + // Given + appLifecycle.onActivityStarted(mockActivity) // First activity starts + verify(stateObserver).onChanged(AppStateCallback.State.Foreground) + + // When + val secondActivity = mock(Activity::class.java) + appLifecycle.onActivityStarted(secondActivity) // Second activity starts + + // Then - verify observer was only called once with Foreground (from the first activity) + verify(stateObserver).onChanged(AppStateCallback.State.Foreground) + } + + @Test + fun `when one activity stops but another is still running, app state should remain Foreground`() { + // Given + val secondActivity = mock(Activity::class.java) + appLifecycle.onActivityStarted(mockActivity) // First activity starts + appLifecycle.onActivityStarted(secondActivity) // Second activity starts + + // When + appLifecycle.onActivityStopped(mockActivity) // First activity stops + + // Then + verify(stateObserver, never()).onChanged(AppStateCallback.State.Background) + } + + @Test + fun `when all activities stop, app state should change to Background`() { + // Given + appLifecycle.onActivityStarted(mockActivity) // Activity starts + + // When + appLifecycle.onActivityStopped(mockActivity) // Activity stops + + // Then + verify(stateObserver).onChanged(AppStateCallback.State.Background) + } + + @Test + fun `when app goes to background and then foreground, both state changes should be observed`() { + // Given - app starts in foreground + appLifecycle.onActivityStarted(mockActivity) + verify(stateObserver).onChanged(AppStateCallback.State.Foreground) + + // When - app goes to background + appLifecycle.onActivityStopped(mockActivity) + verify(stateObserver).onChanged(AppStateCallback.State.Background) + + // When - app comes back to foreground + appLifecycle.onActivityStarted(mockActivity) + + // Then - verify Foreground state was observed again + verify(stateObserver, times(2)).onChanged(AppStateCallback.State.Foreground) + } + + @Test + fun `other lifecycle methods should not affect app state`() { + // Given + val bundle = mock(Bundle::class.java) + + // When + appLifecycle.onActivityCreated(mockActivity, bundle) + appLifecycle.onActivityResumed(mockActivity) + appLifecycle.onActivityPaused(mockActivity) + appLifecycle.onActivitySaveInstanceState(mockActivity, bundle) + appLifecycle.onActivityDestroyed(mockActivity) + + // Then + verify(stateObserver, never()).onChanged(AppStateCallback.State.Foreground) + verify(stateObserver, never()).onChanged(AppStateCallback.State.Background) + } +} diff --git a/pluto/lib/src/test/java/com/pluto/core/applifecycle/AppStateCallbackTest.kt b/pluto/lib/src/test/java/com/pluto/core/applifecycle/AppStateCallbackTest.kt new file mode 100644 index 000000000..55724c382 --- /dev/null +++ b/pluto/lib/src/test/java/com/pluto/core/applifecycle/AppStateCallbackTest.kt @@ -0,0 +1,73 @@ +package com.pluto.core.applifecycle + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AppStateCallbackTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var appStateCallback: AppStateCallback + + @Mock + private lateinit var stateObserver: Observer + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + appStateCallback = AppStateCallback() + appStateCallback.state.observeForever(stateObserver) + } + + @Test + fun `when state is set to Foreground, observer should receive Foreground state`() { + // When + appStateCallback.state.postValue(AppStateCallback.State.Foreground) + + // Then + verify(stateObserver).onChanged(AppStateCallback.State.Foreground) + } + + @Test + fun `when state is set to Background, observer should receive Background state`() { + // When + appStateCallback.state.postValue(AppStateCallback.State.Background) + + // Then + verify(stateObserver).onChanged(AppStateCallback.State.Background) + } + + @Test + fun `when state changes from Foreground to Background, observer should receive both states in order`() { + // When + appStateCallback.state.postValue(AppStateCallback.State.Foreground) + appStateCallback.state.postValue(AppStateCallback.State.Background) + + // Then + verify(stateObserver).onChanged(AppStateCallback.State.Foreground) + verify(stateObserver).onChanged(AppStateCallback.State.Background) + } + + @Test + fun `when state changes from Background to Foreground, observer should receive both states in order`() { + // When + appStateCallback.state.postValue(AppStateCallback.State.Background) + appStateCallback.state.postValue(AppStateCallback.State.Foreground) + + // Then + verify(stateObserver).onChanged(AppStateCallback.State.Background) + verify(stateObserver).onChanged(AppStateCallback.State.Foreground) + } +} diff --git a/pluto/proguard-rules.pro b/pluto/proguard-rules.pro deleted file mode 100644 index 202acce9e..000000000 --- a/pluto/proguard-rules.pro +++ /dev/null @@ -1,34 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. --renamesourcefileattribute SourceFile - --keep class kotlin.** { *; } --keep class kotlin.Metadata { *; } --dontwarn kotlin.** --keepclassmembers class **$WhenMappings { - ; -} --keepclassmembers class kotlin.Metadata { - public ; -} --assumenosideeffects class kotlin.jvm.internal.Intrinsics { - static void checkParameterIsNotNull(java.lang.Object, java.lang.String); -} \ No newline at end of file diff --git a/pluto/src/androidTest/java/com/mocklets/pluto/ExampleInstrumentedTest.kt b/pluto/src/androidTest/java/com/mocklets/pluto/ExampleInstrumentedTest.kt deleted file mode 100644 index 57812670e..000000000 --- a/pluto/src/androidTest/java/com/mocklets/pluto/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.mocklets.pluto - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.mocklets.pluto.test", appContext.packageName) - } -} diff --git a/pluto/src/main/AndroidManifest.xml b/pluto/src/main/AndroidManifest.xml deleted file mode 100644 index 8c22a1290..000000000 --- a/pluto/src/main/AndroidManifest.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/java/com/mocklets/pluto/Pluto.kt b/pluto/src/main/java/com/mocklets/pluto/Pluto.kt deleted file mode 100644 index 2c3aa7f36..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/Pluto.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.mocklets.pluto - -import android.app.Application -import android.content.Context -import android.content.Intent -import androidx.annotation.Keep -import com.mocklets.pluto.core.Session -import com.mocklets.pluto.core.preferences.Preferences -import com.mocklets.pluto.modules.activities.ActivityTracker -import com.mocklets.pluto.modules.exceptions.ANRListener -import com.mocklets.pluto.modules.exceptions.ExceptionRepo -import com.mocklets.pluto.modules.exceptions.anrs.AnrSupervisor -import com.mocklets.pluto.modules.exceptions.crashes.CrashHandler -import com.mocklets.pluto.modules.network.proxy.NetworkProxyRepo -import com.mocklets.pluto.ui.PlutoActivity - -@Keep -object Pluto { - internal var appContext: Context? = null - internal lateinit var activity: ActivityTracker - internal lateinit var exceptionRepo: ExceptionRepo - internal lateinit var preferences: Preferences - private lateinit var anrSupervisor: AnrSupervisor - internal lateinit var session: Session - internal var appProperties: HashMap = hashMapOf() - private var crashHandler: CrashHandler? = null - - @JvmOverloads - fun initialize(context: Context, shouldShowIntroToast: Boolean = true) { - if (appContext != null) { - return - } - - session = Session() - appContext = context.applicationContext - preferences = Preferences(context) - NetworkProxyRepo.init(context) - crashHandler = CrashHandler(context) - Thread.setDefaultUncaughtExceptionHandler(crashHandler) - exceptionRepo = ExceptionRepo(context) - activity = ActivityTracker(context.applicationContext as Application, shouldShowIntroToast) - anrSupervisor = AnrSupervisor() - - anrSupervisor.start() - } - - fun setAppProperties(properties: HashMap) { - this.appProperties.putAll(properties) - } - - fun setExceptionHandler(uncaughtExceptionHandler: Thread.UncaughtExceptionHandler) { - this.crashHandler?.let { - it.setExceptionHandler(uncaughtExceptionHandler) - return - } - throw IllegalStateException("UncaughtExceptionHandler cannot be set as Pluto is not initialised.") - } - - fun setANRListener(listener: ANRListener) { - anrSupervisor.setListener(listener) - } - - fun showUi() { - val context = - appContext ?: throw IllegalStateException("cannot open Ui as Pluto is not initialised.") - val intent = Intent(context, PlutoActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - - context.startActivity(intent) - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/PlutoInterceptor.kt b/pluto/src/main/java/com/mocklets/pluto/PlutoInterceptor.kt deleted file mode 100644 index 401e52fdd..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/PlutoInterceptor.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.mocklets.pluto - -import android.util.Log -import androidx.annotation.Keep -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.modules.exceptions.asExceptionData -import com.mocklets.pluto.modules.network.ApiCallData -import com.mocklets.pluto.modules.network.NetworkCallsRepo -import com.mocklets.pluto.modules.network.ProxyConfig -import com.mocklets.pluto.modules.network.interceptor.ResponseBodyProcessor -import com.mocklets.pluto.modules.network.interceptor.convert -import com.mocklets.pluto.modules.network.proxy.NetworkProxyRepo -import com.mocklets.pluto.modules.setup.SetupNotification -import java.io.IOException -import java.util.UUID -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response - -@Keep -class PlutoInterceptor : Interceptor { - private var setupNotification: SetupNotification? = null - - init { - Pluto.appContext?.let { context -> - setupNotification = SetupNotification(context) - } - } - - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - - Pluto.appContext?.let { context -> - setupNotification?.add() - val responseBodyProcessor = ResponseBodyProcessor(context) - val id = UUID.nameUUIDFromBytes("${System.currentTimeMillis()}::${request.url()}".toByteArray()).toString() - DebugLog.d("interceptor : ot", "$id ${request.url()}") - val requestData = request.convert() - val apiCallData = ApiCallData(id = id, request = requestData) - NetworkCallsRepo.set(apiCallData) - - var proxyRequest: Request? = null - val proxyUrl = NetworkProxyRepo.get(request.url(), request.method()) - proxyUrl?.let { - val builder = request.newBuilder().url(it) - proxyRequest = builder.build() - apiCallData.proxy = ProxyConfig(proxyUrl) - NetworkCallsRepo.set(apiCallData) - } - - val response: Response = try { - chain.proceed(proxyRequest ?: request) - } catch (e: IOException) { - DebugLog.e("interceptor : ex", "network_crash", e) - apiCallData.exception = e.asExceptionData() - NetworkCallsRepo.set(apiCallData) - throw e - } - return responseBodyProcessor.processBody(response, apiCallData) - } - Log.e("pluto", "API call not intercepted as Pluto is not initialised.") - return chain.proceed(request) - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/PlutoLog.kt b/pluto/src/main/java/com/mocklets/pluto/PlutoLog.kt deleted file mode 100644 index b55e8a567..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/PlutoLog.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.mocklets.pluto - -import android.util.Log -import androidx.annotation.Keep -import com.mocklets.pluto.modules.logging.Level -import com.mocklets.pluto.modules.logging.LogsRepo - -@Suppress("StringLiteralDuplication") -@Keep -class PlutoLog private constructor() { - - companion object { - - @JvmStatic - fun v(tag: String, message: String?, tr: Throwable? = null) { - preProcessLog(Level.Verbose, tag, message, tr) { trace -> - Log.v("$trace | $tag", "$message", tr) - } - } - - @JvmStatic - fun d(tag: String, message: String?, tr: Throwable? = null) { - preProcessLog(Level.Debug, tag, message, tr) { trace -> - Log.d("$trace | $tag", "$message", tr) - } - } - - @JvmStatic - fun i(tag: String, message: String?, tr: Throwable? = null) { - preProcessLog(Level.Info, tag, message, tr) { trace -> - Log.i("$trace | $tag", "$message", tr) - } - } - - @JvmStatic - fun w(tag: String, message: String?, tr: Throwable? = null) { - preProcessLog(Level.Warning, tag, message, tr) { trace -> - Log.w("$trace | $tag", "$message", tr) - } - } - - @JvmStatic - fun e(tag: String, message: String?, tr: Throwable? = null) { - preProcessLog(Level.Error, tag, message, tr) { trace -> - Log.e("$trace | $tag", "$message", tr) - } - } - - @JvmStatic - fun event(tag: String, event: String, attributes: HashMap?) { - if (!isValidInitialisation()) return - val stackTrace = Thread.currentThread().stackTraceElement() - LogsRepo.saveEvent(Level.Event, tag, event, attributes, stackTrace) - Log.d("${stackTrace.formattedStack()} | $tag", "$event => $attributes") - } - - private fun preProcessLog( - level: Level, - tag: String, - message: String?, - tr: Throwable? = null, - next: ((String) -> Unit) - ) { - if (isValidInitialisation()) { - val stackTrace = Thread.currentThread().stackTraceElement() - LogsRepo.save(level, tag, message, tr, stackTrace) - next.invoke(stackTrace.formattedStack()) - } - } - - private fun isValidInitialisation(): Boolean { - Pluto.appContext?.let { - return true - } - Log.e("pluto", "PlutoLog not printing as Pluto is not initialised.") - return false - } - } -} - -private fun Thread.stackTraceElement(): StackTraceElement { - stackTrace.forEach { - if (!it.className.startsWith(BuildConfig.LIBRARY_PACKAGE_NAME) && - !it.className.startsWith("java.lang.") && - !it.className.startsWith("dalvik.system.") - ) { - return it - } - } - return stackTrace[if (name == "main") MAIN_THREAD_INDEX else DAEMON_THREAD_INDEX] -} - -private const val MAIN_THREAD_INDEX = 6 -private const val DAEMON_THREAD_INDEX = 5 - -@Keep -private fun StackTraceElement.formattedStack(): String = "$methodName($fileName:$lineNumber)" diff --git a/pluto/src/main/java/com/mocklets/pluto/core/DebugLog.kt b/pluto/src/main/java/com/mocklets/pluto/core/DebugLog.kt deleted file mode 100644 index 5254ee595..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/DebugLog.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.mocklets.pluto.core - -import android.util.Log -import com.mocklets.pluto.BuildConfig - -internal class DebugLog private constructor() { - - companion object { - fun v(tag: String, message: String?, tr: Throwable? = null) { - if (BuildConfig.DEBUG) { - Log.v(tag, message.toString(), tr) - } - } - - fun d(tag: String, message: String?, tr: Throwable? = null) { - if (BuildConfig.DEBUG) { - Log.d(tag, message.toString(), tr) - } - } - - fun i(tag: String, message: String?, tr: Throwable? = null) { - if (BuildConfig.DEBUG) { - Log.i(tag, message.toString(), tr) - } - } - - fun w(tag: String, message: String?, tr: Throwable? = null) { - if (BuildConfig.DEBUG) { - Log.w(tag, message.toString(), tr) - } - } - - fun e(tag: String, message: String?, tr: Throwable? = null) { - if (BuildConfig.DEBUG) { - Log.e(tag, message, tr) - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/DeviceFingerPrint.kt b/pluto/src/main/java/com/mocklets/pluto/core/DeviceFingerPrint.kt deleted file mode 100644 index 1ca167611..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/DeviceFingerPrint.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.mocklets.pluto.core - -import android.content.Context -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Build -import android.provider.Settings -import androidx.annotation.Keep -import com.mocklets.pluto.core.ui.list.ListItem -import kotlin.math.ceil -import kotlin.math.pow -import kotlin.math.sqrt - -@Keep -internal class DeviceFingerPrint(context: Context) : ListItem() { - val build = BuildData() - val screen = context.getScreen() - val software = context.getSoftwareData() - val isRooted = RootUtil.isDeviceRooted - val gaId: String? = null -} - -private fun Context.getSoftwareData(): SoftwareData { - return SoftwareData( - androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID), - androidAPILevel = Build.VERSION.SDK_INT.toString(), - androidOs = Build.VERSION.RELEASE, - appVersion = getVersion(), - orientation = getOrientation() - ) -} - -private const val DENSITY_MULTIPLIER = 160f -private fun Context.getScreen(): Screen { - val dm = resources.displayMetrics - val height = dm.heightPixels - val width = dm.widthPixels - val x = (width / dm.xdpi.toDouble()).pow(2.0) - val y = (height / dm.ydpi.toDouble()).pow(2.0) - val screenInches = sqrt(x + y) - val rounded = ceil(screenInches) - val densityDpi = (dm.density * DENSITY_MULTIPLIER).toInt() - - return Screen( - width = "${width}px", - height = "${height}px", - size = "$rounded inches", - density = "$densityDpi dpi" - ) -} - -private fun Context.getOrientation(): String { - return when (resources.configuration.orientation) { - 1 -> "portrait" - 2 -> "landscape" - else -> "undefined" - } -} - -private fun Context.getVersion(): VersionData? { - try { - val info: PackageInfo = packageManager.getPackageInfo(packageName, 0) - return VersionData( - info.versionName, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong() - ) - } catch (e: PackageManager.NameNotFoundException) { - DebugLog.e("device-fingerprint", "error while fetching version info", e) - } - return null -} - -@Keep -internal data class VersionData( - val name: String, - val code: Long -) - -@Keep -internal data class Screen( - val width: String, - val height: String, - val size: String, - val density: String -) - -@Keep -internal data class SoftwareData( - val androidId: String?, - val androidAPILevel: String?, - val androidOs: String?, - val appVersion: VersionData?, - val orientation: String -) - -@Keep -internal data class BuildData( - val user: String? = Build.USER, - val host: String? = Build.HOST, - val id: String? = Build.ID, - val fingerprint: String? = Build.FINGERPRINT, - val manufacturer: String? = Build.MANUFACTURER, - val hardware: String? = Build.HARDWARE, - val board: String = Build.BOARD, - val brand: String? = Build.BRAND, - val bootloader: String? = Build.BOOTLOADER, - val model: String? = Build.MODEL -) diff --git a/pluto/src/main/java/com/mocklets/pluto/core/DeviceInfo.kt b/pluto/src/main/java/com/mocklets/pluto/core/DeviceInfo.kt deleted file mode 100644 index a770839ac..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/DeviceInfo.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.mocklets.pluto.core - -import android.content.Context -import android.graphics.Point -import android.view.WindowManager - -internal class DeviceInfo(context: Context) { - - private val screenDimension: ScreenDimension by lazy { - val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - val display = windowManager.defaultDisplay - val size = Point() - display.getSize(size) - ScreenDimension(size.x, size.y) - } - - val height = screenDimension.height - val width = screenDimension.width - - private data class ScreenDimension(val width: Int, val height: Int) -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/PlutoFileProvider.kt b/pluto/src/main/java/com/mocklets/pluto/core/PlutoFileProvider.kt deleted file mode 100644 index b82956cdc..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/PlutoFileProvider.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.mocklets.pluto.core - -import androidx.core.content.FileProvider - -internal class PlutoFileProvider : FileProvider() diff --git a/pluto/src/main/java/com/mocklets/pluto/core/RootUtil.kt b/pluto/src/main/java/com/mocklets/pluto/core/RootUtil.kt deleted file mode 100644 index daf81d160..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/RootUtil.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.mocklets.pluto.core - -import android.os.Build -import java.io.BufferedReader -import java.io.File -import java.io.InputStreamReader - -/** Credits: https://stackoverflow.com/questions/1101380/determine-if-running-on-a-rooted-device/8097801 - * - * @author Kevin Kowalewski - */ -internal object RootUtil { - val isDeviceRooted: Boolean - get() = checkRootMethod1() || checkRootMethod2() || checkRootMethod3() - - private fun checkRootMethod1(): Boolean { - val buildTags = Build.TAGS - return buildTags != null && buildTags.contains("test-keys") - } - - private fun checkRootMethod2(): Boolean { - val paths = arrayOf( - "/system/app/Superuser.apk", - "/sbin/su", - "/system/bin/su", - "/system/xbin/su", - "/data/local/xbin/su", - "/data/local/bin/su", - "/system/sd/xbin/su", - "/system/bin/failsafe/su", - "/data/local/su", - "/su/bin/su" - ) - for (path in paths) { - if (File(path).exists()) return true - } - return false - } - - @Suppress("TooGenericExceptionCaught") - private fun checkRootMethod3(): Boolean { - var process: Process? = null - return try { - process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su")) - val stream = BufferedReader(InputStreamReader(process.inputStream)) - stream.readLine() != null - } catch (t: Throwable) { - DebugLog.e("root-utils", "exception occurred", t) - false - } finally { - process?.destroy() - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/Session.kt b/pluto/src/main/java/com/mocklets/pluto/core/Session.kt deleted file mode 100644 index b4f2ae8b1..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/Session.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mocklets.pluto.core - -internal class Session { - - var networkSearchText: String = "" - - var loggerSearchText: String = "" - - var exceptionSearchText: String = "" - - var preferencesSearchText: String = "" - - var selectTabIndex: Int = 0 -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/binding/FragmentViewBindingDelegate.kt b/pluto/src/main/java/com/mocklets/pluto/core/binding/FragmentViewBindingDelegate.kt deleted file mode 100644 index f4e28cfac..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/binding/FragmentViewBindingDelegate.kt +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Source: https://gist.github.com/Zhuinden/ea3189198938bd16c03db628e084a4fa#file-fragmentviewbindingdelegate-kt - */ - -// https://github.com/Zhuinden/fragmentviewbindingdelegate-kt -package com.mocklets.pluto.core.binding - -import android.view.View -import androidx.fragment.app.Fragment -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.Observer -import androidx.viewbinding.ViewBinding -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty - -internal class FragmentViewBindingDelegate( - val fragment: Fragment, - val viewBindingFactory: (View) -> T -) : ReadOnlyProperty { - private var binding: T? = null - - init { - fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { - val viewLifecycleOwnerLiveDataObserver = - Observer { - val viewLifecycleOwner = it ?: return@Observer - - viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - binding = null - } - }) - } - - override fun onCreate(owner: LifecycleOwner) { - fragment.viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerLiveDataObserver) - } - - override fun onDestroy(owner: LifecycleOwner) { - fragment.viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerLiveDataObserver) - } - }) - } - - override fun getValue(thisRef: Fragment, property: KProperty<*>): T { - val binding = binding - if (binding != null) { - return binding - } - - val lifecycle = fragment.viewLifecycleOwner.lifecycle - check(lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { - "Should not attempt to get bindings when Fragment views are destroyed." - } - - return viewBindingFactory(thisRef.requireView()).also { this.binding = it } - } -} - -internal fun Fragment.viewBinding(viewBindingFactory: (View) -> T) = - FragmentViewBindingDelegate(this, viewBindingFactory) diff --git a/pluto/src/main/java/com/mocklets/pluto/core/database/DatabaseManager.kt b/pluto/src/main/java/com/mocklets/pluto/core/database/DatabaseManager.kt deleted file mode 100644 index 30a07f9d5..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/database/DatabaseManager.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.mocklets.pluto.core.database - -import android.content.Context -import androidx.room.Room - -internal class DatabaseManager(context: Context) { - - val db by lazy { - Room.databaseBuilder(context, PlutoDatabase::class.java, DATABASE_NAME) - .addMigrations() - .fallbackToDestructiveMigration() - .build() - } - - companion object { - private const val DATABASE_NAME = "_pluto_database" - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/database/PlutoDatabase.kt b/pluto/src/main/java/com/mocklets/pluto/core/database/PlutoDatabase.kt deleted file mode 100644 index f5e6465b0..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/database/PlutoDatabase.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.mocklets.pluto.core.database - -import androidx.room.RoomDatabase -import com.mocklets.pluto.modules.exceptions.dao.ExceptionDao -import com.mocklets.pluto.modules.exceptions.dao.ExceptionEntity -import com.mocklets.pluto.modules.network.proxy.dao.NetworkProxyDao -import com.mocklets.pluto.modules.network.proxy.dao.NetworkProxyEntity - -@androidx.room.Database( - entities = [ - ExceptionEntity::class, - NetworkProxyEntity::class - ], - version = 2, - exportSchema = false -) -internal abstract class PlutoDatabase : RoomDatabase() { - abstract fun exceptionDao(): ExceptionDao - abstract fun networkProxyDao(): NetworkProxyDao -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/ContextKtx.kt b/pluto/src/main/java/com/mocklets/pluto/core/extensions/ContextKtx.kt deleted file mode 100644 index f98721d80..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/ContextKtx.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.mocklets.pluto.core.extensions - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.widget.Toast -import android.widget.Toast.LENGTH_LONG -import android.widget.Toast.LENGTH_SHORT -import androidx.annotation.ColorInt -import androidx.annotation.ColorRes -import androidx.annotation.DrawableRes -import androidx.annotation.FontRes -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat - -@ColorInt -internal fun Context.color(@ColorRes id: Int) = ContextCompat.getColor(this, id) - -internal fun Context.font(@FontRes id: Int) = ResourcesCompat.getFont(this, id) - -internal fun Context.drawable(@DrawableRes id: Int) = ContextCompat.getDrawable(this, id) - -internal fun Context.toast(message: String, isLong: Boolean = false) { - Toast.makeText(this, message, if (isLong) LENGTH_LONG else LENGTH_SHORT).show() -} - -fun Context.checkAndOpenSupportedApp(uri: Uri?) { - val intent = Intent(Intent.ACTION_VIEW, uri) - if (intent.resolveActivity(packageManager) != null) { - startActivity(intent) - } else { - toast("No app to perform this action") - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/DimensKtx.kt b/pluto/src/main/java/com/mocklets/pluto/core/extensions/DimensKtx.kt deleted file mode 100644 index 144c9980d..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/DimensKtx.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.mocklets.pluto.core.extensions - -import android.content.res.Resources -import android.util.TypedValue -import java.util.Locale - -val Float.dp: Float - get() { - val displayMetrics = Resources.getSystem().displayMetrics - return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this, displayMetrics) - } - -val Float.sp: Float - get() { - val displayMetrics = Resources.getSystem().displayMetrics - return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this, displayMetrics) - } - -val Int.twoDigit: String - get() { - return String.format(Locale.ENGLISH, "%02d", this) - } diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/FragmentKtx.kt b/pluto/src/main/java/com/mocklets/pluto/core/extensions/FragmentKtx.kt deleted file mode 100644 index 0443e7e69..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/FragmentKtx.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.mocklets.pluto.core.extensions - -import android.os.Bundle -import android.os.Parcelable -import androidx.fragment.app.Fragment - -internal const val KEY_EXTRA = "extra" - -fun Fragment.setParcelExtra(parcelable: Parcelable?, key: String = KEY_EXTRA) { - arguments = (arguments ?: Bundle()).apply { - putParcelable(key, parcelable) - } -} - -fun Fragment.setStringExtra(value: String?, key: String = KEY_EXTRA) { - arguments = (arguments ?: Bundle()).apply { - putString(key, value) - } -} - -fun Fragment.getParcelExtra(key: String = KEY_EXTRA): T? = arguments?.getParcelable(key) - -internal inline fun Fragment.lazyParcelExtra(key: String = KEY_EXTRA): Lazy = - lazy { this.getParcelExtra(key) } diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/PopupKtx.kt b/pluto/src/main/java/com/mocklets/pluto/core/extensions/PopupKtx.kt deleted file mode 100644 index a23740b8f..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/PopupKtx.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.mocklets.pluto.core.extensions - -import android.annotation.SuppressLint -import android.content.Context -import android.view.Gravity -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.annotation.MenuRes -import androidx.appcompat.view.menu.MenuBuilder -import androidx.appcompat.view.menu.MenuPopupHelper -import androidx.appcompat.widget.PopupMenu -import com.mocklets.pluto.R -import com.mocklets.pluto.core.ui.spannable.createSpan - -@SuppressLint("RestrictedApi") -internal fun Context.showMoreOptions(view: View, @MenuRes menu: Int, listener: (MenuItem) -> Unit) { - val popup = PopupMenu(this, view, Gravity.END).apply { - menuInflater.inflate(menu, this.menu) - applyFontToMenu(this.menu) - setOnMenuItemClickListener { item -> - listener.invoke(item) - true - } - } - - val menuHelper = MenuPopupHelper(this, popup.menu as MenuBuilder, view) - menuHelper.setForceShowIcon(true) - menuHelper.gravity = Gravity.END - menuHelper.show() -} - -private fun Context.applyFontToMenu(m: Menu) { - for (i in 0 until m.size()) { - applyFontToMenuItem(m.getItem(i)) - } -} - -private fun Context.applyFontToMenuItem(mi: MenuItem) { - if (mi.hasSubMenu()) { - for (i in 0 until mi.subMenu.size()) { - applyFontToMenuItem(mi.subMenu.getItem(i)) - } - } - mi.title = createSpan { - append(fontColor(fontSize(semiBold(mi.title), MENU_FONT_SIZE), color(R.color.pluto___text_dark_80))) - } -} - -private const val MENU_FONT_SIZE = 16 diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/RecyclerViewKtx.kt b/pluto/src/main/java/com/mocklets/pluto/core/extensions/RecyclerViewKtx.kt deleted file mode 100644 index a3a328ca1..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/RecyclerViewKtx.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.mocklets.pluto.core.extensions - -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView - -fun RecyclerView.linearLayoutManager(): LinearLayoutManager? { - if (layoutManager is LinearLayoutManager) { - return layoutManager as LinearLayoutManager - } - return null -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/extensions/StringsKtx.kt b/pluto/src/main/java/com/mocklets/pluto/core/extensions/StringsKtx.kt deleted file mode 100644 index d8f97f7a8..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/extensions/StringsKtx.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.mocklets.pluto.core.extensions - -import java.util.Locale - -fun String.capitalizeText(default: Locale = Locale.getDefault()): String = - replaceFirstChar { if (it.isLowerCase()) it.titlecase(default) else it.toString() } diff --git a/pluto/src/main/java/com/mocklets/pluto/core/notification/NotificationUtil.kt b/pluto/src/main/java/com/mocklets/pluto/core/notification/NotificationUtil.kt deleted file mode 100644 index 122473df1..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/notification/NotificationUtil.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.mocklets.pluto.core.notification - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.os.Build -import androidx.core.app.NotificationCompat -import com.mocklets.pluto.R - -internal class NotificationUtil(private val context: Context) { - - private val manager: NotificationManager? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - context.getSystemService(NotificationManager::class.java) - } else { - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? - } - - private fun createNotificationChannel(channel: NotificationChannel) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - manager?.createNotificationChannel(channel) - } - } - - private fun createChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_DEFAULT - ) - channel.setShowBadge(false) - createNotificationChannel(channel) - } - } - - fun cancel() { - manager?.cancel(NOTIFICATION_ID) - } - - fun notify( - title: String, - text: String, - intent: PendingIntent, - isOngoing: Boolean = false, - isAutoCancel: Boolean = false - ) { - createChannel() - val notification: Notification = NotificationCompat.Builder(context, CHANNEL_ID) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setContentTitle(title) - .setContentText(text) - .setSmallIcon(R.drawable.pluto___ic_launcher) - .setContentIntent(intent) - .setOngoing(isOngoing) - .setOnlyAlertOnce(true) - .setAutoCancel(isAutoCancel) - .setSilent(true) - .setSound(null) - .build() - manager?.notify(NOTIFICATION_ID, notification) - } - - companion object { - const val NOTIFICATION_ID = 1011 - const val CHANNEL_ID = "pluto_notifications" - const val GROUP_ID = "pluto_notifications_group" - const val CHANNEL_NAME = "Pluto Notifications" - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/preferences/Preferences.kt b/pluto/src/main/java/com/mocklets/pluto/core/preferences/Preferences.kt deleted file mode 100644 index 081aec2cb..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/preferences/Preferences.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.mocklets.pluto.core.preferences - -import android.annotation.SuppressLint -import android.content.Context - -internal class Preferences(context: Context) { - - private val statePrefs by lazy { context.preferences("states") } - - private val settingsPrefs by lazy { context.preferences("settings") } - - internal var lastSessionCrash: String? - get() = statePrefs.getString(LAST_SESSION_CRASH, null) - @SuppressLint("ApplySharedPref") - /* added commit, as apply() is getting missed */ - set(value) { - statePrefs.edit().putString(LAST_SESSION_CRASH, value).commit() - } - - internal var selectedPreferenceFiles: String? - get() = settingsPrefs.getString(SELECTED_PREF_FILE, null) - set(value) = settingsPrefs.edit().putString(SELECTED_PREF_FILE, value).apply() - - internal var isDarkAccessPopup: Boolean - get() = settingsPrefs.getBoolean(IS_DARK_ACCESS_POPUP, true) - set(value) = settingsPrefs.edit().putBoolean(IS_DARK_ACCESS_POPUP, value).apply() - - internal var isRightHandedAccessPopup: Boolean - get() = settingsPrefs.getBoolean(IS_RIGHT_HANDED_ACCESS_POPUP, true) - set(value) = settingsPrefs.edit().putBoolean(IS_RIGHT_HANDED_ACCESS_POPUP, value).apply() - - internal var isEasyAccessSetupDialogShown: Boolean - get() = settingsPrefs.getBoolean(IS_EASY_ACCESS_SETUP_DIALOG_SHOWN, false) - set(value) = settingsPrefs.edit().putBoolean(IS_EASY_ACCESS_SETUP_DIALOG_SHOWN, value).apply() - - internal companion object { - fun isPlutoPref(it: String): Boolean { - return it.startsWith("_pluto_pref", true) - } - - const val DEFAULT = "Default" - - const val LAST_SESSION_CRASH = "last_session_crash" - const val SELECTED_PREF_FILE = "selected_pref_file" - const val IS_DARK_ACCESS_POPUP = "is_dark_access_popup" - const val IS_RIGHT_HANDED_ACCESS_POPUP = "is_right_handed_access_popup" - const val IS_EASY_ACCESS_SETUP_DIALOG_SHOWN = "is_easy_access_setup_dialog_shown" - } -} - -private fun Context.preferences(name: String, mode: Int = Context.MODE_PRIVATE) = getSharedPreferences(name, mode) diff --git a/pluto/src/main/java/com/mocklets/pluto/core/sharing/ContentShare.kt b/pluto/src/main/java/com/mocklets/pluto/core/sharing/ContentShare.kt deleted file mode 100644 index c6863078b..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/sharing/ContentShare.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.mocklets.pluto.core.sharing - -import androidx.activity.ComponentActivity -import androidx.activity.viewModels -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.activityViewModels -import com.mocklets.pluto.core.extensions.toast - -internal class ContentShare(activity: FragmentActivity) { - private val sharer: ContentShareViewModel by activity.lazyContentSharer() - private val dialog = ShareOptionsDialog(activity) { - when (it) { - is ShareAction.ShareAsText -> activity.share(it.shareable.processedContent, it.shareable.title) - is ShareAction.ShareAsFile -> activity.shareFile(it.shareable.processedContent, it.shareable.title, it.shareable.fileName) - is ShareAction.ShareAsCopy -> activity.let { context -> - context.copyToClipboard(it.shareable.content, it.shareable.title) - context.toast("Content copied to Clipboard") - } - } - } - - init { - sharer.data.observe( - activity, - { - dialog.show(it) - } - ) - } -} - -internal fun Fragment.lazyContentSharer(): Lazy = activityViewModels() - -internal fun ComponentActivity.lazyContentSharer(): Lazy = viewModels() - -internal data class Shareable( - val title: String, - val content: String, - val fileName: String = "File shared from Pluto" -) { - val processedContent: String = StringBuilder().apply { - append(content) - append("\n\n==================\nreport generated by Pluto (https://pluto.mocklets.com)") - }.toString() -} - -internal sealed class ShareAction { - data class ShareAsText(val shareable: Shareable) : ShareAction() - data class ShareAsFile(val shareable: Shareable) : ShareAction() - data class ShareAsCopy(val shareable: Shareable) : ShareAction() -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/sharing/ContentShareViewModel.kt b/pluto/src/main/java/com/mocklets/pluto/core/sharing/ContentShareViewModel.kt deleted file mode 100644 index e89d40a95..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/sharing/ContentShareViewModel.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.mocklets.pluto.core.sharing - -import androidx.lifecycle.ViewModel -import com.mocklets.pluto.core.SingleLiveEvent - -internal class ContentShareViewModel : ViewModel() { - - val data = SingleLiveEvent() - - fun share(shareable: Shareable) { - data.postValue(shareable) - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/sharing/ShareKtx.kt b/pluto/src/main/java/com/mocklets/pluto/core/sharing/ShareKtx.kt deleted file mode 100644 index ac0965839..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/sharing/ShareKtx.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.mocklets.pluto.core.sharing - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.os.Environment -import androidx.core.content.FileProvider -import com.mocklets.pluto.core.DebugLog -import java.io.File -import java.io.FileOutputStream - -internal fun Context.share(message: String, title: String? = null, subject: String? = null) { - val intent = Intent().apply { - type = "text/plain" - putExtra(Intent.EXTRA_SUBJECT, subject ?: "") - putExtra(Intent.EXTRA_TEXT, message) - action = Intent.ACTION_SEND - } - startActivity(Intent.createChooser(intent, title ?: "Share via...")) -} - -internal fun Context.shareFile(message: String, title: String? = null, fileName: String) { - val dir = getDirectoryName() - val file = generateFile(message, dir) - val uri = FileProvider.getUriForFile(applicationContext, "pluto___${applicationContext.packageName}.provider", file) - val intent = Intent().apply { - type = "text/*" - - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - putExtra(Intent.EXTRA_TEXT, fileName) - putExtra(Intent.EXTRA_STREAM, uri) - action = Intent.ACTION_SEND - } - startActivity(Intent.createChooser(intent, title ?: "Share via...")) -} - -internal fun Context.copyToClipboard(data: String, label: String) { - val clipboard: ClipboardManager? = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager? - val clip = ClipData.newPlainText(label, data) - clipboard?.setPrimaryClip(clip) -} - -private fun Context.getDirectoryName(): File { - val dir = File(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "PlutoShares") - if (!dir.exists()) { - if (dir.mkdir()) { - DebugLog.d("Create Directory", "Directory Created : $dir") - } - } - return dir -} - -@Suppress("TooGenericExceptionCaught") -private fun generateFile(content: String, saveFilePath: File): File { - val dir = File(saveFilePath.absolutePath) - if (!dir.exists()) { - dir.mkdirs() - } - val file = File(saveFilePath.absolutePath, "pluto_share.txt") - try { - val fOut = FileOutputStream(file) - fOut.write(content.toByteArray()) - fOut.flush() - fOut.close() - } catch (e: Exception) { - DebugLog.e("share", "error while generating file", e) - } - return file -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/sharing/ShareOptionsDialog.kt b/pluto/src/main/java/com/mocklets/pluto/core/sharing/ShareOptionsDialog.kt deleted file mode 100644 index d21249c61..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/sharing/ShareOptionsDialog.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.mocklets.pluto.core.sharing - -import android.content.Context -import android.graphics.drawable.ColorDrawable -import android.view.View -import android.widget.FrameLayout -import androidx.core.content.ContextCompat -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.mocklets.pluto.R -import com.mocklets.pluto.core.DeviceInfo -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoLayoutContentShareOptionsBinding - -internal class ShareOptionsDialog( - context: Context, - private val onAction: (ShareAction) -> Unit -) : BottomSheetDialog(context, R.style.PlutoBottomSheetDialogTheme) { - - private val sheetView: View = context.inflate(R.layout.pluto___layout_content_share_options) - private val binding = PlutoLayoutContentShareOptionsBinding.bind(sheetView) - private val deviceInfo = DeviceInfo(context) - - init { - setContentView(sheetView) - (sheetView.parent as View).background = - ColorDrawable(ContextCompat.getColor(context, R.color.pluto___transparent)) - - setOnShowListener { dialog -> - if (dialog is BottomSheetDialog) { - val bottomSheet = dialog.findViewById(R.id.design_bottom_sheet) as FrameLayout? - val behavior = BottomSheetBehavior.from(bottomSheet!!) - behavior.apply { - state = BottomSheetBehavior.STATE_EXPANDED - isHideable = false - skipCollapsed = true - peekHeight = deviceInfo.height - } - } - } - } - - fun show(shareable: Shareable) { - binding.title.text = shareable.title - binding.shareText.setDebounceClickListener { - onAction.invoke(ShareAction.ShareAsText(shareable)) - dismiss() - } - binding.shareFile.setDebounceClickListener { - onAction.invoke(ShareAction.ShareAsFile(shareable)) - dismiss() - } - binding.shareCopy.setDebounceClickListener { - onAction.invoke(ShareAction.ShareAsCopy(shareable)) - dismiss() - } - show() - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/OnBackKeyHandler.kt b/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/OnBackKeyHandler.kt deleted file mode 100644 index 7f9aa66a0..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/OnBackKeyHandler.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.mocklets.pluto.core.ui.routing - -interface OnBackKeyHandler { - fun onBackPressed(): Boolean -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/RouteManager.kt b/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/RouteManager.kt deleted file mode 100644 index 10b3dc85a..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/RouteManager.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.mocklets.pluto.core.ui.routing - -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentTransaction -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.hideKeyboard -import com.mocklets.pluto.core.extensions.setParcelExtra -import com.mocklets.pluto.modules.appstate.AppStateFragment -import com.mocklets.pluto.modules.exceptions.ui.CrashDetailsFragment -import com.mocklets.pluto.modules.network.proxy.ui.NetworkProxySettingsFragment -import com.mocklets.pluto.modules.network.proxy.ui.NetworkProxySettingsListFragment -import com.mocklets.pluto.modules.network.ui.details.NetworkCallDetailsFragment -import com.mocklets.pluto.modules.preferences.ui.filter.SharedPrefFilterFragment -import com.mocklets.pluto.modules.settings.SettingsFragment -import com.mocklets.pluto.ui.AboutFragment - -internal class RouteManager(private val activity: FragmentActivity, private val containerId: Int) { - - private val router by activity.lazyRouter() - private val fragmentManager - get() = if (!activity.supportFragmentManager.isDestroyed) { - activity.supportFragmentManager - } else null - private val currentFragment - get() = fragmentManager?.findFragmentById(containerId) - - init { - router.routerData.observe( - activity, - { - if (!activity.supportFragmentManager.isStateSaved) { - handleRoutingData(it) - } else { - router.perform(it) - } - } - ) - } - - private fun handleRoutingData(data: RouterAction) { - when (data) { - is Screens -> executeAction(generateAction(data)) - is RouterAction.PopBackStack -> executeAction(Action.PopStack(data.popTag, data.inclusive)) - is RouterAction.BackToApp -> activity.finish() - } - } - - private fun executeAction(action: Action) { - when (action) { - is Action.Switch -> { - fragmentManager?.beginTransaction()?.apply { - animate() - if (action.replace) { - replace(containerId, action.fragment, action.fragmentTag) - } else { - add(containerId, action.fragment, action.fragmentTag) - } - addToBackStack(action.fragmentTag) - }?.commit() - activity.hideKeyboard() - } - - is Action.ShowDialog -> fragmentManager?.beginTransaction()?.apply { - if (action.addToBackStack) { - addToBackStack(action.fragmentTag) - } - }?.let { action.fragment.show(it, action.fragmentTag) } - - is Action.PopStack -> fragmentManager?.popBackStack( - action.tag, - if (action.inclusive) FragmentManager.POP_BACK_STACK_INCLUSIVE else 0 - ) - } - } - - private fun generateAction(screen: Screens): Action { - return when (screen) { - is Screens.Settings -> Action.Switch(screen.tag, SettingsFragment()) - is Screens.AppState -> Action.Switch(screen.tag, AppStateFragment()) - is Screens.About -> Action.Switch(screen.tag, AboutFragment(), false) - is Screens.NetworkCallDetails -> Action.Switch( - screen.tag, - NetworkCallDetailsFragment().apply { - setParcelExtra(screen.data) - } - ) - is Screens.NetworkProxySettings -> Action.Switch( - screen.tag, - NetworkProxySettingsFragment().apply { - setParcelExtra(screen.data) - } - ) - is Screens.NetworkProxySettingsList -> Action.Switch(screen.tag, NetworkProxySettingsListFragment()) - is Screens.CrashDetails -> Action.Switch( - screen.tag, - CrashDetailsFragment().apply { - setParcelExtra(screen.data) - } - ) - is Screens.SharedPrefFilter -> Action.Switch(screen.tag, SharedPrefFilterFragment()) - } - } - - fun onBackPressed(): Boolean { - var handled = - if (currentFragment is OnBackKeyHandler) { - (currentFragment as OnBackKeyHandler).onBackPressed() - } else { - false - } - - if (!handled) { - if (fragmentManager?.backStackEntryCount ?: 0 > 0) { - handled = true - fragmentManager?.popBackStack() - } - } - return handled - } -} - -private fun FragmentTransaction.animate() { - setCustomAnimations( - R.anim.pluto___fragment_default_enter, - R.anim.pluto___fragment_default_exit, - R.anim.pluto___fragment_default_reenter, - R.anim.pluto___fragment_default_return - ) -} diff --git a/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/Router.kt b/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/Router.kt deleted file mode 100644 index f9b5c6eb1..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/Router.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.mocklets.pluto.core.ui.routing - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.activity.viewModels -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.AndroidViewModel -import com.mocklets.pluto.core.SingleLiveEvent - -internal class Router(application: Application) : AndroidViewModel(application) { - - private val routeLiveData = SingleLiveEvent() - val routerData: SingleLiveEvent - get() = routeLiveData - - fun navigate(screen: Screens) { - perform(screen) - } - - fun perform(action: RouterAction) { - routeLiveData.postValue(action) - } -} - -internal fun Fragment.lazyRouter(): Lazy = activityViewModels() - -internal fun ComponentActivity.lazyRouter(): Lazy = viewModels() diff --git a/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/Utilities.kt b/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/Utilities.kt deleted file mode 100644 index bce5f1620..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/core/ui/routing/Utilities.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.mocklets.pluto.core.ui.routing - -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment -import com.mocklets.pluto.modules.exceptions.ui.CrashDetailsFragment -import com.mocklets.pluto.modules.network.proxy.ui.NetworkProxySettingsFragment -import com.mocklets.pluto.modules.network.ui.details.NetworkCallDetailsFragment - -internal sealed class RouterAction { - class PopBackStack(val popTag: String? = null, val inclusive: Boolean = false) : RouterAction() - class BackToApp(val trigger: String) : RouterAction() -} - -internal sealed class Screens : RouterAction() { - - val tag = this.javaClass.simpleName - - object Settings : Screens() - object About : Screens() - class NetworkCallDetails(val data: NetworkCallDetailsFragment.Data) : Screens() - object NetworkProxySettingsList : Screens() - class NetworkProxySettings(val data: NetworkProxySettingsFragment.Data? = null) : Screens() - class CrashDetails(val data: CrashDetailsFragment.Data) : Screens() - object SharedPrefFilter : Screens() - object AppState : Screens() -} - -internal sealed class Action { - class Switch( - val fragmentTag: String, - val fragment: Fragment, - val replace: Boolean = true - ) : Action() - - class ShowDialog( - val fragmentTag: String, - val fragment: DialogFragment, - val addToBackStack: Boolean = true - ) : Action() - - class PopStack( - val tag: String? = null, - val inclusive: Boolean = false - ) : Action() -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/activities/ActivityTracker.kt b/pluto/src/main/java/com/mocklets/pluto/modules/activities/ActivityTracker.kt deleted file mode 100644 index ce1707d6d..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/activities/ActivityTracker.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.mocklets.pluto.modules.activities - -import android.app.Activity -import android.app.Application -import android.app.Application.ActivityLifecycleCallbacks -import android.content.Context -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.modules.setup.SetupNotification -import com.mocklets.pluto.modules.setup.easyaccess.Popup -import com.mocklets.pluto.ui.PlutoActivity - -internal class ActivityTracker @JvmOverloads constructor( - application: Application, - shouldShowIntroToast: Boolean = true -) { - - private var isCustomTabOpened: Boolean = false - private var activityCount = 0 - private val popup: Popup = Popup(application.applicationContext, shouldShowIntroToast) - private val setupNotification = SetupNotification(application.applicationContext) - - private val fragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() { - override fun onFragmentCreated(manager: FragmentManager, fragment: Fragment, savedInstanceState: Bundle?) { - DebugLog.d(LOG_TAG, ">> created ${fragment.javaClass.simpleName}") - } - - override fun onFragmentAttached(manager: FragmentManager, fragment: Fragment, context: Context) { - DebugLog.d(LOG_TAG, ">> attached ${fragment.javaClass.simpleName}") - } - - override fun onFragmentStarted(manager: FragmentManager, fragment: Fragment) { - DebugLog.d(LOG_TAG, ">> started ${fragment.javaClass.simpleName}") - } - - override fun onFragmentResumed(manager: FragmentManager, fragment: Fragment) { - DebugLog.d(LOG_TAG, ">> resumed ${fragment.javaClass.simpleName}") - } - - override fun onFragmentPaused(manager: FragmentManager, fragment: Fragment) { - DebugLog.d(LOG_TAG, ">> paused ${fragment.javaClass.simpleName}") - } - - override fun onFragmentStopped(manager: FragmentManager, fragment: Fragment) { - DebugLog.d(LOG_TAG, ">> stopped ${fragment.javaClass.simpleName}") - } - - override fun onFragmentViewCreated(manager: FragmentManager, fragment: Fragment, v: View, state: Bundle?) { - DebugLog.d(LOG_TAG, ">> view_created ${fragment.javaClass.simpleName}") - } - - override fun onFragmentViewDestroyed(manager: FragmentManager, fragment: Fragment) { - DebugLog.d(LOG_TAG, ">> view_destroyed ${fragment.javaClass.simpleName}") - } - - override fun onFragmentDetached(manager: FragmentManager, fragment: Fragment) { - DebugLog.d(LOG_TAG, ">> detached ${fragment.javaClass.simpleName}") - } - - override fun onFragmentDestroyed(manager: FragmentManager, fragment: Fragment) { - DebugLog.d(LOG_TAG, ">> destroyed ${fragment.javaClass.simpleName}") - } - } - - private val lifecycleCallbacks = object : ActivityLifecycleCallbacks { - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - DebugLog.d(LOG_TAG, "create ${activity.javaClass.simpleName}") - activity.registerFragmentLifecycle(fragmentLifecycleCallbacks) - } - - override fun onActivityStarted(activity: Activity) { - DebugLog.d(LOG_TAG, "start ${activity.javaClass.simpleName}") - activityCount++ - if (activityCount == 1) { - onAppForeground(activity) - setupNotification.add() - } - } - - override fun onActivityResumed(activity: Activity) { - DebugLog.d(LOG_TAG, "resume ${activity.javaClass.simpleName}") - if (activity is PlutoActivity) { - popup.remove() - } else { - popup.add(activity) - } - } - - override fun onActivityPaused(activity: Activity) { - DebugLog.d(LOG_TAG, "pause ${activity.javaClass.simpleName}") - } - - override fun onActivityStopped(activity: Activity) { - DebugLog.d(LOG_TAG, "stop ${activity.javaClass.simpleName}") - activityCount-- - if (activityCount == 0) { - if (!isCustomTabOpened && activity is PlutoActivity) { - activity.finish() - } - onAppBackground() - } - } - - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} - override fun onActivityDestroyed(activity: Activity) { - if (activity is AppCompatActivity) { - activity.unregisterFragmentLifecycle(fragmentLifecycleCallbacks) - } - if (activity !is PlutoActivity && activityCount == 0) { - popup.remove() - } - DebugLog.d(LOG_TAG, "destroy ${activity.javaClass.simpleName}") - } - } - - private fun onAppBackground() { - popup.remove() - } - - private fun onAppForeground(activity: Activity) { - DebugLog.d(LOG_TAG, "app_foreground ${activity.javaClass.simpleName}") - } - - init { - registerActivityLifecycle(application) - } - - private fun registerActivityLifecycle(application: Application) { - application.registerActivityLifecycleCallbacks(lifecycleCallbacks) - } - - fun customTabOpened() { - isCustomTabOpened = true - } - - fun customTabClosed() { - isCustomTabOpened = false - } - - companion object { - const val LOG_TAG = "activity_tracker" - } -} - -private fun Activity.registerFragmentLifecycle(callback: FragmentManager.FragmentLifecycleCallbacks) { - if (this is FragmentActivity) { - supportFragmentManager.registerFragmentLifecycleCallbacks(callback, true) - } - if (this is AppCompatActivity) { - supportFragmentManager.registerFragmentLifecycleCallbacks(callback, true) - } -} - -private fun Activity.unregisterFragmentLifecycle(callback: FragmentManager.FragmentLifecycleCallbacks) { - if (this is FragmentActivity) { - supportFragmentManager.unregisterFragmentLifecycleCallbacks(callback) - } - if (this is AppCompatActivity) { - supportFragmentManager.unregisterFragmentLifecycleCallbacks(callback) - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateFragment.kt deleted file mode 100644 index 027d79c06..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateFragment.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.mocklets.pluto.modules.appstate - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import com.mocklets.pluto.Pluto.appProperties -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.dp -import com.mocklets.pluto.core.extensions.hideKeyboard -import com.mocklets.pluto.core.extensions.showKeyboard -import com.mocklets.pluto.core.extensions.toast -import com.mocklets.pluto.core.sharing.Shareable -import com.mocklets.pluto.core.sharing.copyToClipboard -import com.mocklets.pluto.core.sharing.lazyContentSharer -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.CustomItemDecorator -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.routing.OnBackKeyHandler -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoFragmentAppStateBinding - -internal class AppStateFragment : Fragment(R.layout.pluto___fragment_app_state), OnBackKeyHandler { - - private val binding by viewBinding(PlutoFragmentAppStateBinding::bind) - private val appStateAdapter: BaseAdapter by lazy { AppStateItemAdapter(onActionListener) } - private val viewModel: AppStateViewModel by activityViewModels() - private val contentSharer by lazyContentSharer() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.list.apply { - adapter = appStateAdapter - addItemDecoration(CustomItemDecorator(context, DECORATOR_DIVIDER_PADDING)) - } - if (appProperties.isNotEmpty()) { - binding.noAppPropertiesGroup.visibility = GONE - binding.note.visibility = VISIBLE - } else { - binding.noAppPropertiesGroup.visibility = VISIBLE - binding.note.visibility = GONE - } - - binding.share.setDebounceClickListener { - viewModel.properties.value?.let { - contentSharer.share( - Shareable( - title = "Share App Properties", - content = it.toShareText(), - fileName = "App Properties from Pluto" - ) - ) - } - } - - binding.close.setDebounceClickListener { - activity?.onBackPressed() - } - binding.noAppPropertiesCta.setDebounceClickListener { - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://pluto.mocklets.com/#doc_set_properties")) - startActivity(browserIntent) - } - binding.search.setDebounceClickListener { - binding.searchView.visibility = VISIBLE - binding.searchView.requestFocus() - } - binding.closeSearch.setDebounceClickListener { - exitSearch() - } - binding.clearSearch.setDebounceClickListener { - binding.editSearch.text = null - } - binding.editSearch.setOnFocusChangeListener { v, hasFocus -> - if (hasFocus) { - v.showKeyboard() - } else { - v.hideKeyboard() - } - } - binding.editSearch.addTextChangedListener { editable -> - lifecycleScope.launchWhenResumed { - editable?.toString()?.let { - viewModel.filter(it) - } - } - } - - viewModel.properties.removeObserver(appPropertiesObserver) - viewModel.properties.observe(viewLifecycleOwner, appPropertiesObserver) - viewModel.filter() - } - - private val appPropertiesObserver = Observer> { - if (it.isNotEmpty()) { - binding.share.visibility = VISIBLE - binding.divider.visibility = VISIBLE - } - appStateAdapter.list = it - } - - private val onActionListener = object : DiffAwareAdapter.OnActionListener { - override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder?) { - when (data) { - is AppStateItem -> { - context?.copyToClipboard("${data.key} : ${data.value}", "app_state") - context?.toast("${data.key} copied!") - } - } - } - } - - override fun onBackPressed(): Boolean { - if (binding.searchView.isVisible) { - exitSearch() - return true - } - return false - } - - private fun exitSearch() { - binding.editSearch.text = null - binding.searchView.visibility = GONE - binding.editSearch.clearFocus() - } - - private companion object { - val DECORATOR_DIVIDER_PADDING = 16f.dp.toInt() - } -} - -private fun List.toShareText(): String { - val text = StringBuilder() - text.append("App Properties : \n\n") - this.forEach { - text.append("${it.key} : ${it.value}\n") - } - return text.toString() -} - -data class AppStateItem( - val key: String, - val value: String? -) : ListItem() diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateItemAdapter.kt b/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateItemAdapter.kt deleted file mode 100644 index 1637062fe..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateItemAdapter.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.mocklets.pluto.modules.appstate - -import android.view.ViewGroup -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem - -internal class AppStateItemAdapter(private val listener: OnActionListener) : BaseAdapter() { - override fun getItemViewType(item: ListItem): Int? { - return when (item) { - is AppStateItem -> ITEM_TYPE_APP_STATE - else -> null - } - } - - override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { - return when (viewType) { - ITEM_TYPE_APP_STATE -> AppStateItemHolder(parent, listener) - else -> null - } - } - - companion object { - const val ITEM_TYPE_APP_STATE = 1000 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateItemHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateItemHolder.kt deleted file mode 100644 index d26cbc4c7..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateItemHolder.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.mocklets.pluto.modules.appstate - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoItemAppStateBinding - -internal class AppStateItemHolder( - parent: ViewGroup, - listener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_app_state), listener) { - - private val binding = PlutoItemAppStateBinding.bind(itemView) - - private val key = binding.key - private val value = binding.value - - override fun onBind(item: ListItem) { - if (item is AppStateItem) { - key.text = "- ${item.key}" - value.text = item.value - itemView.setDebounceClickListener { - onAction("click") - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateViewModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateViewModel.kt deleted file mode 100644 index 219f66c67..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/appstate/AppStateViewModel.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.mocklets.pluto.modules.appstate - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import com.mocklets.pluto.Pluto.appProperties - -internal class AppStateViewModel : ViewModel() { - - val properties: LiveData> - get() = _properties - private val _properties = MutableLiveData>() - - fun filter(search: String = "") { - _properties.postValue(appProperties.convert(search)) - } - - private fun HashMap.convert(search: String): List { - val list = arrayListOf() - forEach { (key, value) -> - if (key.contains(search, true)) { - list.add(AppStateItem(key, value)) - } - } - return list - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ANRException.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ANRException.kt deleted file mode 100644 index d8e04fb08..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ANRException.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.mocklets.pluto.modules.exceptions - -import androidx.annotation.Keep -import java.io.ByteArrayOutputStream -import java.io.PrintStream -import java.util.Locale - -/** - * [Exception] to represent an ANR. This [Exception]'s stack trace will be the current stack trace of the given [Thread] - */ -@Keep -class ANRException(thread: Thread) : Exception("ANR detected") { - - val threadStateMap: String - internal val threadStateList: List - - init { - stackTrace = thread.stackTrace - threadStateMap = generateProcessMap() - threadStateList = generateProcessList() - } - - /** - * Logs the current process and all its threads - */ - private fun generateProcessMap(): String { - val bos = ByteArrayOutputStream() - val ps = PrintStream(bos) - printProcessMap(ps) - return String(bos.toByteArray()) - } - - /** - * Prints the current process and all its threads - * - * @param ps the [PrintStream] to which the - * info is written - */ - private fun printProcessMap(ps: PrintStream) { - // Get all stack traces in the system - val stackTraces = Thread.getAllStackTraces() - ps.println("Process map:") - for (thread in stackTraces.keys) { - if (!stackTraces[thread].isNullOrEmpty()) { - printThread(ps, Locale.getDefault(), thread, stackTraces[thread]!!) - ps.println() - } - } - } - - /** - * Prints the given thread - * @param ps the [PrintStream] to which the - * info is written - * @param l the [Locale] to use - * @param thread the [Thread] to print - * @param stack the [Thread]'s stack trace - */ - private fun printThread(ps: PrintStream, l: Locale, thread: Thread, stack: Array) { - ps.println(String.format(l, "\t%s (%s)", thread.name, thread.state)) - for (element in stack) { - element.apply { - ps.println(String.format(l, "\t\t%s.%s(%s:%d)", className, methodName, fileName, lineNumber)) - } - } - } - - private fun generateProcessList(): List { - val list = arrayListOf() - val stackTraces = Thread.getAllStackTraces() - for (thread in stackTraces.keys) { - if (!stackTraces[thread].isNullOrEmpty()) { - val process = ProcessThread(thread.name, thread.state.name, stackTraces[thread]!!.asStringArray()) - list.add(process) - } - } - return list - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ANRListener.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ANRListener.kt deleted file mode 100644 index 30c4d19dd..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ANRListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.mocklets.pluto.modules.exceptions - -import androidx.annotation.Keep - -@Keep -interface ANRListener { - fun onAppNotResponding(exception: ANRException) -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/DataModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/DataModel.kt deleted file mode 100644 index 6e8517f6a..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/DataModel.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.mocklets.pluto.modules.exceptions - -import androidx.annotation.Keep -import com.mocklets.pluto.BuildConfig -import com.mocklets.pluto.core.DeviceFingerPrint -import com.mocklets.pluto.core.ui.list.ListItem - -@Keep -internal data class ExceptionAllData( - val thread: ThreadData? = null, - val exception: ExceptionData, - val device: DeviceFingerPrint, - val threadStateList: ThreadStates? = null -) - -@Keep -internal data class ThreadData( - val id: Long, - val name: String, - val priority: Int, - val isDaemon: Boolean, - val state: String, - val group: ThreadGroupData? -) : ListItem() - -@Keep -internal data class ThreadGroupData( - val name: String, - val parent: String, - val activeCount: Int -) - -@Keep -internal data class ExceptionData( - val message: String?, - val name: String?, - val file: String?, - val lineNumber: Int, - val stackTrace: ArrayList, - val timeStamp: Long = System.currentTimeMillis(), - val isANRException: Boolean = false -) : ListItem() - -@Keep -internal data class ThreadStates( - val states: List -) : ListItem() - -@Keep -internal data class ProcessThread( - val name: String, - val state: String, - val stackTrace: ArrayList -) : ListItem() - -internal fun Throwable.asExceptionData(isANR: Boolean = false): ExceptionData { - return ExceptionData( - name = this.toString().replace(": $message", "", true), - message = message, - stackTrace = stackTrace.asStringArray(), - file = stackTrace.getOrNull(0)?.fileName, - lineNumber = stackTrace.getOrNull(0)?.lineNumber ?: Int.MIN_VALUE, - isANRException = isANR - ) -} - -internal fun Array.asStringArray(): ArrayList { - val array = arrayListOf() - forEach { - if (it.isNativeMethod) { - array.add("${it.className}.${it.methodName}(Native Method)") - } else { - array.add("${it.className}.${it.methodName}(${it.fileName}:${it.lineNumber})") - } - } - return array -} - -internal fun Thread.asThreadData(): ThreadData { - return ThreadData( - id = id, - name = name, - isDaemon = isDaemon, - state = state.name, - group = threadGroup.convert(), - priority = priority - ) -} - -private fun ThreadGroup?.convert(): ThreadGroupData? { - this?.let { - ThreadGroupData( - name = it.name, - parent = it.parent.name, - activeCount = it.activeCount() - ) - } - return null -} - -@Keep -data class ReportData( - val message: String?, - val name: String?, - val stackTrace: ArrayList, - val client: String?, - val gitSha: String = BuildConfig.GIT_SHA, - val version: String = BuildConfig.VERSION_NAME, - val buildType: String = BuildConfig.BUILD_TYPE -) diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ExceptionRepo.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ExceptionRepo.kt deleted file mode 100644 index 1474a3eb0..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ExceptionRepo.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.mocklets.pluto.modules.exceptions - -import android.content.Context -import com.google.gson.Gson -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.core.DeviceFingerPrint -import com.mocklets.pluto.core.database.DatabaseManager -import com.mocklets.pluto.core.extensions.capitalizeText -import com.mocklets.pluto.modules.exceptions.dao.ExceptionEntity -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -internal class ExceptionRepo(context: Context) { - - private val db = DatabaseManager(context).db - private val preferences by lazy { Pluto.preferences } - private val deviceFingerPrint = DeviceFingerPrint(context) - private val scope = CoroutineScope(Dispatchers.IO) - - init { - checkAndSaveCrash() - } - - @Synchronized - private fun checkAndSaveCrash() { - scope.launch { - preferences.lastSessionCrash?.let { - val exception = Gson().fromJson(it, ExceptionEntity::class.java) - db.exceptionDao().save( - ExceptionEntity( - timestamp = exception.timestamp, - data = exception.data - ) - ) - preferences.lastSessionCrash = null - } - } - } - - @Synchronized - internal fun saveANR(e: ANRException) { - scope.launch { - db.exceptionDao().save( - ExceptionEntity( - timestamp = System.currentTimeMillis(), - data = ExceptionAllData( - exception = e.asExceptionData(true), - device = deviceFingerPrint, - threadStateList = ThreadStates(e.threadStateList) - ) - ) - ) - } - } - - companion object { - fun getPriorityString(priority: Int) = - when (priority) { - Thread.MAX_PRIORITY -> "maximum" - Thread.MIN_PRIORITY -> "minimum" - else -> "normal" - }.capitalizeText() - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/anrs/AnrSupervisorCallback.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/anrs/AnrSupervisorCallback.kt deleted file mode 100644 index 337405b1a..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/anrs/AnrSupervisorCallback.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.anrs - -import com.mocklets.pluto.core.extensions.notifyAll - -/** - * A [Runnable] which calls [.notifyAll] when run. - */ -internal class AnrSupervisorCallback -/** - * Creates a new instance - */ - : Runnable { - /** - * Returns whether [.run] was called yet - * - * @return true if called, false if not - */ - /** - * Flag storing whether [.run] was called - */ - @get:Synchronized - var isCalled = false - private set - - @Synchronized - override fun run() { - isCalled = true - this.notifyAll() - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/anrs/AnrSupervisorRunnable.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/anrs/AnrSupervisorRunnable.kt deleted file mode 100644 index f21c6007d..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/anrs/AnrSupervisorRunnable.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.anrs - -import android.os.Handler -import android.os.Looper -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.core.extensions.wait -import com.mocklets.pluto.modules.exceptions.ANRException -import com.mocklets.pluto.modules.exceptions.ANRListener -import com.mocklets.pluto.modules.exceptions.anrs.AnrSupervisor.Companion.ANR_WATCHER_THREAD_NAME -import com.mocklets.pluto.modules.exceptions.anrs.AnrSupervisor.Companion.ANR_WATCHER_TIMEOUT -import com.mocklets.pluto.modules.exceptions.anrs.AnrSupervisor.Companion.LOGTAG -import com.mocklets.pluto.modules.exceptions.anrs.AnrSupervisor.Companion.MAIN_THREAD_RESPONSE_THRESHOLD - -/** - * A [Runnable] testing the UI thread every 10s until [ ][.stop] is called - */ -internal class AnrSupervisorRunnable : Runnable { - /** - * The [Handler] to access the UI threads message queue - */ - private val mHandler = Handler(Looper.getMainLooper()) - private var anrListener: ANRListener? = null - - /** - * The stop flag - */ - private var mStopped = false - /** - * Returns whether the stop is completed - * - * @return true if stop is completed, false if not - */ - /** - * Flag indicating the stop was performed - */ - @get:Synchronized - var isStopped = true - private set - - override fun run() { - Thread.currentThread().name = ANR_WATCHER_THREAD_NAME - isStopped = false - - while (!Thread.interrupted()) { - try { - DebugLog.d(LOGTAG, "Check for ANR...") - - // Create new callback - val callback = AnrSupervisorCallback() - - // Perform test, Handler should run the callback within 1s - synchronized(callback) { - mHandler.post(callback) - callback.wait(MAIN_THREAD_RESPONSE_THRESHOLD) - - // Check if called - if (!callback.isCalled) { - val e = ANRException(mHandler.looper.thread) - anrListener?.onAppNotResponding(e) - Pluto.exceptionRepo.saveANR(e) - /** Wait until the thread responds again */ - callback.wait() - } else { - DebugLog.d(LOGTAG, "UI Thread responded within 1s") - } - } - // Check if stopped - checkStopped() - /** Sleep for next test */ - Thread.sleep(ANR_WATCHER_TIMEOUT) - } catch (e: InterruptedException) { - break - } - } - - // Set stop completed flag - isStopped = true - DebugLog.d(LOGTAG, "ANR supervision stopped") - } - - @Synchronized - @Throws(InterruptedException::class) - private fun checkStopped() { - if (mStopped) { - Thread.sleep(MAIN_THREAD_RESPONSE_THRESHOLD) - if (mStopped) { - throw InterruptedException() - } - } - } - - /** - * Stops the check - */ - @Synchronized - fun stop() { - DebugLog.d(LOGTAG, "Stopping...") - mStopped = true - } - - /** - * Stops the check - */ - @Synchronized - fun unstop() { - DebugLog.d(LOGTAG, "Revert stopping...") - mStopped = false - } - - fun setListener(listener: ANRListener) { - anrListener = listener - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/crashes/CrashHandler.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/crashes/CrashHandler.kt deleted file mode 100644 index 100a90521..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/crashes/CrashHandler.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.crashes - -import android.content.Context -import com.google.gson.Gson -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.core.DeviceFingerPrint -import com.mocklets.pluto.modules.exceptions.ExceptionAllData -import com.mocklets.pluto.modules.exceptions.asExceptionData -import com.mocklets.pluto.modules.exceptions.asThreadData -import com.mocklets.pluto.modules.exceptions.dao.ExceptionEntity - -internal class CrashHandler(context: Context) : Thread.UncaughtExceptionHandler { - - private var handler: Thread.UncaughtExceptionHandler? = Thread.getDefaultUncaughtExceptionHandler() - private val deviceFingerPrint = DeviceFingerPrint(context) - private val preferences by lazy { Pluto.preferences } - private val crashNotification: CrashNotification = CrashNotification(context) - - override fun uncaughtException(t: Thread, e: Throwable) { - val exceptionData = ExceptionEntity( - timestamp = System.currentTimeMillis(), - data = ExceptionAllData( - thread = t.asThreadData(), - exception = e.asExceptionData(), - device = deviceFingerPrint - ) - ) - preferences.lastSessionCrash = Gson().toJson(exceptionData) - crashNotification.add() - handler?.uncaughtException(t, e) - } - - internal fun setExceptionHandler(handler: Thread.UncaughtExceptionHandler) { - this.handler = handler - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/crashes/CrashNotification.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/crashes/CrashNotification.kt deleted file mode 100644 index 077e29aea..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/crashes/CrashNotification.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.crashes - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import com.mocklets.pluto.R -import com.mocklets.pluto.core.notification.NotificationUtil -import com.mocklets.pluto.ui.PlutoActivity - -internal class CrashNotification(private val context: Context) { - - private val notificationUtil = NotificationUtil(context) - - private val clientAppName: String = context.packageManager.getApplicationLabel( - context.packageManager.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA) - ) as String - - fun add() { - val notificationIntent = Intent(context, PlutoActivity::class.java) - // todo add intent data to open crash tab directly - val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) - } else { - PendingIntent.getActivity(context, 0, notificationIntent, 0) - } - - notificationUtil.notify( - title = context.getString(R.string.pluto___crash_notification_title, clientAppName), - text = context.getString(R.string.pluto___crash_notification_subtitle), - intent = pendingIntent, - isOngoing = false, - isAutoCancel = true - ) - } - - fun remove() { - notificationUtil.cancel() - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/dao/EntityConverters.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/dao/EntityConverters.kt deleted file mode 100644 index 81ccea202..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/dao/EntityConverters.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.dao - -import androidx.room.TypeConverter -import com.google.gson.Gson -import com.mocklets.pluto.modules.exceptions.ExceptionAllData - -internal class EntityConverters { - - private val gson = Gson() - - @TypeConverter - fun stringToException(data: String?): ExceptionAllData { - return gson.fromJson(data, ExceptionAllData::class.java) - } - - @TypeConverter - fun exceptionToString(data: ExceptionAllData): String? { - return gson.toJson(data) - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/dao/ExceptionEntity.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/dao/ExceptionEntity.kt deleted file mode 100644 index f1cda27cd..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/dao/ExceptionEntity.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.dao - -import androidx.annotation.Keep -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import androidx.room.TypeConverters -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.modules.exceptions.ExceptionAllData - -@Keep -@TypeConverters(EntityConverters::class) -@Entity(tableName = "exceptions") -internal data class ExceptionEntity( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "id") - val id: Int? = null, - @ColumnInfo(name = "timestamp") - val timestamp: Long, - @ColumnInfo(name = "data") - val data: ExceptionAllData -) : ListItem() diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashDetailsFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashDetailsFragment.kt deleted file mode 100644 index 996ff735a..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashDetailsFragment.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.ui - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.os.Parcelable -import android.util.Base64.DEFAULT -import android.util.Base64.encodeToString -import android.view.View -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import com.google.gson.Gson -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.R -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.capitalizeText -import com.mocklets.pluto.core.extensions.delayedLaunchWhenResumed -import com.mocklets.pluto.core.extensions.lazyParcelExtra -import com.mocklets.pluto.core.extensions.toast -import com.mocklets.pluto.core.sharing.Shareable -import com.mocklets.pluto.core.sharing.lazyContentSharer -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoFragmentCrashDetailsBinding -import com.mocklets.pluto.modules.exceptions.ExceptionAllData -import com.mocklets.pluto.modules.exceptions.ExceptionRepo -import com.mocklets.pluto.modules.exceptions.ReportData -import com.mocklets.pluto.modules.exceptions.dao.ExceptionEntity -import java.net.URLEncoder -import java.util.Locale -import kotlinx.parcelize.Parcelize - -internal class CrashDetailsFragment : Fragment(R.layout.pluto___fragment_crash_details) { - - private val binding by viewBinding(PlutoFragmentCrashDetailsBinding::bind) - private val viewModel: CrashesViewModel by viewModels() - private val crashAdapter: BaseAdapter by lazy { CrashesAdapter(onActionListener) } - private val arguments by lazyParcelExtra() - private val exceptionCipher by lazy { getCipheredException() } - private val contentSharer by lazyContentSharer() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - arguments?.id?.let { viewModel.fetch(it) } - binding.list.apply { - adapter = crashAdapter - } - binding.close.setDebounceClickListener { - activity?.onBackPressed() - } - - binding.delete.setDebounceClickListener { - viewModel.currentException.value?.id?.let { id -> - lifecycleScope.delayedLaunchWhenResumed(SCREEN_CLOSE_DELAY) { - viewModel.delete(id) - activity?.onBackPressed() - context?.toast("Crash logs deleted.") - } - } - } - - binding.share.setDebounceClickListener { - viewModel.currentException.value?.let { - contentSharer.share(Shareable(title = "Share Crash Report", content = it.data.toShareText(), fileName = "Crash Report from Pluto")) - } - } - - viewModel.currentException.removeObservers(viewLifecycleOwner) - viewModel.currentException.observe(viewLifecycleOwner, exceptionObserver) - } - - private fun getCipheredException(): String? { - viewModel.currentException.value?.data?.exception?.let { - val reportData = ReportData( - name = it.name, - message = it.message, - stackTrace = it.stackTrace.take(STACK_TRACE_SHORT_LENGTH) as ArrayList, - client = Pluto.appContext?.packageName - ) - val exceptionString = Gson().toJson(reportData) - - val encodedByteArray = exceptionString.toByteArray(Charsets.UTF_8) - val encodedString = encodeToString(encodedByteArray, DEFAULT) - -// val decodedByteArray = decode(encodedString, DEFAULT) -// val decodedString = String(decodedByteArray, Charsets.UTF_8) - - return URLEncoder.encode(encodedString, "utf-8") - } - return null - } - - private val onActionListener = object : DiffAwareAdapter.OnActionListener { - override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder?) { - if (action == "report_crash") { - exceptionCipher?.let { - val url = "https://pluto.mocklets.com/exception/$it/a0bbe9cd-2f02-4a12-b7b7-36fce61a6b48" - DebugLog.d("Prateek", url) - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - startActivity(browserIntent) - } - } - } - } - - private val exceptionObserver = Observer { - val list = arrayListOf() - list.add(it.data.exception) - it.data.threadStateList?.let { states -> list.add(states) } - it.data.thread?.let { thread -> list.add(thread) } - list.add(it.data.device) - - crashAdapter.list = list - } - - @Parcelize - data class Data(val id: Int) : Parcelable - - private companion object { - const val SCREEN_CLOSE_DELAY = 200L - private const val STACK_TRACE_SHORT_LENGTH = 15 - } -} - -private const val STACK_TRACE_LENGTH = 25 -private fun ExceptionAllData.toShareText(): String { - val text = StringBuilder() - text.append("EXCEPTION : \n") - text.append("${this.exception.name}: ${this.exception.message}\n") - this.exception.stackTrace.take(STACK_TRACE_LENGTH).forEach { - text.append("\t at $it\n") - } - if (this.exception.stackTrace.size - STACK_TRACE_LENGTH > 0) { - text.append("\t + ${this.exception.stackTrace.size - STACK_TRACE_LENGTH} more lines\n\n") - } - - this.thread?.let { - text.append("Thread : ") - text.append("${it.name.uppercase(Locale.getDefault())} (") - text.append("id : ${it.id}, ") - text.append("priority : ${ExceptionRepo.getPriorityString(it.priority)}, ") - text.append("is_Daemon : ${it.isDaemon}, ") - text.append("state : ${it.state}") - text.append(")") - - text.append("\n\n==================\n\n") - } - - text.append("APP STATE : \n") - this.device.software.appVersion?.let { - text.append("App Version : ${it.name} (${it.code})\n") - } - text.append("Android (OS : ${this.device.software.androidOs}, API_Level : ${this.device.software.androidAPILevel})\n") - text.append("Orientation : ${this.device.software.orientation}\n") - text.append("is_Rooted : ${this.device.isRooted}") - - text.append("\n\n==================\n\n") - - text.append("DEVICE INFO : \n") - text.append("Model : ${this.device.build.brand?.capitalizeText()} ${this.device.build.model}\n") - text.append( - "Screen : { height : ${this.device.screen.height}, width : ${this.device.screen.height}, " + - "density : ${this.device.screen.density}, size : ${this.device.screen.size} }" - ) - return text.toString() -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashesAdapter.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashesAdapter.kt deleted file mode 100644 index 08151520b..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashesAdapter.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.ui - -import android.view.ViewGroup -import com.mocklets.pluto.core.DeviceFingerPrint -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.modules.exceptions.ExceptionData -import com.mocklets.pluto.modules.exceptions.ProcessThread -import com.mocklets.pluto.modules.exceptions.ThreadData -import com.mocklets.pluto.modules.exceptions.ThreadStates -import com.mocklets.pluto.modules.exceptions.dao.ExceptionEntity -import com.mocklets.pluto.modules.exceptions.ui.holder.CrashItemDetailsDeviceHolder -import com.mocklets.pluto.modules.exceptions.ui.holder.CrashItemDetailsHeaderHolder -import com.mocklets.pluto.modules.exceptions.ui.holder.CrashItemDetailsThreadHolder -import com.mocklets.pluto.modules.exceptions.ui.holder.CrashItemDetailsThreadStatesHolder -import com.mocklets.pluto.modules.exceptions.ui.holder.CrashItemDetailsThreadStatesItemHolder -import com.mocklets.pluto.modules.exceptions.ui.holder.CrashItemHolder - -internal class CrashesAdapter(private val listener: OnActionListener) : BaseAdapter() { - override fun getItemViewType(item: ListItem): Int? { - return when (item) { - is ExceptionEntity -> ITEM_TYPE_CRASH - is ExceptionData -> ITEM_DETAILS_TYPE_HEADER - is ThreadData -> ITEM_DETAILS_TYPE_THREAD - is DeviceFingerPrint -> ITEM_DETAILS_TYPE_DEVICE - is ThreadStates -> ITEM_DETAILS_TYPE_THREAD_STATES - is ProcessThread -> ITEM_DETAILS_TYPE_THREAD_STATES_ITEM - else -> null - } - } - - override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { - return when (viewType) { - ITEM_TYPE_CRASH -> CrashItemHolder(parent, listener) - ITEM_DETAILS_TYPE_HEADER -> CrashItemDetailsHeaderHolder(parent, listener) - ITEM_DETAILS_TYPE_THREAD -> CrashItemDetailsThreadHolder(parent, listener) - ITEM_DETAILS_TYPE_DEVICE -> CrashItemDetailsDeviceHolder(parent, listener) - ITEM_DETAILS_TYPE_THREAD_STATES -> CrashItemDetailsThreadStatesHolder(parent, listener) - ITEM_DETAILS_TYPE_THREAD_STATES_ITEM -> CrashItemDetailsThreadStatesItemHolder(parent, listener) - else -> null - } - } - - companion object { - const val ITEM_TYPE_CRASH = 1000 - const val ITEM_DETAILS_TYPE_HEADER = 1100 - const val ITEM_DETAILS_TYPE_THREAD = 1101 - const val ITEM_DETAILS_TYPE_DEVICE = 1102 - const val ITEM_DETAILS_TYPE_THREAD_STATES = 1103 - const val ITEM_DETAILS_TYPE_THREAD_STATES_ITEM = 1104 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashesFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashesFragment.kt deleted file mode 100644 index f8163ae31..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/CrashesFragment.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.ui - -import android.os.Bundle -import android.view.View -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.hideKeyboard -import com.mocklets.pluto.core.extensions.linearLayoutManager -import com.mocklets.pluto.core.extensions.showMoreOptions -import com.mocklets.pluto.core.extensions.toast -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.CustomItemDecorator -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.routing.Screens -import com.mocklets.pluto.core.ui.routing.lazyRouter -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoFragmentCrashesBinding -import com.mocklets.pluto.modules.exceptions.dao.ExceptionEntity - -internal class CrashesFragment : Fragment(R.layout.pluto___fragment_crashes) { - - private val binding by viewBinding(PlutoFragmentCrashesBinding::bind) - private val viewModel: CrashesViewModel by activityViewModels() - private val crashAdapter: BaseAdapter by lazy { CrashesAdapter(onActionListener) } - private val router by lazyRouter() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.logList.apply { - adapter = crashAdapter - addItemDecoration(CustomItemDecorator(requireContext())) - } - binding.search.addTextChangedListener { editable -> - lifecycleScope.launchWhenResumed { - editable?.toString()?.let { - Pluto.session.exceptionSearchText = it - crashAdapter.list = filteredLogs(it) - if (it.isEmpty()) { - binding.logList.linearLayoutManager()?.scrollToPositionWithOffset(0, 0) - } - } - } - } - binding.more.setDebounceClickListener { - requireContext().showMoreOptions(it, R.menu.pluto___popup_menu_crashes) { item -> - when (item.itemId) { - R.id.clear -> viewModel.deleteAll() - } - } - } - binding.search.setText(Pluto.session.exceptionSearchText) - viewModel.exceptions.removeObserver(exceptionObserver) - viewModel.exceptions.observe(viewLifecycleOwner, exceptionObserver) - } - - private fun filteredLogs(search: String): List { - var list = emptyList() - viewModel.exceptions.value?.let { - list = it.filter { exception -> - (exception.data.exception.name ?: "").contains(search, true) || - (exception.data.exception.file ?: "").contains(search, true) - } - } - binding.noItemText.text = getString( - if (search.isNotEmpty()) R.string.pluto___no_search_result else R.string.pluto___no_crashes_text - ) - binding.noItemText.visibility = if (list.isEmpty()) View.VISIBLE else View.GONE - return list - } - - private val exceptionObserver = Observer> { - crashAdapter.list = filteredLogs(binding.search.text.toString()) - } - - private val onActionListener = object : DiffAwareAdapter.OnActionListener { - override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder?) { - if (data is ExceptionEntity) { - activity?.hideKeyboard() - if (data.id != null) { - router.navigate(Screens.CrashDetails(CrashDetailsFragment.Data(data.id))) - } else { - context?.toast("invalid crash id") - } - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsDeviceHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsDeviceHolder.kt deleted file mode 100644 index f3ebec0ec..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsDeviceHolder.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.ui.holder - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.DeviceFingerPrint -import com.mocklets.pluto.core.extensions.capitalizeText -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.databinding.PlutoItemCrashDetailsDeviceBinding - -internal class CrashItemDetailsDeviceHolder( - parent: ViewGroup, - actionListener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_crash_details_device), actionListener) { - - private val binding = PlutoItemCrashDetailsDeviceBinding.bind(itemView) - - private val appVersion = binding.appVersion - private val androidOS = binding.androidOS - private val androidAPI = binding.androidAPILevel - private val orientation = binding.orientation - private val rooted = binding.rooted - - private val height = binding.deviceHeight - private val width = binding.deviceWidth - private val density = binding.density - private val size = binding.size - private val build = binding.build - - override fun onBind(item: ListItem) { - if (item is DeviceFingerPrint) { - item.software.appVersion?.let { appVersion.text = "${it.name} (${it.code})" } - androidOS.text = item.software.androidOs - androidAPI.text = item.software.androidAPILevel - orientation.text = item.software.orientation.capitalizeText() - rooted.text = item.isRooted.toString() - - build.text = "${item.build.brand?.capitalizeText()} ${item.build.model}" - height.text = item.screen.height - width.text = item.screen.width - density.text = item.screen.density - size.text = item.screen.size - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsHeaderHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsHeaderHolder.kt deleted file mode 100644 index bb392e2df..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsHeaderHolder.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.ui.holder - -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import com.mocklets.pluto.BuildConfig -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.asFormattedDate -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoItemCrashDetailsHeaderBinding -import com.mocklets.pluto.modules.exceptions.ExceptionData -import com.mocklets.pluto.modules.exceptions.anrs.AnrSupervisor.Companion.MAIN_THREAD_RESPONSE_THRESHOLD - -internal class CrashItemDetailsHeaderHolder( - parent: ViewGroup, - actionListener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_crash_details_header), actionListener) { - - private val binding = PlutoItemCrashDetailsHeaderBinding.bind(itemView) - private val timestamp = binding.timestamp - private val stacktrace = binding.stackTrace - private val reportCrash = binding.reportCrash.crashReportRoot - private val message = binding.message - private val title = binding.title - - override fun onBind(item: ListItem) { - if (item is ExceptionData) { - handleTitle(item) - timestamp.text = item.timeStamp.asFormattedDate() - - stacktrace.setSpan { - append("${item.name}: ${item.message}") - item.stackTrace.take(MAX_STACK_TRACE_LINES).forEach { - append("\n\t\t\t") - append( - fontColor( - " at ", context.color(R.color.pluto___text_dark_40) - ) - ) - append(it) - } - val extraTrace = item.stackTrace.size - MAX_STACK_TRACE_LINES - if (extraTrace > 0) { - append( - fontColor( - "\n\t\t\t + $extraTrace more lines", context.color(R.color.pluto___text_dark_40) - ) - ) - } - } - - if (isPlutoCrash(item.stackTrace.take(MAX_STACK_TRACE_LINES))) { - reportCrash.visibility = VISIBLE - reportCrash.setDebounceClickListener { - onAction("report_crash") - } - } else { - reportCrash.visibility = GONE - reportCrash.setDebounceClickListener {} - } - } - } - - private fun handleTitle(item: ExceptionData) { - if (item.isANRException) { - message.text = - context.getString(R.string.pluto___anr_list_message, MAIN_THREAD_RESPONSE_THRESHOLD) - title.setSpan { - context.apply { - append( - fontColor( - getString(R.string.pluto___anr_list_title), - color(R.color.pluto___text_dark_80) - ) - ) - } - } - } else { - title.setSpan { - append("${item.file}\t\t") - append( - fontColor("line: ${item.lineNumber}", context.color(R.color.pluto___text_dark_80)) - ) - } - message.setSpan { - append("${item.name}\n") - append( - fontColor("${item.message}", context.color(R.color.pluto___text_dark_60)) - ) - } - } - } - - private fun isPlutoCrash(trace: List): Boolean { - trace.forEach { - if (it.startsWith(BuildConfig.LIBRARY_PACKAGE_NAME)) { - return true - } - } - return false - } - - companion object { - const val MAX_STACK_TRACE_LINES = 20 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsThreadHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsThreadHolder.kt deleted file mode 100644 index 0c40ba653..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsThreadHolder.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.ui.holder - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoItemCrashDetailsThreadBinding -import com.mocklets.pluto.modules.exceptions.ExceptionRepo -import com.mocklets.pluto.modules.exceptions.ThreadData - -internal class CrashItemDetailsThreadHolder( - parent: ViewGroup, - actionListener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_crash_details_thread), actionListener) { - - private val binding = PlutoItemCrashDetailsThreadBinding.bind(itemView) - private val name = binding.name - private val priority = binding.priority - private val daemon = binding.daemon - private val state = binding.state - - override fun onBind(item: ListItem) { - if (item is ThreadData) { - name.setSpan { - append("${item.name.uppercase()} ") - append( - fontColor("(thread id: ${item.id})", context.color(R.color.pluto___text_dark_60)) - ) - } - priority.text = ExceptionRepo.getPriorityString(item.priority) - daemon.text = item.isDaemon.toString() - state.text = item.state - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsThreadStatesHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsThreadStatesHolder.kt deleted file mode 100644 index 807c22e17..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsThreadStatesHolder.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.ui.holder - -import android.view.ViewGroup -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.dp -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.CustomItemDecorator -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoItemCrashDetailsThreadStatesBinding -import com.mocklets.pluto.modules.exceptions.ThreadStates -import com.mocklets.pluto.modules.exceptions.ui.CrashesAdapter - -internal class CrashItemDetailsThreadStatesHolder( - parent: ViewGroup, - actionListener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_crash_details_thread_states), actionListener) { - - private val binding = PlutoItemCrashDetailsThreadStatesBinding.bind(itemView) - private val onActionListener = object : DiffAwareAdapter.OnActionListener { - override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder?) { - } - } - - private var linearLayoutManager: LinearLayoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) - private var threadAdapter: BaseAdapter = CrashesAdapter(onActionListener) - - init { - binding.list.apply { - adapter = threadAdapter - layoutManager = linearLayoutManager - addItemDecoration(CustomItemDecorator(context!!, DECORATOR_DIVIDER_PADDING)) - } - } - - override fun onBind(item: ListItem) { - if (item is ThreadStates) { - threadAdapter.list = item.states - binding.label.setSpan { - append(context.getString(R.string.pluto___thread_states_label)) - append(fontColor(" (${item.states.size})", context.color(R.color.pluto___text_dark_40))) - } - } - } - - private companion object { - val DECORATOR_DIVIDER_PADDING = 16f.dp.toInt() - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsThreadStatesItemHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsThreadStatesItemHolder.kt deleted file mode 100644 index 6d1e15718..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemDetailsThreadStatesItemHolder.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.ui.holder - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoItemAnrThreadStateBinding -import com.mocklets.pluto.modules.exceptions.ProcessThread - -internal class CrashItemDetailsThreadStatesItemHolder( - parent: ViewGroup, - actionListener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_anr_thread_state), actionListener) { - - private val binding = PlutoItemAnrThreadStateBinding.bind(itemView) - - override fun onBind(item: ListItem) { - if (item is ProcessThread) { - binding.stackTrace.setSpan { - append( - fontColor( - semiBold("${item.name} (${item.state.uppercase()})"), - context.color(if (item.state == Thread.State.BLOCKED.name) R.color.pluto___red else R.color.pluto___text_dark_80) - ) - ) - item.stackTrace.take(MAX_STACK_TRACE_LINES).forEach { - append("\n\t") - append(fontColor(" at ", context.color(R.color.pluto___text_dark_40))) - append(it) - } - val extraTrace = item.stackTrace.size - MAX_STACK_TRACE_LINES - if (extraTrace > 0) { - append( - fontColor( - "\n\t + $extraTrace more lines", context.color(R.color.pluto___text_dark_40) - ) - ) - } - } - } - } - - companion object { - const val MAX_STACK_TRACE_LINES = 10 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemHolder.kt deleted file mode 100644 index c8b8f1739..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/exceptions/ui/holder/CrashItemHolder.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.mocklets.pluto.modules.exceptions.ui.holder - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.asTimeElapsed -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoItemCrashBinding -import com.mocklets.pluto.modules.exceptions.anrs.AnrSupervisor.Companion.MAIN_THREAD_RESPONSE_THRESHOLD -import com.mocklets.pluto.modules.exceptions.dao.ExceptionEntity - -internal class CrashItemHolder( - parent: ViewGroup, - actionListener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_crash), actionListener) { - - private val binding = PlutoItemCrashBinding.bind(itemView) - private val timeElapsed = binding.timeElapsed - - override fun onBind(item: ListItem) { - if (item is ExceptionEntity) { - with(item.data.exception) { - if (isANRException) { - binding.message.text = - context.getString(R.string.pluto___anr_list_message, MAIN_THREAD_RESPONSE_THRESHOLD) - binding.title.setSpan { - context.apply { - append( - fontColor( - getString(R.string.pluto___anr_list_title), color(R.color.pluto___text_dark_80) - ) - ) - } - } - binding.title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.pluto___ic_anr_warning, 0, 0, 0) - } else { - binding.message.setSpan { - append("${item.data.exception.file}\t\t") - append( - fontColor( - "line:${item.data.exception.lineNumber}", - context.color(R.color.pluto___text_dark_60) - ) - ) - } - binding.title.text = item.data.exception.name - binding.title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) - } - } - timeElapsed.text = item.timestamp.asTimeElapsed() - itemView.setDebounceClickListener { - onAction("click") - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/logging/DataModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/logging/DataModel.kt deleted file mode 100644 index de5d6952e..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/logging/DataModel.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.mocklets.pluto.modules.logging - -import androidx.annotation.DrawableRes -import com.mocklets.pluto.R -import com.mocklets.pluto.core.ui.list.ListItem - -internal sealed class Level( - val label: String, - val color: Int = R.color.pluto___transparent, - val textColor: Int = R.color.pluto___text_dark_60, - @DrawableRes val iconRes: Int = 0 -) { - object Verbose : Level("verbose") - object Debug : Level("debug") - object Info : Level("info") - object Warning : Level("warning") - object Error : Level("error", R.color.pluto___red_05, R.color.pluto___red_80) - object Event : Level(label = "event", iconRes = R.drawable.pluto___ic_analytics, textColor = R.color.pluto___blue) -} - -internal data class LogData( - val level: Level, - val tag: String, - val message: String, - val tr: Throwable? = null, - val stackTraceElement: StackTraceElement, - val eventAttributes: HashMap? = null, - val timeStamp: Long = System.currentTimeMillis() -) : ListItem() diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/logging/LogsRepo.kt b/pluto/src/main/java/com/mocklets/pluto/modules/logging/LogsRepo.kt deleted file mode 100644 index d7ecad0d5..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/logging/LogsRepo.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.mocklets.pluto.modules.logging - -import androidx.lifecycle.MutableLiveData - -internal object LogsRepo { - - private val logs = MutableLiveData>() - private val logsList = arrayListOf() - private const val MAX_LIMIT = 256 - - fun save(level: Level, tag: String, message: String?, tr: Throwable?, ele: StackTraceElement) { - synchronized(logsList) { - logsList.add(0, LogData(level, tag, message ?: "", tr, ele)) - val temp = logsList.take(MAX_LIMIT) - logsList.clear() - logsList.addAll(temp) - logs.postValue(logsList) - } - } - - fun saveEvent( - level: Level, - tag: String, - event: String?, - attributes: HashMap?, - ele: StackTraceElement - ) { - logsList.add(0, LogData(level, tag, event ?: "", null, ele, attributes)) - val temp = logsList.take(MAX_LIMIT) - logsList.clear() - logsList.addAll(temp) - logs.postValue(logsList) - } - - fun getLogsLD() = logs - - fun deleteAll() { - logsList.clear() - logs.postValue(logsList) - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogDetailsDialog.kt b/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogDetailsDialog.kt deleted file mode 100644 index b2adac134..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogDetailsDialog.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.mocklets.pluto.modules.logging.ui - -import android.content.Context -import android.graphics.drawable.ColorDrawable -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.widget.FrameLayout -import androidx.fragment.app.FragmentActivity -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.mocklets.pluto.R -import com.mocklets.pluto.core.DeviceInfo -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.sharing.Shareable -import com.mocklets.pluto.core.sharing.lazyContentSharer -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.createSpan -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoLayoutLogDetailsBinding -import com.mocklets.pluto.modules.exceptions.asExceptionData -import com.mocklets.pluto.modules.logging.LogData -import com.mocklets.pluto.modules.logging.ui.LogDetailsDialog.Companion.MAX_STACK_TRACE_LINES - -internal class LogDetailsDialog(context: FragmentActivity, data: LogData) : - BottomSheetDialog(context, R.style.PlutoBottomSheetDialogTheme) { - - private val sheetView: View = context.inflate(R.layout.pluto___layout_log_details) - private val binding = PlutoLayoutLogDetailsBinding.bind(sheetView) - private val deviceInfo = DeviceInfo(context) - private val contentSharer by context.lazyContentSharer() - - init { - setContentView(sheetView) - (sheetView.parent as View).background = ColorDrawable(context.color(R.color.pluto___transparent)) - - this.setOnShowListener { dialog -> - val d = dialog as BottomSheetDialog - - val bottomSheet = d.findViewById(R.id.design_bottom_sheet) as FrameLayout? - val behavior = BottomSheetBehavior.from(bottomSheet!!) - behavior.apply { - state = BottomSheetBehavior.STATE_EXPANDED - isHideable = false - skipCollapsed = true - peekHeight = deviceInfo.height - } - - binding.title.setSpan { - append("${context.getString(R.string.pluto___log_details)} ") - append(italic(fontColor(data.level.label.uppercase(), context.color(data.level.textColor)))) - } - - binding.cta.setDebounceClickListener { - contentSharer.share(Shareable(title = "Share Log details", content = data.toShareText(context))) - } - - binding.tag.text = data.tag - binding.filename.setSpan { - append(fontColor("called from\n", context.color(R.color.pluto___text_dark_40))) - append(data.stackTraceElement.methodName) - append(fontColor(" (", context.color(R.color.pluto___text_dark_40))) - append(data.stackTraceElement.fileName) - append(fontColor(", line:", context.color(R.color.pluto___text_dark_60))) - append(fontColor("${data.stackTraceElement.lineNumber}", context.color(R.color.pluto___text_dark_80))) - append(fontColor(")", context.color(R.color.pluto___text_dark_40))) - } - binding.message.text = data.message - binding.stackTraceContainer.visibility = GONE - data.tr?.asExceptionData()?.let { - binding.stackTraceContainer.visibility = VISIBLE - binding.stackTrace.setSpan { - append(fontColor("${it.name}: ${it.message}", context.color(R.color.pluto___text_dark_80))) - it.stackTrace.take(MAX_STACK_TRACE_LINES).forEach { - append("\n\t\t\t") - append(fontColor(" at ", context.color(R.color.pluto___text_dark_40))) - append(it) - } - val extraTrace = it.stackTrace.size - MAX_STACK_TRACE_LINES - if (extraTrace > 0) { - append( - fontColor( - "\n\t\t\t + $extraTrace more lines", context.color(R.color.pluto___text_dark_40) - ) - ) - } - } - } - - if (!data.eventAttributes.isNullOrEmpty()) { - binding.stackTraceContainer.visibility = VISIBLE - binding.stackTraceTitle.setSpan { - append(context.getString(R.string.pluto___event_attributes)) - append(fontColor(" (${data.eventAttributes.size})", context.color(R.color.pluto___text_dark_40))) - } - binding.stackTrace.text = context.beautifyAttributes(data.eventAttributes) - } - } - } - - companion object { - const val MAX_STACK_TRACE_LINES = 15 - } -} - -private fun Context?.beautifyAttributes(data: Map): CharSequence? { - return this?.createSpan { - data.forEach { - append("${it.key} : ") - if (it.value != null) { - append(fontColor(semiBold("${it.value}"), context.color(R.color.pluto___text_dark_80))) - } else { - append(fontColor(light(italic("null")), context.color(R.color.pluto___text_dark_40))) - } - append("\n") - } - }?.trim() -} - -private fun LogData.toShareText(context: Context): String { - val text = StringBuilder() - text.append("$tag : $message\n") - - tr?.asExceptionData()?.let { - text.append("\n${it.name}: ${it.message}\n") - it.stackTrace.take(MAX_STACK_TRACE_LINES).forEach { trace -> - text.append("\t at $trace\n") - } - if (it.stackTrace.size - MAX_STACK_TRACE_LINES > 0) { - text.append("\t + ${it.stackTrace.size - MAX_STACK_TRACE_LINES} more lines\n\n") - } - } - - if (!eventAttributes.isNullOrEmpty()) { - text.append("\n${context.getString(R.string.pluto___event_attributes).lowercase()} - ") - eventAttributes.entries.forEach { - text.append("\n\t ${it.key} : ${it.value}") - } - } - return text.toString() -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogItemHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogItemHolder.kt deleted file mode 100644 index d2a4bb399..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogItemHolder.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.mocklets.pluto.modules.logging.ui - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.asTimeElapsed -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.dp -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoItemLogBinding -import com.mocklets.pluto.modules.logging.LogData - -internal class LogItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : - DiffAwareHolder(parent.inflate(R.layout.pluto___item_log), actionListener) { - - private val binding = PlutoItemLogBinding.bind(itemView) - private val logTag = binding.logtag - private val message = binding.message - private val timestamp = binding.timestamp - - override fun onBind(item: ListItem) { - if (item is LogData) { - logTag.setSpan { - append(fontColor(semiBold(item.tag.trim()), context.color(R.color.pluto___text_dark_40))) - append( - fontColor( - " | ${item.stackTraceElement.fileName}:${item.stackTraceElement.lineNumber}", - context.color(R.color.pluto___text_dark_40) - ) - ) - } - logTag.setCompoundDrawablesWithIntrinsicBounds(item.level.iconRes, 0, 0, 0) - logTag.compoundDrawablePadding = DRAWABLE_PADDING - message.setSpan { - append(semiBold(item.message.trim())) - item.eventAttributes?.let { - append( - regular( - fontColor(" (${it.size} attributes)", context.color(R.color.pluto___text_dark_60)) - ) - ) - } - } - timestamp.text = item.timeStamp.asTimeElapsed() - itemView.setBackgroundColor(itemView.context.color(item.level.color)) - itemView.setDebounceClickListener { onAction("click") } - } - } - - private companion object { - val DRAWABLE_PADDING = 4f.dp.toInt() - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogsAdapter.kt b/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogsAdapter.kt deleted file mode 100644 index d332a5b29..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogsAdapter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.mocklets.pluto.modules.logging.ui - -import android.view.ViewGroup -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.modules.logging.LogData - -internal class LogsAdapter(private val listener: OnActionListener) : BaseAdapter() { - override fun getItemViewType(item: ListItem): Int? { - return when (item) { - is LogData -> ITEM_TYPE_LOG - else -> null - } - } - - override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { - return when (viewType) { - ITEM_TYPE_LOG -> LogItemHolder(parent, listener) - else -> null - } - } - - companion object { - const val ITEM_TYPE_LOG = 1000 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogsFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogsFragment.kt deleted file mode 100644 index 7448de9d0..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogsFragment.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.mocklets.pluto.modules.logging.ui - -import android.os.Bundle -import android.view.View -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.hideKeyboard -import com.mocklets.pluto.core.extensions.linearLayoutManager -import com.mocklets.pluto.core.extensions.showMoreOptions -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.CustomItemDecorator -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoFragmentLogsBinding -import com.mocklets.pluto.modules.logging.LogData -import com.mocklets.pluto.modules.logging.LogsRepo - -internal class LogsFragment : Fragment(R.layout.pluto___fragment_logs) { - - private val binding by viewBinding(PlutoFragmentLogsBinding::bind) - private val viewModel: LogsViewModel by viewModels() - private val logsAdapter: BaseAdapter by lazy { LogsAdapter(onActionListener) } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.logList.apply { - adapter = logsAdapter - addItemDecoration(CustomItemDecorator(requireContext())) - } - binding.search.addTextChangedListener { editable -> - lifecycleScope.launchWhenResumed { - editable?.toString()?.let { - Pluto.session.loggerSearchText = it - logsAdapter.list = filteredLogs(it) - if (it.isEmpty()) { - binding.logList.linearLayoutManager()?.scrollToPositionWithOffset(0, 0) - } - } - } - } - binding.more.setDebounceClickListener { - requireContext().showMoreOptions(it, R.menu.pluto___popup_menu_logger) { item -> - when (item.itemId) { - R.id.clear -> LogsRepo.deleteAll() - } - } - } - binding.search.setText(Pluto.session.loggerSearchText) - viewModel.logs.removeObserver(logsObserver) - viewModel.logs.observe(viewLifecycleOwner, logsObserver) - } - - private fun filteredLogs(search: String): List { - var list = emptyList() - viewModel.logs.value?.let { - list = it.filter { log -> - log.tag.contains(search, true) || - log.message.contains(search, true) || - log.stackTraceElement.fileName.contains(search, true) - } - } - binding.noItemText.text = getString( - if (search.isNotEmpty()) { - R.string.pluto___no_search_result - } else { - R.string.pluto___no_logs_text - } - ) - binding.noItemText.visibility = if (list.isEmpty()) View.VISIBLE else View.GONE - return list - } - - private val logsObserver = Observer> { - logsAdapter.list = filteredLogs(binding.search.text.toString()) - } - - private val onActionListener = object : DiffAwareAdapter.OnActionListener { - override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder?) { - if (data is LogData) { - activity?.let { - it.hideKeyboard(lifecycleScope) { - LogDetailsDialog(it, data).show() - } - } - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogsViewModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogsViewModel.kt deleted file mode 100644 index 386c9ac32..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/logging/ui/LogsViewModel.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.mocklets.pluto.modules.logging.ui - -import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import com.mocklets.pluto.modules.logging.LogData -import com.mocklets.pluto.modules.logging.LogsRepo - -internal class LogsViewModel : ViewModel() { - - val logs: LiveData> - get() = LogsRepo.getLogsLD() -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/BodyTransformer.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/BodyTransformer.kt deleted file mode 100644 index b3a5c2816..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/BodyTransformer.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.mocklets.pluto.modules.network - -import android.content.Context -import com.mocklets.pluto.R -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.ui.spannable.createSpan -import com.mocklets.pluto.modules.network.interceptor.doUnZipToString -import com.mocklets.pluto.modules.network.transformers.FormEncodedTransformer -import com.mocklets.pluto.modules.network.transformers.JsonBaseTransformer -import com.mocklets.pluto.modules.network.transformers.XmlBaseTransformer -import java.nio.charset.Charset -import okhttp3.HttpUrl -import okhttp3.RequestBody -import okhttp3.ResponseBody -import okio.Buffer -import okio.IOException - -internal fun RequestBody.convertPretty(gzipped: Boolean): ProcessedBody { - contentType()?.let { - DebugLog.e(LOGTAG, "request : ${it.type()}, ${it.subtype()}, ${it.charset()}") - return if (it.isText()) { - val plainBody = convert(gzipped) - ProcessedBody( - isValid = true, - body = it.beautify(plainBody), - mediaType = it.type(), - mediaSubtype = it.subtype() - ) - } else { - ProcessedBody( - isValid = true, - body = BINARY_BODY, - mediaType = BINARY_MEDIA_TYPE, - mediaSubtype = BINARY_MEDIA_TYPE - ) - } - } - return ProcessedBody( - isValid = false, - mediaType = null, - mediaSubtype = null - ) -} - -internal fun ResponseBody?.convertPretty(buffer: Buffer): ProcessedBody? { - this?.let { - val contentType = it.contentType() - if (contentType != null) { - return if (contentType.isText()) { - val body = buffer.readString(contentType.charset(UTF8) ?: UTF8) - ProcessedBody( - isValid = true, - body = contentType.beautify(body), - mediaType = contentType.type(), - mediaSubtype = contentType.subtype() - ) - } else { - // todo process image response - ProcessedBody( - isValid = true, - body = BINARY_BODY, - mediaType = BINARY_MEDIA_TYPE, - mediaSubtype = BINARY_MEDIA_TYPE - ) - } - } - return ProcessedBody( - isValid = false, - mediaType = null, - mediaSubtype = null - ) - } - return null -} - -private fun RequestBody.convert(gzipped: Boolean): CharSequence { - return try { - val buffer = Buffer() - writeTo(buffer) - if (gzipped) { - doUnZipToString(buffer.readByteArray()) - } else { - buffer.readUtf8() - } - } catch (e: IOException) { - DebugLog.e(LOGTAG, "request body parsing failed", e) - "" - } -} - -internal fun Context?.beautifyHeaders(data: Map): CharSequence? { - return this?.createSpan { - data.forEach { - append("${it.key} : ") - if (it.value != null) { - append(fontColor(semiBold("${it.value}"), context.color(R.color.pluto___text_dark_80))) - } else { - append(fontColor(light(italic("null")), context.color(R.color.pluto___text_dark_40))) - } - append("\n") - } - }?.trim() -} - -internal fun Context?.beautifyQueryParams(url: HttpUrl): CharSequence? { - return this?.createSpan { - url.queryParameterNames().forEach { - append("$it : ") - val value = url.queryParameter(it) - if (value != null) { - append(fontColor(semiBold("$value"), context.color(R.color.pluto___text_dark_80))) - } else { - append(fontColor(light(italic("null")), context.color(R.color.pluto___text_dark_40))) - } - append("\n") - } - }?.trim() -} - -internal fun ProcessedBody.flatten(): String? { - body?.toString()?.let { body -> - return when { - mediaType == "binary" -> body - mediaSubtype == "json" -> JsonBaseTransformer().flatten(body) - mediaSubtype == "xml" || mediaSubtype == "html" -> XmlBaseTransformer().flatten(body) - mediaSubtype == "x-www-form-urlencoded" -> FormEncodedTransformer().flatten(body) - else -> body - } - } - return null -} - -internal fun String.pruneQueryParams(): String { - val separated: List = split("?") - return separated[0] -} - -internal fun HttpUrl.hostUrl(): String { - val hostString = StringBuilder() - hostString.append("${scheme()}://${host()}") - if (port() != HTTP_PORT && port() != HTTPS_PORT) { - hostString.append(":${port()}") - } - return hostString.toString() -} - -internal const val LOGTAG = "pluto_sdk" -internal const val BODY_INDENTATION = 4 -private const val BINARY_BODY = "~ Binary Data" -internal const val BINARY_MEDIA_TYPE = "binary" -internal val UTF8 = Charset.forName("UTF-8") -private const val HTTP_PORT = 80 -private const val HTTPS_PORT = 443 diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/CurlBuilder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/CurlBuilder.kt deleted file mode 100644 index eca8c99b0..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/CurlBuilder.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.mocklets.pluto.modules.network - -internal fun RequestData.getCurl(): String { - val curlCommandBuilder = StringBuilder("") - curlCommandBuilder.append("cURL") - curlCommandBuilder.append(" -X") - curlCommandBuilder.append(" ${method.uppercase()}") - for (headerName in headers) { - curlCommandBuilder.append(headerPair(headerName.key, headerName.value)) - } - - body?.let { - curlCommandBuilder.append(" -d '${it.flatten()}'") - } - curlCommandBuilder.append(" \"$url\"") - curlCommandBuilder.append(" -L") - return curlCommandBuilder.toString() // beautify(request.url.toString(), curlCommandBuilder.toString()) -} - -private fun headerPair(headerName: String, headerValue: String?): String { - return " -H \"$headerName: $headerValue\"" -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/DataModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/DataModel.kt deleted file mode 100644 index 597c08553..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/DataModel.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.mocklets.pluto.modules.network - -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.modules.exceptions.ExceptionData -import okhttp3.HttpUrl -import okhttp3.Protocol - -internal data class RequestData( - val url: HttpUrl, - val method: String, - val body: ProcessedBody?, - val headers: Map, -// val headerCount: Int, - val timestamp: Long, - val isGzipped: Boolean -) - -internal data class ProxyConfig( - val url: String -) - -internal data class ResponseData( - val status: Status, - val isSuccessful: Boolean, - val body: ProcessedBody?, - val protocol: Protocol, - val fromDiskCache: Boolean, - val headers: Map, - val sendTimeMillis: Long, - val receiveTimeMillis: Long, - val isGzipped: Boolean -) - -internal data class Status( - val code: Int, - val message: String -) - -internal class ApiCallData( - val id: String, - val request: RequestData, - var hasResponseBody: Boolean = false, - var response: ResponseData? = null, - var exception: ExceptionData? = null, - var proxy: ProxyConfig? = null -) : ListItem() { - val curl: String = request.getCurl() - override fun isEqual(other: Any): Boolean { - if (other is ApiCallData) { - id == other.id && response == other.response && exception == other.exception - } - return false - } -} - -internal data class ProcessedBody( - val isValid: Boolean, - val body: CharSequence? = null, - val mediaType: String?, - val mediaSubtype: String? -) { - val isBinary: Boolean = mediaType == BINARY_MEDIA_TYPE -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/MediaTypeKtx.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/MediaTypeKtx.kt deleted file mode 100644 index e25689bb8..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/MediaTypeKtx.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.mocklets.pluto.modules.network - -import com.mocklets.pluto.modules.network.transformers.FormEncodedTransformer -import com.mocklets.pluto.modules.network.transformers.JsonBaseTransformer -import com.mocklets.pluto.modules.network.transformers.XmlBaseTransformer -import okhttp3.MediaType - -internal fun MediaType.isText(): Boolean { - return (type() == "application" || type() == "text") && - (subtype().endsWith("json") || subtype() == "plain" || subtype() == "xml" || subtype() == "html" || subtype() == "x-www-form-urlencoded") -} - -internal fun MediaType.beautify(plain: CharSequence, indent: Int = BODY_INDENTATION): CharSequence? { - return when { - subtype().endsWith("json") -> JsonBaseTransformer().beautify(plain, indent) - subtype() == "xml" || subtype() == "html" -> XmlBaseTransformer().beautify(plain, indent) - subtype() == "x-www-form-urlencoded" -> FormEncodedTransformer().beautify(plain) - else -> plain - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/CacheDirectoryProvider.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/CacheDirectoryProvider.kt deleted file mode 100644 index fe15a6e22..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/CacheDirectoryProvider.kt +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Source: chucker - https://github.com/ChuckerTeam/chucker/blob/develop/library/src/main/java/com/chuckerteam/chucker/internal/support/CacheDirectoryProvider.kt - * License: https://github.com/ChuckerTeam/chucker/blob/develop/LICENSE.txt - */ -package com.mocklets.pluto.modules.network.interceptor - -import java.io.File - -/** - * An interface that returns a reference to a cache directory where temporary files can be - * saved. - */ -internal fun interface CacheDirectoryProvider { - fun provide(): File? -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/DataConvertor.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/DataConvertor.kt deleted file mode 100644 index cd8823ab9..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/DataConvertor.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.mocklets.pluto.modules.network.interceptor - -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.modules.network.ProcessedBody -import com.mocklets.pluto.modules.network.RequestData -import com.mocklets.pluto.modules.network.ResponseData -import com.mocklets.pluto.modules.network.Status -import com.mocklets.pluto.modules.network.convertPretty -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.InputStream -import java.io.OutputStream -import java.nio.charset.Charset -import java.util.zip.GZIPInputStream -import okhttp3.Request -import okhttp3.Response -import okio.IOException - -internal fun Request.convert(): RequestData { - return RequestData( - url = this.url(), - method = this.method(), - body = this.body()?.convertPretty(this.isGzipped), - headers = this.headerMap(), - timestamp = System.currentTimeMillis(), - isGzipped = this.isGzipped - ) -} - -internal fun Request.headerMap(): Map { - val headerNames = arrayListOf() - headerNames.addAll(headers().names()) - headerNames.add("content-type") -// headerNames.add("content-length") todo get content-length header - headerNames.sortBy { it } - - val map = mutableMapOf() - headerNames.forEach { - val key = it.lowercase().trim() - when (it) { - "content-type" -> body()?.contentType()?.toString()?.let { value -> - map[key] = value.trim() - } - else -> map[key] = headers().get(it)?.trim() - } - } - return map -} - -internal fun Response.convert(body: ProcessedBody?): ResponseData { - return ResponseData( - status = Status(code(), statusCodeMessage()), - isSuccessful = isSuccessful, - body = body, - protocol = protocol(), - fromDiskCache = false, - headers = headersMap(), - sendTimeMillis = sentRequestAtMillis(), - receiveTimeMillis = receivedResponseAtMillis(), - isGzipped = isGzipped - ) -} - -private fun Response.statusCodeMessage(): String { - return message() // TODO compile local message if server message is blank -} - -private fun Response.headersMap(): Map { - val headerNames = arrayListOf() - headerNames.addAll(headers().names()) - headerNames.sortBy { it } - - val map = mutableMapOf() - headerNames.forEach { - map[it.lowercase().trim()] = headers().get(it)?.trim() - } - - return map -} - -@Suppress("TooGenericExceptionCaught") -internal fun doUnZipToString(gzippedMessage: ByteArray?): String { - var unzippedMessage: String? = null - try { - val gzippped = doUnZip(gzippedMessage) - unzippedMessage = String(gzippped!!, Charset.defaultCharset()) - } catch (e: Throwable) { - DebugLog.e(LOGTAG, "doUnZipToString 1", e) - } - return unzippedMessage ?: "" -} - -private fun doUnZip(stream: InputStream?): ByteArray? { - if (stream !is ByteArrayInputStream) { - return try { - doUnZip(stream?.readBytes()) - } catch (e: IOException) { - DebugLog.e(LOGTAG, "doUnZip 1", e) - null -// throw SystemFailedException(e.getMessage(), e) - } - } - var bos: ByteArrayOutputStream? = null - var gzipStream: InputStream? = null - var bytes: ByteArray? = null - try { - bos = ByteArrayOutputStream() - gzipStream = GZIPInputStream(stream) - copy(gzipStream, bos) - bytes = bos.toByteArray() - } catch (e: IOException) { - DebugLog.e(LOGTAG, "error while unzip", e) - } finally { - try { - gzipStream?.close() - bos?.close() - } catch (e: IOException) { - DebugLog.e(LOGTAG, "error while closing stream", e) - } - } - return bytes -} - -private fun doUnZip(zippedMessage: ByteArray?): ByteArray? { - var stream: ByteArrayInputStream? = null - return try { - stream = ByteArrayInputStream(zippedMessage) - doUnZip(stream) - } finally { - try { - stream?.close() - } catch (e: IOException) { - DebugLog.e(LOGTAG, "error while closing zippedMessage stream", e) - } - } -} - -private const val BUFFER_SIZE = 1024 - -@Throws(IOException::class) -private fun copy(stream: InputStream, out: OutputStream) { - val buf = ByteArray(BUFFER_SIZE) - var len: Int - while (stream.read(buf, 0, buf.size).also { len = it } != -1) { - out.write(buf, 0, len) - } -} - -private const val LOGTAG = "data-convertor" diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/FileFactory.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/FileFactory.kt deleted file mode 100644 index a7ccedd47..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/FileFactory.kt +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Source: chucker - https://github.com/ChuckerTeam/chucker/blob/develop/library/src/main/java/com/chuckerteam/chucker/internal/support/FileFactory.kt - * License: https://github.com/ChuckerTeam/chucker/blob/develop/LICENSE.txt - */ -package com.mocklets.pluto.modules.network.interceptor - -import java.io.File -import java.io.IOException -import java.util.concurrent.atomic.AtomicLong - -internal object FileFactory { - private val uniqueIdGenerator = AtomicLong() - - fun create(directory: File) = create(directory, fileName = "pluto-${uniqueIdGenerator.getAndIncrement()}") - - private fun create(directory: File, fileName: String): File? = try { - File(directory, fileName).apply { - if (exists() && !delete()) { - throw IOException("Failed to delete file $this") - } - parentFile?.mkdirs() - if (!createNewFile()) { - throw IOException("File $this already exists") - } - } - } catch (e: IOException) { - IOException("An error occurred while creating a Pluto file", e).printStackTrace() - null - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/OkHttpKtx.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/OkHttpKtx.kt deleted file mode 100644 index 31eb74035..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/OkHttpKtx.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.mocklets.pluto.modules.network.interceptor - -import java.net.HttpURLConnection -import okhttp3.Headers -import okhttp3.Request -import okhttp3.Response - -private const val HTTP_CONTINUE = 100 - -/** Returns true if the response must have a (possibly 0-length) body. See RFC 7231. */ -internal fun Response.hasBody(): Boolean { - // HEAD requests never yield a body regardless of the response headers. - if (request().method() == "HEAD") { - return false - } - - val responseCode = code() - val isSuccessResponse = responseCode < HTTP_CONTINUE || responseCode >= HttpURLConnection.HTTP_OK - if (isSuccessResponse && - responseCode != HttpURLConnection.HTTP_NO_CONTENT && - responseCode != HttpURLConnection.HTTP_NOT_MODIFIED - ) { - return true - } - - // If the Content-Length or Transfer-Encoding headers disagree with the response code, the - // response is malformed. For best compatibility, we honor the headers. - return contentLength > 0 || isChunked -} - -internal val Response.contentLength: Long - get() { - return this.header("Content-Length")?.toLongOrNull() ?: -1L - } - -internal val Response.isChunked: Boolean - get() { - return this.header("Transfer-Encoding").equals("chunked", ignoreCase = true) - } - -internal val Response.contentType: String? - get() { - return this.header("Content-Type") - } - -/** Checks if the OkHttp response uses gzip encoding. */ -internal val Response.isGzipped: Boolean - get() { - return this.headers().containsGzip - } - -/** Checks if the OkHttp request uses gzip encoding. */ -internal val Request.isGzipped: Boolean - get() { - return this.headers().containsGzip - } - -private val Headers.containsGzip: Boolean - get() { - return this["Content-Encoding"].equals("gzip", ignoreCase = true) - } diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/ResponseBodyProcessor.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/ResponseBodyProcessor.kt deleted file mode 100644 index d34e68cb9..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/interceptor/ResponseBodyProcessor.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.mocklets.pluto.modules.network.interceptor - -import android.content.Context -import com.mocklets.pluto.modules.network.ApiCallData -import com.mocklets.pluto.modules.network.NetworkCallsRepo -import com.mocklets.pluto.modules.network.convertPretty -import java.io.File -import java.io.IOException -import okhttp3.MediaType -import okhttp3.Response -import okhttp3.ResponseBody -import okio.Buffer -import okio.BufferedSource -import okio.GzipSource -import okio.Source -import okio.buffer -import okio.source - -internal class ResponseBodyProcessor(context: Context) { - private val maxContentLength = MAX_CONTENT_LENGTH - private val cacheDirectoryProvider = CacheDirectoryProvider { context.filesDir } - - fun processBody(response: Response, apiCallData: ApiCallData): Response { - apiCallData.response = response.convert(null) - NetworkCallsRepo.set(apiCallData) - val responseBody = response.body() - if (!response.hasBody() || responseBody == null) { - return response - } - - val contentType = responseBody.contentType() - val contentLength = responseBody.contentLength() - - apiCallData.hasResponseBody = true - NetworkCallsRepo.set(apiCallData) - - val sideStream = ReportingSink( - createTempTransactionFile(), - ApiCallReportingSinkCallback(response, apiCallData), - maxContentLength - ) - var upstream: Source = TeeSource(responseBody.source(), sideStream) - upstream = DepletingSource(upstream) - - return response.newBuilder() - .body(upstream.buffer().asResponseBody(contentType, contentLength)) - .build() - } - - private fun createTempTransactionFile(): File? { - val cache = cacheDirectoryProvider.provide() - return if (cache == null) { - IOException("Failed to obtain a valid cache directory for Pluto transaction file").printStackTrace() - null - } else { - FileFactory.create(cache) - } - } - - private inner class ApiCallReportingSinkCallback( - private val response: Response, - private val apiCallData: ApiCallData - ) : ReportingSink.Callback { - - override fun onSuccess(file: File?, sourceByteCount: Long) { - file?.let { f -> - readResponseBuffer(f, response.isGzipped)?.let { - val responseBody = response.body() ?: return - val body = responseBody.convertPretty(it) - apiCallData.response = response.convert(body) - NetworkCallsRepo.set(apiCallData) - } - f.delete() - } - } - - override fun onFailure(file: File?, exception: IOException) = exception.printStackTrace() - - private fun readResponseBuffer(responseBody: File, isGzipped: Boolean) = try { - val bufferedSource = responseBody.source().buffer() - val source = if (isGzipped) { - GzipSource(bufferedSource) - } else { - bufferedSource - } - Buffer().apply { source.use { writeAll(it) } } - } catch (e: IOException) { - IOException("Response payload couldn't be processed by Pluto", e).printStackTrace() - null - } - } - - companion object { - private const val MAX_CONTENT_LENGTH = 300_000L -// private const val MAX_BLOB_SIZE = 1_000_000L - } -} - -/** Returns a new response body that transmits this source. */ -fun BufferedSource.asResponseBody(contentType: MediaType? = null, contentLength: Long = -1L) = object : ResponseBody() { - override fun contentType() = contentType - - override fun contentLength() = contentLength - - override fun source() = this@asResponseBody -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/NetworkProxyRepo.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/NetworkProxyRepo.kt deleted file mode 100644 index 6b118de1d..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/NetworkProxyRepo.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.mocklets.pluto.modules.network.proxy - -import android.content.Context -import com.mocklets.pluto.core.database.DatabaseManager -import com.mocklets.pluto.modules.network.proxy.dao.NetworkProxyDao -import com.mocklets.pluto.modules.network.proxy.dao.NetworkProxyEntity -import com.mocklets.pluto.modules.network.proxy.dao.ProxyData -import com.mocklets.pluto.modules.network.pruneQueryParams -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import okhttp3.HttpUrl - -internal object NetworkProxyRepo { - private var networkProxyDao: NetworkProxyDao? = null - private var proxyMap = linkedSetOf() - - fun init(context: Context) { - networkProxyDao = DatabaseManager(context).db.networkProxyDao() - GlobalScope.launch { - populateMap() - } - } - - private suspend fun populateMap() { - networkProxyDao?.fetchList()?.let { - proxyMap.clear() - proxyMap.addAll(it) - } - } - - fun get(url: HttpUrl, method: String): String? { - val proxyData = - proxyMap.singleOrNull { p -> - p.requestUrl == url.toString().pruneQueryParams() && p.requestMethod == method - }?.proxyData - return proxyData?.url // todo url before returning, like wildcards, response_status & delay - } - - fun fetch(url: String, method: String): NetworkProxyEntity? { - return proxyMap.singleOrNull { p -> p.requestUrl == url && p.requestMethod == method } - } - - fun fetchList(search: String = ""): List { - return if (search.trim().isEmpty()) { - proxyMap.toList() - } else { - proxyMap.filter { p -> p.requestUrl.contains(search.trim()) } - } - } - - suspend fun update(requestUrl: String, requestMethod: String, proxyData: ProxyData) { - val data = NetworkProxyEntity( - requestUrl = requestUrl, - requestMethod = requestMethod, - proxyData = proxyData, - timestamp = System.currentTimeMillis() - ) - networkProxyDao?.let { - it.save(data) - populateMap() - } - } - - suspend fun delete(url: String) { - networkProxyDao?.let { - it.delete(url) - populateMap() - } - } - - suspend fun deleteAll() { - networkProxyDao?.let { - it.deleteAll() - populateMap() - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/NetworkProxyViewModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/NetworkProxyViewModel.kt deleted file mode 100644 index fc9d2a171..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/NetworkProxyViewModel.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.mocklets.pluto.modules.network.proxy - -import android.app.Application -import android.webkit.URLUtil -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import com.mocklets.pluto.core.SingleLiveEvent -import com.mocklets.pluto.modules.network.proxy.dao.NetworkProxyEntity -import com.mocklets.pluto.modules.network.proxy.dao.ProxyData -import com.mocklets.pluto.modules.network.pruneQueryParams -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -internal class NetworkProxyViewModel(application: Application) : AndroidViewModel(application) { - - val proxyList: LiveData> - get() = _proxyList - private val _proxyList = MutableLiveData>() - - val currentProxy: SingleLiveEvent - get() = _currentProxy - private val _currentProxy = SingleLiveEvent() - - val event: SingleLiveEvent> - get() = _event - private val _event = SingleLiveEvent>() - - val mockletsUrl: SingleLiveEvent - get() = _mockletsUrl - private val _mockletsUrl = SingleLiveEvent() - - fun fetchList(search: String = "") { - viewModelScope.launch(Dispatchers.IO) { - val list = NetworkProxyRepo.fetchList(search) - _proxyList.postValue(list) - } - } - - fun update(requestUrl: String, requestMethod: String, proxyData: ProxyData) { - if (!URLUtil.isHttpsUrl(proxyData.url)) { - _event.postValue(Pair(false, "Need https:// URL")) - return - } - if (proxyData.url.length < URL_MIN_LENGTH) { // length of https:// - _event.postValue(Pair(false, "Malformed URL")) - return - } - viewModelScope.launch(Dispatchers.IO) { - NetworkProxyRepo.update(requestUrl.pruneQueryParams(), requestMethod, proxyData) - _event.postValue(Pair(true, "Proxy Setting updated!")) - } - } - - fun fetch(url: String, method: String) { - _currentProxy.postValue(NetworkProxyRepo.fetch(url.pruneQueryParams(), method)) - } - - fun delete(url: String) { - viewModelScope.launch(Dispatchers.IO) { - NetworkProxyRepo.delete(url) - _event.postValue(Pair(true, "Proxy Setting deleted!")) - } - } - - fun onInAppBrowserClose() { - _mockletsUrl.postValue("mock") - } - - private companion object { - const val URL_MIN_LENGTH = 9 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/dao/NetworkProxyConverters.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/dao/NetworkProxyConverters.kt deleted file mode 100644 index 13c14c8ed..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/dao/NetworkProxyConverters.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.mocklets.pluto.modules.network.proxy.dao - -import androidx.room.TypeConverter -import com.google.gson.Gson - -internal class NetworkProxyConverters { - - private val gson = Gson() - - @TypeConverter - fun stringToProxyData(data: String?): ProxyData { - return gson.fromJson(data, ProxyData::class.java) - } - - @TypeConverter - fun proxyDataToString(data: ProxyData): String? { - return gson.toJson(data) - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/dao/NetworkProxyDao.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/dao/NetworkProxyDao.kt deleted file mode 100644 index 5f0815d6b..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/dao/NetworkProxyDao.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.mocklets.pluto.modules.network.proxy.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query - -@Dao -internal interface NetworkProxyDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun save(entity: NetworkProxyEntity) - - @Query("SELECT * FROM network_proxy where request_url is :requestUrl") - suspend fun fetch(requestUrl: String): NetworkProxyEntity? - - @Query("SELECT * FROM network_proxy where request_url like '%' || :search || '%' order by timestamp DESC") - suspend fun fetchList(search: String = ""): List? - - @Query("DELETE FROM network_proxy where request_url is :requestUrl") - suspend fun delete(requestUrl: String) - - @Query("DELETE FROM network_proxy") - suspend fun deleteAll() -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/dao/NetworkProxyEntity.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/dao/NetworkProxyEntity.kt deleted file mode 100644 index 3322b15c5..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/dao/NetworkProxyEntity.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.mocklets.pluto.modules.network.proxy.dao - -import androidx.annotation.Keep -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey -import androidx.room.TypeConverters -import com.mocklets.pluto.core.ui.list.ListItem - -@Keep -@TypeConverters(NetworkProxyConverters::class) -@Entity(tableName = "network_proxy", indices = [Index(value = ["request_url"], unique = true)]) -internal data class NetworkProxyEntity( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "id") - val id: Int? = null, - @ColumnInfo(name = "timestamp") - val timestamp: Long, - @ColumnInfo(name = "request_url") - val requestUrl: String, - @ColumnInfo(name = "request_method") - val requestMethod: String, - @ColumnInfo(name = "proxy_data") - val proxyData: ProxyData -) : ListItem() { - override fun isSame(other: Any): Boolean { - return other is NetworkProxyEntity && other.requestUrl == requestUrl - } - - override fun equals(other: Any?): Boolean { - return other is NetworkProxyEntity && other.requestUrl == requestUrl - } -} - -@Keep -internal data class ProxyData( - val url: String, - val statusCode: Int? = null, - val delay: Int? = null -) diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxyItemAdapter.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxyItemAdapter.kt deleted file mode 100644 index 68fabc105..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxyItemAdapter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.mocklets.pluto.modules.network.proxy.ui - -import android.view.ViewGroup -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.modules.network.proxy.dao.NetworkProxyEntity - -internal class NetworkProxyItemAdapter(private val listener: OnActionListener) : BaseAdapter() { - override fun getItemViewType(item: ListItem): Int? { - return when (item) { - is NetworkProxyEntity -> ITEM_TYPE_NETWORK_PROXY - else -> null - } - } - - override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { - return when (viewType) { - ITEM_TYPE_NETWORK_PROXY -> NetworkProxyItemHolder(parent, listener) - else -> null - } - } - - companion object { - const val ITEM_TYPE_NETWORK_PROXY = 1000 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxyItemHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxyItemHolder.kt deleted file mode 100644 index 24d09a1ae..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxyItemHolder.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.mocklets.pluto.modules.network.proxy.ui - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoItemNetworkProxySettingsBinding -import com.mocklets.pluto.modules.network.proxy.dao.NetworkProxyEntity - -internal class NetworkProxyItemHolder( - parent: ViewGroup, - listener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_network_proxy_settings), listener) { - - private val binding = PlutoItemNetworkProxySettingsBinding.bind(itemView) - private val value = binding.value - - override fun onBind(item: ListItem) { - if (item is NetworkProxyEntity) { - value.text = item.requestUrl - binding.root.setDebounceClickListener { - onAction("click") - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxySettingsFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxySettingsFragment.kt deleted file mode 100644 index d415f7bcd..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxySettingsFragment.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.mocklets.pluto.modules.network.proxy.ui - -import android.content.ClipboardManager -import android.content.Context -import android.net.Uri -import android.os.Bundle -import android.os.Parcelable -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.customTab -import com.mocklets.pluto.core.extensions.delayedLaunchWhenResumed -import com.mocklets.pluto.core.extensions.fadeInAndOut -import com.mocklets.pluto.core.extensions.hideKeyboard -import com.mocklets.pluto.core.extensions.lazyParcelExtra -import com.mocklets.pluto.core.extensions.toast -import com.mocklets.pluto.core.ui.routing.Screens -import com.mocklets.pluto.core.ui.routing.lazyRouter -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoFragmentNetworkProxySettingsBinding -import com.mocklets.pluto.modules.network.proxy.NetworkProxyViewModel -import com.mocklets.pluto.modules.network.proxy.dao.NetworkProxyEntity -import com.mocklets.pluto.modules.network.proxy.dao.ProxyData -import com.mocklets.pluto.modules.network.pruneQueryParams -import kotlinx.parcelize.Parcelize - -internal class NetworkProxySettingsFragment : Fragment(R.layout.pluto___fragment_network_proxy_settings) { - - private val binding by viewBinding(PlutoFragmentNetworkProxySettingsBinding::bind) - private val arguments by lazyParcelExtra() - private val viewModel: NetworkProxyViewModel by activityViewModels() - private val router by lazyRouter() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.currentProxy.removeObservers(viewLifecycleOwner) - viewModel.currentProxy.observe(viewLifecycleOwner, apiCallObserver) - arguments?.let { viewModel.fetch(it.url, it.method) } - arguments?.showListCta?.let { - if (!it) { - binding.seeAll.visibility = GONE - binding.divider.visibility = GONE - } - } - binding.requestLabel.setSpan { - append(getString(R.string.pluto___network_proxy_settings_request_label)) - arguments?.let { - append(bold(fontColor(" (", context.color(R.color.pluto___text_dark_60)))) - append(bold(fontColor(it.method, context.color(R.color.pluto___text_dark)))) - append(bold(fontColor(")", context.color(R.color.pluto___text_dark_60)))) - } - } - - binding.seeAll.setDebounceClickListener { - requireActivity().hideKeyboard() - activity?.onBackPressed() - router.navigate(Screens.NetworkProxySettingsList) - } - binding.close.setDebounceClickListener { - requireActivity().hideKeyboard() - activity?.onBackPressed() - } - binding.save.setDebounceClickListener { - arguments?.let { - viewModel.update( - binding.endPoint.text.toString(), - it.method, - ProxyData(url = binding.proxyUrl.text.toString()) - ) - } - requireActivity().hideKeyboard() - } - binding.delete.setDebounceClickListener { - requireActivity().hideKeyboard() - viewModel.delete(binding.endPoint.text.toString()) - } - binding.accessMocklets.setDebounceClickListener { - val uri = Uri.parse(MOCKLETS_URL) - .buildUpon() - .appendQueryParameter(METHOD_PARAM, arguments?.method?.lowercase() ?: "any") - .build() - requireActivity().customTab(uri) - } - - viewModel.event.removeObservers(viewLifecycleOwner) - viewModel.event.observe(viewLifecycleOwner, eventsObserver) - - viewModel.mockletsUrl.removeObservers(viewLifecycleOwner) - viewModel.mockletsUrl.observe(viewLifecycleOwner, mockletsUrlObserver) - } - - private val eventsObserver = Observer> { - context?.toast(it.second) - if (it.first) { - activity?.onBackPressed() - } - } - - private val mockletsUrlObserver = Observer { - binding.proxyUrl.requestFocus() - lifecycleScope.delayedLaunchWhenResumed(CLIPBOARD_PROCESS_DELAY) { - context?.clipboardData()?.let { - if (isSelectionValid(it)) { - binding.proxyUrl.setText(it) - binding.mockletsSuccess.fadeInAndOut(lifecycleScope) - } - } - binding.proxyUrl.clearFocus() - } - } - - private fun isSelectionValid(it: String): Boolean = - it.startsWith(MOCKLETS_API_PREFIX) && binding.proxyUrl.text.toString() != it - - private val apiCallObserver = Observer { - binding.endPoint.setText(arguments?.url?.pruneQueryParams()) - binding.delete.visibility = GONE - binding.divider.visibility = GONE - it?.let { - setupUpdateUI(it) - } - binding.proxyUrl.setSelection(binding.proxyUrl.text.toString().length) - } - - private fun setupUpdateUI(entity: NetworkProxyEntity) { - binding.endPoint.isEnabled = false - binding.delete.visibility = VISIBLE - binding.note.visibility = VISIBLE - arguments?.showListCta?.let { - if (it) { - binding.divider.visibility = VISIBLE - } - } - context?.let { binding.endPoint.setTextColor(it.color(R.color.pluto___text_dark_40)) } - binding.endPoint.setText(entity.requestUrl) - binding.proxyUrl.setText(entity.proxyData.url) - } - - private fun Context.clipboardData(): String? { - val clipBoardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - return clipBoardManager.primaryClip?.getItemAt(0)?.text?.toString() - } - - @Parcelize - data class Data(val url: String, val method: String, val showListCta: Boolean = false) : Parcelable - - companion object { - const val IN_APP_BROWSER_RESULT_CODE = 10_001 - const val MOCKLETS_URL = "https://connect.mocklets.com?ref=pluto" - const val MOCKLETS_API_PREFIX = "https://api.mocklets.com/" - const val METHOD_PARAM = "method" - const val CLIPBOARD_PROCESS_DELAY = 50L - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxySettingsListFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxySettingsListFragment.kt deleted file mode 100644 index cb7e5b1b2..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/proxy/ui/NetworkProxySettingsListFragment.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.mocklets.pluto.modules.network.proxy.ui - -import android.os.Bundle -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.dp -import com.mocklets.pluto.core.extensions.hideKeyboard -import com.mocklets.pluto.core.extensions.showKeyboard -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.CustomItemDecorator -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.routing.OnBackKeyHandler -import com.mocklets.pluto.core.ui.routing.Screens -import com.mocklets.pluto.core.ui.routing.lazyRouter -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoFragmentNetworkProxySettingsListBinding -import com.mocklets.pluto.modules.network.proxy.NetworkProxyViewModel -import com.mocklets.pluto.modules.network.proxy.dao.NetworkProxyEntity - -internal class NetworkProxySettingsListFragment : - Fragment(R.layout.pluto___fragment_network_proxy_settings_list), - OnBackKeyHandler { - - private val binding by viewBinding(PlutoFragmentNetworkProxySettingsListBinding::bind) - private val networkProxyAdapter: BaseAdapter by lazy { NetworkProxyItemAdapter(onActionListener) } - private val viewModel: NetworkProxyViewModel by activityViewModels() - private val router by lazyRouter() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.list.apply { - adapter = networkProxyAdapter - addItemDecoration(CustomItemDecorator(context, DECORATOR_DIVIDER_PADDING)) - } - binding.close.setDebounceClickListener { - activity?.onBackPressed() - } - binding.search.setDebounceClickListener { - binding.searchView.visibility = VISIBLE - binding.searchView.requestFocus() - } - binding.closeSearch.setDebounceClickListener { - exitSearch() - } - binding.clearSearch.setDebounceClickListener { - binding.editSearch.text = null - } - binding.editSearch.setOnFocusChangeListener { v, hasFocus -> - if (hasFocus) { - v.showKeyboard() - } else { - v.hideKeyboard() - } - } - binding.editSearch.addTextChangedListener { editable -> - lifecycleScope.launchWhenResumed { - editable?.toString()?.let { - viewModel.fetchList(it) - } - } - } - binding.create.setDebounceClickListener { - router.navigate(Screens.NetworkProxySettings()) - } - - viewModel.proxyList.removeObserver(networkProxyObserver) - viewModel.proxyList.observe(viewLifecycleOwner, networkProxyObserver) - viewModel.fetchList() - } - - private val networkProxyObserver = Observer> { - networkProxyAdapter.list = it - binding.noNetworkProxy.visibility = if (it.isEmpty()) VISIBLE else GONE - } - - override fun onBackPressed(): Boolean { - if (binding.searchView.isVisible) { - exitSearch() - return true - } - return false - } - - private fun exitSearch() { - binding.editSearch.text = null - binding.searchView.visibility = GONE - binding.editSearch.clearFocus() - } - - private val onActionListener = object : DiffAwareAdapter.OnActionListener { - override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder?) { - when (data) { - is NetworkProxyEntity -> router.navigate( - Screens.NetworkProxySettings( - NetworkProxySettingsFragment.Data( - data.requestUrl, - data.requestMethod - ) - ) - ) - } - } - } - - private companion object { - val DECORATOR_DIVIDER_PADDING = 16f.dp.toInt() - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/BaseTransformer.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/BaseTransformer.kt deleted file mode 100644 index 861d363a9..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/BaseTransformer.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.mocklets.pluto.modules.network.transformers - -import com.mocklets.pluto.modules.network.BODY_INDENTATION - -internal interface BaseTransformer { - fun beautify(plain: CharSequence, indent: Int = BODY_INDENTATION): CharSequence? - fun flatten(plain: CharSequence): String -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/JsonBaseTransformer.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/JsonBaseTransformer.kt deleted file mode 100644 index f4098aea4..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/JsonBaseTransformer.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.mocklets.pluto.modules.network.transformers - -import com.google.gson.JsonElement -import com.google.gson.JsonParser -import com.google.gson.JsonSyntaxException -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.modules.network.LOGTAG -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject - -internal class JsonBaseTransformer : BaseTransformer { - override fun beautify(plain: CharSequence, indent: Int): CharSequence? { - return try { - val je: JsonElement = JsonParser.parseString(plain.toString()) - when { - je.isJsonArray -> JSONArray(plain.toString()).toString(indent) - je.isJsonObject -> JSONObject(plain.toString()).toString(indent) - else -> plain - } - } catch (e: JsonSyntaxException) { - DebugLog.e(LOGTAG, "json parsing failed", e) - plain - } catch (e: JSONException) { - DebugLog.e(LOGTAG, "json parsing failed", e) - plain - } - } - - override fun flatten(plain: CharSequence): String { - return plain.toString().replace("\n", "").replace("\\s+".toRegex(), "") - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/XmlBaseTransformer.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/XmlBaseTransformer.kt deleted file mode 100644 index 1ae681f38..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/transformers/XmlBaseTransformer.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.mocklets.pluto.modules.network.transformers - -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.modules.network.LOGTAG -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import javax.xml.transform.OutputKeys -import javax.xml.transform.Transformer -import javax.xml.transform.sax.SAXSource -import javax.xml.transform.sax.SAXTransformerFactory -import javax.xml.transform.stream.StreamResult -import org.xml.sax.InputSource - -internal class XmlBaseTransformer : BaseTransformer { - - @Suppress("TooGenericExceptionCaught") - override fun beautify(plain: CharSequence, indent: Int): CharSequence? { - return try { - val serializer: Transformer = SAXTransformerFactory.newInstance().newTransformer() - serializer.setOutputProperty(OutputKeys.INDENT, "yes") - serializer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "$indent") - val xmlSource = SAXSource(InputSource(ByteArrayInputStream(plain.toString().toByteArray()))) - val res = StreamResult(ByteArrayOutputStream()) - serializer.transform(xmlSource, res) - String((res.outputStream as ByteArrayOutputStream).toByteArray()) - } catch (e: Exception) { - DebugLog.e(LOGTAG, "xml parsing failed", e) - plain - } - } - - override fun flatten(plain: CharSequence): String { - return plain.toString().replace("\n", "").replace("\\s+".toRegex(), "") - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/ApiItemHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/ApiItemHolder.kt deleted file mode 100644 index e269ad352..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/ApiItemHolder.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.mocklets.pluto.modules.network.ui - -import android.view.View.GONE -import android.view.View.INVISIBLE -import android.view.View.VISIBLE -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.asTimeElapsed -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoItemNetworkBinding -import com.mocklets.pluto.modules.network.ApiCallData -import com.mocklets.pluto.modules.network.ResponseData -import com.mocklets.pluto.modules.network.hostUrl - -internal class ApiItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter.OnActionListener) : - DiffAwareHolder(parent.inflate(R.layout.pluto___item_network), actionListener) { - - private val binding = PlutoItemNetworkBinding.bind(itemView) - private val host = binding.host - private val url = binding.url - private val progress = binding.progress - private val status = binding.status - private val error = binding.error - private val timeElapsed = binding.timeElapsed - private val proxyIndicator = binding.proxyIndicator - - override fun onBind(item: ListItem) { - if (item is ApiCallData) { - host.text = item.request.url.hostUrl() - timeElapsed.text = item.request.timestamp.asTimeElapsed() - itemView.setBackgroundColor(itemView.context.color(R.color.pluto___transparent)) - - val endPoint = StringBuilder() - item.request.url.pathSegments().forEach { segment -> - endPoint.append("/$segment") - } - url.setSpan { - append(fontColor(item.request.method.uppercase(), context.color(R.color.pluto___text_dark_60))) - append(" $endPoint") - } - progress.visibility = VISIBLE - status.visibility = INVISIBLE - error.visibility = INVISIBLE - proxyIndicator.visibility = GONE - - item.exception?.let { - handleExceptionUI() - } - - item.proxy?.let { - proxyIndicator.visibility = VISIBLE - } - - item.response?.let { - handleResponseUI(it) - } - itemView.setDebounceClickListener(DEBOUNCE_DELAY) { - onAction("click") - } - } - } - - private fun handleResponseUI(it: ResponseData) { - error.visibility = INVISIBLE - progress.visibility = INVISIBLE - status.visibility = VISIBLE - status.text = it.status.code.toString() - status.setTextColor( - itemView.context.color( - if (it.isSuccessful) { - R.color.pluto___dull_green - } else { - if (it.status.code in RESPONSE_ERROR_STATUS_RANGE) { - R.color.pluto___orange - } else { - R.color.pluto___red - } - } - ) - ) - itemView.setBackgroundColor( - itemView.context.color( - if (it.isSuccessful) { - R.color.pluto___dull_green_05 - } else { - if (it.status.code in RESPONSE_ERROR_STATUS_RANGE) { - R.color.pluto___orange_05 - } else { - R.color.pluto___red_05 - } - } - ) - ) - } - - private fun handleExceptionUI() { - error.visibility = VISIBLE - progress.visibility = INVISIBLE - status.visibility = INVISIBLE - itemView.setBackgroundColor(itemView.context.color(R.color.pluto___red_05)) - } - - private companion object { - const val DEBOUNCE_DELAY = 1_000L - val RESPONSE_ERROR_STATUS_RANGE = 400..499 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/NetworkAdapter.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/NetworkAdapter.kt deleted file mode 100644 index 8abcdad8f..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/NetworkAdapter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.mocklets.pluto.modules.network.ui - -import android.view.ViewGroup -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.modules.network.ApiCallData - -internal class NetworkAdapter(private val listener: OnActionListener) : BaseAdapter() { - override fun getItemViewType(item: ListItem): Int? { - return when (item) { - is ApiCallData -> ITEM_TYPE_API - else -> null - } - } - - override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { - return when (viewType) { - ITEM_TYPE_API -> ApiItemHolder(parent, listener) - else -> null - } - } - - companion object { - const val ITEM_TYPE_API = 1000 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/NetworkFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/NetworkFragment.kt deleted file mode 100644 index fb7177674..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/NetworkFragment.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.mocklets.pluto.modules.network.ui - -import android.os.Bundle -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.hideKeyboard -import com.mocklets.pluto.core.extensions.linearLayoutManager -import com.mocklets.pluto.core.extensions.showMoreOptions -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.CustomItemDecorator -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.routing.Screens -import com.mocklets.pluto.core.ui.routing.lazyRouter -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoFragmentNetworkBinding -import com.mocklets.pluto.modules.network.ApiCallData -import com.mocklets.pluto.modules.network.NetworkCallsRepo -import com.mocklets.pluto.modules.network.ui.details.NetworkCallDetailsFragment - -internal class NetworkFragment : Fragment(R.layout.pluto___fragment_network) { - - private val binding by viewBinding(PlutoFragmentNetworkBinding::bind) - private val logsAdapter: BaseAdapter by lazy { NetworkAdapter(onActionListener) } - private val viewModel: NetworkViewModel by activityViewModels() - private val router by lazyRouter() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.apiList.apply { - adapter = logsAdapter - addItemDecoration(CustomItemDecorator(requireContext())) - } - binding.search.addTextChangedListener { editable -> - lifecycleScope.launchWhenResumed { - editable?.toString()?.let { - Pluto.session.networkSearchText = it - logsAdapter.list = filteredApi(it) - if (it.isEmpty()) { - binding.apiList.linearLayoutManager()?.scrollToPositionWithOffset(0, 0) - } - } - } - } - binding.more.setDebounceClickListener { - requireContext().showMoreOptions(it, R.menu.pluto___popup_menu_network) { item -> - when (item.itemId) { - R.id.proxy_settings -> router.navigate(Screens.NetworkProxySettingsList) - R.id.clear -> NetworkCallsRepo.deleteAll() - } - } - } - binding.search.setText(Pluto.session.networkSearchText) - viewModel.apiCalls.removeObserver(networkObserver) - viewModel.apiCalls.observe(viewLifecycleOwner, networkObserver) - } - - private fun filteredApi(search: String): List { - val list = (viewModel.apiCalls.value ?: emptyList()).filter { api -> - api.request.url.toString().contains(search, true) - } - binding.noItemText.text = getString( - if (search.isNotEmpty()) R.string.pluto___no_search_result else R.string.pluto___no_api_text - ) - binding.noItemText.visibility = if (list.isEmpty()) VISIBLE else GONE - return list - } - - private val networkObserver = Observer> { - logsAdapter.list = filteredApi(binding.search.text.toString()) - } - - private val onActionListener = object : DiffAwareAdapter.OnActionListener { - override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder?) { - if (data is ApiCallData) { - activity!!.hideKeyboard() - router.navigate(Screens.NetworkCallDetails(NetworkCallDetailsFragment.Data(data.id))) - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/NetworkViewModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/NetworkViewModel.kt deleted file mode 100644 index 5ce3075cb..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/NetworkViewModel.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.mocklets.pluto.modules.network.ui - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import com.mocklets.pluto.modules.network.ApiCallData -import com.mocklets.pluto.modules.network.NetworkCallsRepo - -internal class NetworkViewModel : ViewModel() { - - val apiCalls: LiveData> - get() = NetworkCallsRepo.apiCalls - - private val _currentApiCall = MutableLiveData() - private val _contentSearch = MutableLiveData() - - val detailContentLiveData: LiveData - get() = _detailContentLiveData - private val _detailContentLiveData = MediatorLiveData() - - init { - _detailContentLiveData.addSource(_currentApiCall) { - combineData(_currentApiCall, _contentSearch) - } - _detailContentLiveData.addSource(_contentSearch) { - combineData(_currentApiCall, _contentSearch) - } - } - - private fun combineData(apiCallLD: MutableLiveData, searchLD: MutableLiveData) { - apiCallLD.value?.let { - _detailContentLiveData.postValue(DetailContentData(it, searchLD.value)) - } - } - - fun fetchCurrent(id: String) { - _currentApiCall.postValue(NetworkCallsRepo.get(id)) - } - - fun searchContent(it: String) { - _contentSearch.postValue(it.trim()) - } -} - -internal data class DetailContentData( - val api: ApiCallData, - val search: String? -) diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkCallDetailsFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkCallDetailsFragment.kt deleted file mode 100644 index 062319668..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkCallDetailsFragment.kt +++ /dev/null @@ -1,186 +0,0 @@ -package com.mocklets.pluto.modules.network.ui.details - -import android.os.Bundle -import android.os.Parcelable -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator -import com.google.gson.Gson -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.asFormattedDate -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.hideKeyboard -import com.mocklets.pluto.core.extensions.lazyParcelExtra -import com.mocklets.pluto.core.extensions.showKeyboard -import com.mocklets.pluto.core.sharing.Shareable -import com.mocklets.pluto.core.sharing.lazyContentSharer -import com.mocklets.pluto.core.ui.routing.OnBackKeyHandler -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoLayoutNetworkCallDetailsBinding -import com.mocklets.pluto.modules.network.ApiCallData -import com.mocklets.pluto.modules.network.ui.DetailContentData -import com.mocklets.pluto.modules.network.ui.NetworkViewModel -import java.util.Locale -import kotlinx.parcelize.Parcelize - -internal class NetworkCallDetailsFragment : Fragment(R.layout.pluto___layout_network_call_details), OnBackKeyHandler { - - private val binding by viewBinding(PlutoLayoutNetworkCallDetailsBinding::bind) - private val arguments by lazyParcelExtra() - private val viewModel: NetworkViewModel by activityViewModels() - private val contentSharer by lazyContentSharer() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - if (arguments == null) { - activity?.onBackPressed() - } - viewModel.apiCalls.observe(viewLifecycleOwner, listUpdateObserver) - viewModel.detailContentLiveData.observe(viewLifecycleOwner, apiCallObserver) - setupPager() - binding.close.setDebounceClickListener { - activity?.onBackPressed() - } - binding.share.setDebounceClickListener { - viewModel.detailContentLiveData.value?.let { - contentSharer.share( - Shareable( - title = "Share Network Call details", - content = it.api.toShareText(), - fileName = "Network Call details from Pluto" - ) - ) - } - } - binding.search.setDebounceClickListener { - binding.searchView.visibility = VISIBLE - binding.searchView.requestFocus() - } - binding.closeSearch.setDebounceClickListener { - exitSearch() - } - binding.clearSearch.setDebounceClickListener { - binding.editSearch.text = null - } - binding.editSearch.setOnFocusChangeListener { v, hasFocus -> - if (hasFocus) { - v.showKeyboard() - } else { - v.hideKeyboard() - } - } - binding.editSearch.addTextChangedListener { editable -> - lifecycleScope.launchWhenResumed { - editable?.toString()?.let { - viewModel.searchContent(it) - } - } - } - } - - private fun exitSearch() { - binding.editSearch.text = null - binding.searchView.visibility = GONE - binding.editSearch.clearFocus() - } - - private fun setupPager() { - val pagerAdapter = NetworkDetailsPagerAdapter(this) - binding.viewPager.adapter = pagerAdapter - binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - - TabLayoutMediator(binding.tabs, binding.viewPager) { tab, position -> - tab.text = context?.getString(TAB_TITLES[position]) - }.attach() - binding.tabs.tabMode = TabLayout.MODE_SCROLLABLE - binding.tabs.isInlineLabel = false - } - - private val listUpdateObserver = Observer> { - arguments?.id?.let { id -> - viewModel.fetchCurrent(id) - } - } - - private val apiCallObserver = Observer { - val endPoint = StringBuilder() - it.api.request.url.pathSegments().forEach { segment -> - endPoint.append("/$segment") - } - binding.title.setSpan { - append( - fontColor( - it.api.request.method.uppercase(Locale.getDefault()), - context.color(R.color.pluto___text_dark_60) - ) - ) - append(" $endPoint") - } - } - - @Parcelize - data class Data(val id: String) : Parcelable - - override fun onBackPressed(): Boolean { - if (binding.searchView.isVisible) { - exitSearch() - return true - } - return false - } -} - -private const val STACK_TRACE_LENGTH = 10 -@Suppress("StringLiteralDuplication") -private fun ApiCallData.toShareText(): String { - val text = StringBuilder() - text.append("${request.method.uppercase(Locale.getDefault())}, ${request.url} ") - if (response != null) { - text.append( - "\n\nRequested at : ${response!!.sendTimeMillis.asFormattedDate("MMM dd, yyyy, HH:mm:ss.SSS")}" - ) - text.append( - "\nReceived at : ${response!!.receiveTimeMillis.asFormattedDate("MMM dd, yyyy, HH:mm:ss.SSS")}" - ) - text.append("\nDelay : ${response!!.receiveTimeMillis - response!!.sendTimeMillis} ms") - text.append("\nProtocol : ${response!!.protocol.name}") - } else { - text.append( - "\n\nRequested at : ${request.timestamp.asFormattedDate("MMM dd, yyyy, HH:mm:ss.SSS")}" - ) - } - text.append("\n\n==================\n\n") - text.append("REQUEST") - text.append("\n\nHeaders : \n${Gson().toJson(request.headers)}") - text.append("\n\nBody :\n${request.body?.body}") - - response?.let { response -> - text.append("\n\n==================\n\n") - text.append("RESPONSE") - text.append("\n\nHeaders : \n${Gson().toJson(response.headers)}") - text.append("\n\nBody : \n${response.body?.body}") - } - exception?.let { exception -> - text.append("\n\n==================\n\n") - text.append("RESPONSE\n") - text.append("\n${exception.name}: ${exception.message}\n") - exception.stackTrace.take(STACK_TRACE_LENGTH).forEach { - text.append("\t at $it\n") - } - if (exception.stackTrace.size - STACK_TRACE_LENGTH > 0) { - text.append("\t + ${exception.stackTrace.size - STACK_TRACE_LENGTH} more lines") - } - } - return text.toString() -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsOverviewFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsOverviewFragment.kt deleted file mode 100644 index b139656a9..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsOverviewFragment.kt +++ /dev/null @@ -1,241 +0,0 @@ -package com.mocklets.pluto.modules.network.ui.details - -import android.os.Bundle -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.asFormattedDate -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.sharing.Shareable -import com.mocklets.pluto.core.sharing.lazyContentSharer -import com.mocklets.pluto.core.ui.routing.Screens -import com.mocklets.pluto.core.ui.routing.lazyRouter -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.createSpan -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoFragmentNetworkDetailsOverviewBinding -import com.mocklets.pluto.modules.exceptions.ExceptionData -import com.mocklets.pluto.modules.network.ApiCallData -import com.mocklets.pluto.modules.network.ResponseData -import com.mocklets.pluto.modules.network.proxy.NetworkProxyViewModel -import com.mocklets.pluto.modules.network.proxy.dao.NetworkProxyEntity -import com.mocklets.pluto.modules.network.proxy.ui.NetworkProxySettingsFragment -import com.mocklets.pluto.modules.network.ui.DetailContentData -import com.mocklets.pluto.modules.network.ui.NetworkViewModel - -internal class NetworkDetailsOverviewFragment : Fragment(R.layout.pluto___fragment_network_details_overview) { - - private val binding by viewBinding(PlutoFragmentNetworkDetailsOverviewBinding::bind) - private val viewModel: NetworkViewModel by activityViewModels() - private val proxyViewModel: NetworkProxyViewModel by viewModels() - private val router by lazyRouter() - private val contentSharer by lazyContentSharer() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.detailContentLiveData.removeObservers(viewLifecycleOwner) - viewModel.detailContentLiveData.observe(viewLifecycleOwner, apiCallObserver) - - proxyViewModel.currentProxy.removeObservers(viewLifecycleOwner) - proxyViewModel.currentProxy.observe(viewLifecycleOwner, proxySettingsObserver) - - binding.proxyStub.setupProxy.setDebounceClickListener { - viewModel.detailContentLiveData.value?.api?.request?.let { request -> - router.navigate( - Screens.NetworkProxySettings( - NetworkProxySettingsFragment.Data( - request.url.toString(), - request.method, - true - ) - ) - ) - } - } - } - - private val apiCallObserver = Observer { - updateUi(it.api, it.search) - proxyViewModel.fetch(it.api.request.url.toString(), it.api.request.method) - } - - private val proxySettingsObserver = Observer { proxy -> - context?.apply { - binding.proxyStub.setupProxy.text = - getString(if (proxy != null) R.string.pluto___update_api_proxy else R.string.pluto___setup_api_proxy) - } - } - - private fun updateUi(data: ApiCallData, search: String?) { - setupStatusView(data) - binding.url.setSpan { append(highlight(data.request.url.toString(), search)) } - setupProxyState(data) - binding.method.setSpan { append(highlight(data.request.method, search)) } - binding.ssl.setSpan { append(highlight(data.request.url.isHttps.toString(), search)) } - binding.requestTime.setSpan { - append( - highlight( - semiBold( - fontColor( - data.request.timestamp.asFormattedDate(DATE_FORMAT), - context.color(R.color.pluto___text_dark_80) - ) - ), - search - ) - ) - } - binding.proxyStub.copyCurl.setDebounceClickListener { - contentSharer.share(Shareable(title = "Share Request cURL", content = data.curl, fileName = "cURL Request from Pluto")) - } - - data.exception?.let { - handleExceptionUI(it, data, search) - } - - data.response?.let { - handleResponseUI(it, search) - } - } - - private fun handleResponseUI(it: ResponseData, search: String?) { - binding.protocol.setSpan { - val protocolSpan = context.createSpan { - append(semiBold(fontColor("${it.protocol}", context.color(R.color.pluto___text_dark_80)))) - append(regular(fontColor(" (${it.protocol.name})", context.color(R.color.pluto___text_dark_60)))) - } - append(highlight(protocolSpan, search)) - } - binding.requestTime.setSpan { - append( - highlight( - semiBold( - fontColor( - it.sendTimeMillis.asFormattedDate(DATE_FORMAT), - context.color(R.color.pluto___text_dark_80) - ) - ), - search - ) - ) - } - binding.responseTime.setSpan { - append( - highlight( - semiBold( - fontColor( - it.receiveTimeMillis.asFormattedDate(DATE_FORMAT), - context.color(R.color.pluto___text_dark_80) - ) - ), - search - ) - ) - } - binding.delay.setSpan { - append( - highlight( - semiBold( - fontColor( - "${it.receiveTimeMillis - it.sendTimeMillis} ms", - context.color(R.color.pluto___text_dark_80) - ) - ), - search - ) - ) - } - } - - private fun handleExceptionUI(it: ExceptionData, data: ApiCallData, search: String?) { - binding.protocol.text = context?.getString(R.string.pluto___na) - binding.responseTime.setSpan { - append( - highlight( - semiBold( - fontColor( - it.timeStamp.asFormattedDate(DATE_FORMAT), - context.color(R.color.pluto___text_dark_80) - ) - ), - search - ) - ) - } - binding.delay.setSpan { - append( - highlight( - semiBold( - fontColor( - "${it.timeStamp - data.request.timestamp} ms", - context.color(R.color.pluto___text_dark_80) - ) - ), - search - ) - ) - } - } - - private fun setupProxyState(data: ApiCallData) { - if (data.proxy != null) { - binding.proxyStub.proxyUrlGroup.visibility = VISIBLE - binding.proxyStub.settingsUrl.text = data.proxy?.url - } else { - binding.proxyStub.proxyUrlGroup.visibility = GONE - } - } - - private fun setupStatusView(data: ApiCallData) { - binding.progress.visibility = VISIBLE - binding.status.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) - binding.status.text = context?.getString(R.string.pluto___network_state_in_progress) - binding.statusView.setBackgroundColor(requireContext().color(R.color.pluto___dark_05)) - - data.exception?.let { - binding.progress.visibility = GONE - binding.status.setCompoundDrawablesWithIntrinsicBounds(R.drawable.pluto___ic_error, 0, 0, 0) - binding.status.setSpan { - append( - fontColor( - it.name ?: context.getString(R.string.pluto___network_state_failed), - context.color(R.color.pluto___red) - ) - ) - } - binding.statusView.setBackgroundColor(requireContext().color(R.color.pluto___red_05)) - } - - data.response?.let { - binding.progress.visibility = GONE - binding.status.setCompoundDrawablesWithIntrinsicBounds( - if (it.isSuccessful) R.drawable.pluto___ic_success else R.drawable.pluto___ic_error, 0, 0, 0 - ) - binding.status.setSpan { - append( - fontColor( - bold("${it.status.code} "), - context.color(if (it.isSuccessful) R.color.pluto___dull_green else R.color.pluto___red) - ) - ) - append( - fontColor( - it.status.message, - context.color(if (it.isSuccessful) R.color.pluto___dull_green else R.color.pluto___red) - ) - ) - } - binding.statusView.setBackgroundColor(requireContext().color(if (it.isSuccessful) R.color.pluto___dull_green_08 else R.color.pluto___red_05)) - } - } - - private companion object { - const val DATE_FORMAT = "MMM dd, yyyy, HH:mm:ss.SSS" - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsPagerAdapter.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsPagerAdapter.kt deleted file mode 100644 index 7d93b1fe1..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsPagerAdapter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.mocklets.pluto.modules.network.ui.details - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.mocklets.pluto.R - -internal val TAB_TITLES = arrayOf( - R.string.pluto___network_tab_overview, - R.string.pluto___network_tab_request, - R.string.pluto___network_tab_response -) - -internal class NetworkDetailsPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { - - override fun getItemCount(): Int { - return TAB_TITLES.size - } - - override fun createFragment(position: Int): Fragment { - return when (TAB_TITLES[position]) { - R.string.pluto___network_tab_overview -> NetworkDetailsOverviewFragment() - R.string.pluto___network_tab_request -> NetworkDetailsRequestFragment() - R.string.pluto___network_tab_response -> NetworkDetailsResponseFragment() - else -> NetworkDetailsRequestFragment() - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsRequestFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsRequestFragment.kt deleted file mode 100644 index 692ba52ef..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsRequestFragment.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.mocklets.pluto.modules.network.ui.details - -import android.os.Bundle -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoFragmentNetworkDetailsRequestBinding -import com.mocklets.pluto.modules.network.RequestData -import com.mocklets.pluto.modules.network.beautifyHeaders -import com.mocklets.pluto.modules.network.beautifyQueryParams -import com.mocklets.pluto.modules.network.ui.DetailContentData -import com.mocklets.pluto.modules.network.ui.NetworkViewModel - -internal class NetworkDetailsRequestFragment : Fragment(R.layout.pluto___fragment_network_details_request) { - - private val binding by viewBinding(PlutoFragmentNetworkDetailsRequestBinding::bind) - private val viewModel: NetworkViewModel by activityViewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.detailContentLiveData.removeObservers(viewLifecycleOwner) - viewModel.detailContentLiveData.observe(viewLifecycleOwner, apiCallObserver) - } - - private val apiCallObserver = Observer { - updateUi(it.api.request, it.search) - } - - private fun updateUi(data: RequestData, search: String?) { - if (data.headers.isNotEmpty()) { - binding.headerTitle.setSpan { - append(context.getString(R.string.pluto___headers_title)) - append(fontColor(" (${data.headers.size})", context.color(R.color.pluto___text_dark_40))) - } - context?.beautifyHeaders(data.headers)?.let { binding.headers.setSpan { append(highlight(it, search)) } } - } - - val queryParamsText = context?.beautifyQueryParams(data.url) - if (!queryParamsText.isNullOrEmpty()) { - binding.queryParamsGroup.visibility = VISIBLE - binding.queryParamsTitle.setSpan { - append(context.getString(R.string.pluto___query_params_title)) - append(fontColor(" (${data.url.querySize()})", context.color(R.color.pluto___text_dark_40))) - } - binding.queryParams.setSpan { append(highlight(queryParamsText, search)) } - } else { - binding.queryParamsGroup.visibility = GONE - } - setBody(data, search) - } - - private fun setBody(data: RequestData, search: String?) { - binding.bodyGroup.visibility = GONE - data.body?.let { - if (it.isValid) { - binding.bodyGroup.visibility = VISIBLE - binding.body.setSpan { - if (it.isBinary) { - append(fontColor(italic("${it.body}"), context.color(R.color.pluto___text_dark_60))) - } else { - append(highlight("${it.body}", search)) - } - } - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsResponseFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsResponseFragment.kt deleted file mode 100644 index 082ca4387..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/network/ui/details/NetworkDetailsResponseFragment.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.mocklets.pluto.modules.network.ui.details - -import android.os.Bundle -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.ui.spannable.createSpan -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoFragmentNetworkDetailsResponseBinding -import com.mocklets.pluto.modules.exceptions.ExceptionData -import com.mocklets.pluto.modules.exceptions.ui.holder.CrashItemDetailsHeaderHolder -import com.mocklets.pluto.modules.network.ResponseData -import com.mocklets.pluto.modules.network.beautifyHeaders -import com.mocklets.pluto.modules.network.ui.DetailContentData -import com.mocklets.pluto.modules.network.ui.NetworkViewModel - -internal class NetworkDetailsResponseFragment : Fragment(R.layout.pluto___fragment_network_details_response) { - - private val binding by viewBinding(PlutoFragmentNetworkDetailsResponseBinding::bind) - private val viewModel: NetworkViewModel by activityViewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.waiting.visibility = GONE - binding.headerGroup.visibility = GONE - binding.bodyGroup.visibility = GONE - - viewModel.detailContentLiveData.removeObservers(viewLifecycleOwner) - viewModel.detailContentLiveData.observe(viewLifecycleOwner, apiCallObserver) - } - - private val apiCallObserver = Observer { - when { - it.api.response != null -> updateResponse(it.api.response!!, it.search, it.api.hasResponseBody) - it.api.exception != null -> updateException(it.api.exception!!, it.search) - else -> updateWaiting() - } - } - - private fun updateException(data: ExceptionData, search: String?) { - binding.waiting.visibility = GONE - binding.headerGroup.visibility = VISIBLE - binding.bodyGroup.visibility = GONE - - binding.headerTitle.text = context?.getString(R.string.pluto___exception_title) - setException(data, search) - } - - private fun updateWaiting() { - binding.waiting.visibility = VISIBLE - binding.headerGroup.visibility = GONE - binding.bodyGroup.visibility = GONE - } - - private fun updateResponse(data: ResponseData, search: String?, hasBody: Boolean) { - binding.waiting.visibility = GONE - binding.headerGroup.visibility = VISIBLE - binding.bodyGroup.visibility = GONE - - if (data.headers.isNotEmpty()) { - binding.headerTitle.setSpan { - append(context.getString(R.string.pluto___headers_title)) - append(fontColor(" (${data.headers.size})", context.color(R.color.pluto___text_dark_40))) - } - context?.beautifyHeaders(data.headers)?.let { binding.headers.setSpan { append(highlight(it, search)) } } - } - setBody(data, search, hasBody) - } - - private fun setBody(data: ResponseData, search: String?, hasBody: Boolean) { - if (hasBody) { - binding.bodyGroup.visibility = VISIBLE - binding.body.setSpan { - append(fontColor(italic(context.getString(R.string.pluto___processing_body)), context.color(R.color.pluto___text_dark_40))) - } - data.body?.let { - if (it.isValid) { - binding.bodyGroup.visibility = VISIBLE - binding.body.setSpan { - if (it.isBinary) { - append(fontColor(italic("${it.body}"), context.color(R.color.pluto___text_dark_60))) - } else { - if (it.body?.length ?: 0 > MAX_BLOB_LENGTH) { - append(highlight("${it.body?.subSequence(0, MAX_BLOB_LENGTH)}", search)) - append("\n\n\t") - append( - fontColor( - italic(bold(context.getString(R.string.pluto___content_truncated))), - context.color(R.color.pluto___text_dark_40) - ) - ) - } else { - append(highlight("${it.body}", search)) - } - } - } - } else { - binding.bodyGroup.visibility = GONE - } - } - } - } - - private fun setException(data: ExceptionData, search: String?) { - binding.headerTitle.setSpan { - val exceptionData = context.createSpan { - append( - highlight( - semiBold(fontColor("${data.name}\n", context.color(R.color.pluto___text_dark_80))), - search - ) - ) - data.message?.let { append(highlight(semiBold(it), search)) } - } - append(highlight(exceptionData, search)) - } - binding.headers.setSpan { - append(highlight("${data.name}: ${data.message}", search)) - data.stackTrace.take(CrashItemDetailsHeaderHolder.MAX_STACK_TRACE_LINES).forEach { - append("\n\t\t\t") - append(fontColor(" at ", context.color(R.color.pluto___text_dark_40))) - append(highlight(it, search)) - } - val extraTrace = - data.stackTrace.size - CrashItemDetailsHeaderHolder.MAX_STACK_TRACE_LINES - if (extraTrace > 0) { - append( - fontColor( - "\n\t\t\t + $extraTrace more lines", - context.color(R.color.pluto___text_dark_40) - ) - ) - } - } - } - - private companion object { - const val MAX_BLOB_LENGTH = 25_000 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/SharedPrefRepo.kt b/pluto/src/main/java/com/mocklets/pluto/modules/preferences/SharedPrefRepo.kt deleted file mode 100644 index 112769cf0..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/SharedPrefRepo.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.mocklets.pluto.modules.preferences - -import android.content.Context -import android.content.SharedPreferences -import androidx.preference.PreferenceManager -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.core.DebugLog -import com.mocklets.pluto.core.preferences.Preferences -import com.mocklets.pluto.modules.preferences.ui.SharedPrefFile -import com.mocklets.pluto.modules.preferences.ui.SharedPrefKeyValuePair -import java.io.File -import java.lang.reflect.Type - -internal object SharedPrefRepo { - -// private val prefList: ArrayList -// get() = Pluto.appContext.getSharePreferencesFiles() - - private val preferences by lazy { Pluto.preferences } - private val selectedFileListType: Type = object : TypeToken>() {}.type - - fun get(context: Context): List { - val list = arrayListOf() - val prefFilesList = getSelectedPreferenceFiles(context) - prefFilesList.forEach { - val data = context.getPrefKeyValueMap(it) - list.addAll(data.second) - } - list.sortBy { it.key } - return list - } - - fun set(context: Context, pair: SharedPrefKeyValuePair, value: Any) { - val prefFile = context.getPrefFile(pair.prefLabel ?: Preferences.DEFAULT) - val editor = context.getPrefManager(prefFile).edit() - when (value) { - is Int -> editor.putInt(pair.key, value).apply() - is Long -> editor.putLong(pair.key, value).apply() - is Float -> editor.putFloat(pair.key, value).apply() - is Boolean -> editor.putBoolean(pair.key, value).apply() - else -> editor.putString(pair.key, value.toString()).apply() - } - } - - internal fun getSelectedPreferenceFiles(context: Context): Set { - var selectedFiles: HashSet? = - Gson().fromJson(preferences.selectedPreferenceFiles, selectedFileListType) - if (selectedFiles == null) { - preferences.selectedPreferenceFiles = Gson().toJson(context.getSharePreferencesFiles()) - selectedFiles = Gson().fromJson(preferences.selectedPreferenceFiles, selectedFileListType) - } - return selectedFiles ?: emptySet() - } - - fun updateSelectedPreferenceFile(file: SharedPrefFile) { - val selectedFiles: HashSet? = - Gson().fromJson(preferences.selectedPreferenceFiles, selectedFileListType) - selectedFiles?.let { - if (!it.add(file)) { - it.remove(file) - } - preferences.selectedPreferenceFiles = Gson().toJson(selectedFiles) - } - } - - fun deSelectAll() { - preferences.selectedPreferenceFiles = Gson().toJson(emptySet()) - } -} - -internal fun Context.getSharePreferencesFiles(): ArrayList { - val prefsDir = File(applicationInfo?.dataDir, "shared_prefs") - val list = arrayListOf() - if (prefsDir.exists() && prefsDir.isDirectory) { - prefsDir.list()?.forEach { - if (!Preferences.isPlutoPref(it)) { - list.add( - if (it == "${packageName}_preferences.xml") { - SharedPrefFile(Preferences.DEFAULT, true) - } else { - val label = it.replace(".xml", "", true) - SharedPrefFile(label, false) - } - ) - } - } - } - return list -} - -private fun Context.getPrefManager(file: SharedPrefFile): SharedPreferences = - if (file.isDefault) { - PreferenceManager.getDefaultSharedPreferences(this) - } else { - getSharedPreferences(file.label, Context.MODE_PRIVATE) - } - -private fun Context.getPrefKeyValueMap(file: SharedPrefFile): Pair> { - val prefManager = getPrefManager(file) - val list = prefManager.list(file.label, file.isDefault) - return Pair(file.label, list) -} - -private fun SharedPreferences.list(label: String, default: Boolean): List { - val list = arrayListOf() - all.toList().forEach { - list.add(SharedPrefKeyValuePair(it.first, it.second, label, default)) - } - return list -} - -@Suppress("TooGenericExceptionCaught") -private fun Context.getPrefFile(label: String): SharedPrefFile { - try { - val prefFilesList = getSharePreferencesFiles() - prefFilesList.forEach { - if (it.label == label) { - return it - } - } - } catch (e: Exception) { - DebugLog.e("preferences", "error while fetching pref file", e) - } - return SharedPrefFile(Preferences.DEFAULT, isDefault = true) -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/DataModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/DataModel.kt deleted file mode 100644 index 6d87c0988..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/DataModel.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.mocklets.pluto.modules.preferences.ui - -import androidx.annotation.Keep -import com.mocklets.pluto.core.ui.list.ListItem - -@Keep -data class SharedPrefFile( - val label: String, - val isDefault: Boolean -) : ListItem() { - override fun equals(other: Any?): Boolean { - return other is SharedPrefFile && other.label == this.label - } -} - -data class SharedPrefKeyValuePair( - val key: String, - val value: Any?, - val prefLabel: String?, - val isDefault: Boolean = false -) : ListItem() diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefAdapter.kt b/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefAdapter.kt deleted file mode 100644 index d5b8085a4..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefAdapter.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.mocklets.pluto.modules.preferences.ui - -import android.view.ViewGroup -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.modules.preferences.ui.filter.SharedPrefFilterItemHolder - -internal class SharedPrefAdapter(private val listener: OnActionListener) : BaseAdapter() { - - override fun getItemViewType(item: ListItem): Int? { - return when (item) { - is SharedPrefKeyValuePair -> ITEM_TYPE_PAIR - is SharedPrefFile -> ITEM_TYPE_FILTER - else -> null - } - } - - override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { - return when (viewType) { - ITEM_TYPE_PAIR -> SharedPrefKeyValueItemHolder(parent, listener) - ITEM_TYPE_FILTER -> SharedPrefFilterItemHolder(parent, listener) - else -> null - } - } - - companion object { - const val ITEM_TYPE_PAIR = 1001 - const val ITEM_TYPE_FILTER = 1002 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefEditDialog.kt b/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefEditDialog.kt deleted file mode 100644 index 91d8a6c8f..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefEditDialog.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.mocklets.pluto.modules.preferences.ui - -import android.graphics.drawable.ColorDrawable -import android.text.InputType -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.widget.FrameLayout -import androidx.core.content.ContextCompat -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.mocklets.pluto.R -import com.mocklets.pluto.core.DeviceInfo -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.extensions.showKeyboard -import com.mocklets.pluto.core.sharing.Shareable -import com.mocklets.pluto.core.sharing.lazyContentSharer -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoLayoutSharedPrefEditBinding -import java.util.Locale - -internal class SharedPrefEditDialog( - private val fragment: Fragment, - private val onSave: (SharedPrefKeyValuePair, Any) -> Unit -) : BottomSheetDialog(fragment.requireContext(), R.style.PlutoBottomSheetDialogTheme) { - - private val sheetView: View = context.inflate(R.layout.pluto___layout_shared_pref_edit) - private val binding = PlutoLayoutSharedPrefEditBinding.bind(sheetView) - private val deviceInfo = DeviceInfo(context) - private val contentSharer by fragment.lazyContentSharer() - - init { - setContentView(sheetView) - (sheetView.parent as View).background = - ColorDrawable(ContextCompat.getColor(context, R.color.pluto___transparent)) - - setOnShowListener { dialog -> - if (dialog is BottomSheetDialog) { - val bottomSheet = dialog.findViewById(R.id.design_bottom_sheet) as FrameLayout? - val behavior = BottomSheetBehavior.from(bottomSheet!!) - behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - fragment.lifecycleScope.launchWhenResumed { - binding.value.requestFocus() - } - } - } - - override fun onSlide(bottomSheet: View, slideOffset: Float) { - } - }) - behavior.apply { - state = BottomSheetBehavior.STATE_EXPANDED - isHideable = false - skipCollapsed = true - peekHeight = deviceInfo.height - } - } - } - - binding.value.setOnFocusChangeListener { v, hasFocus -> - if (hasFocus) { - v.showKeyboard() - } - } - } - - fun show(pref: SharedPrefKeyValuePair) { - if (isPrefTypeSupported(pref.value)) { - binding.value.isFocusableInTouchMode = true - binding.value.isFocusable = true - binding.save.visibility = VISIBLE - binding.value.addTextChangedListener { - val isValid = it.toString().isValid(pref.value) - binding.save.isEnabled = isValid - binding.save.setBackgroundColor(context.color(if (isValid) R.color.pluto___dark else R.color.pluto___dark_40)) - } - binding.value.inputType = handleKeypad(pref.value) - binding.value.hint = setHint(pref.value) - binding.save.visibility = VISIBLE - binding.disabled.visibility = GONE - } else { - binding.value.isFocusableInTouchMode = false - binding.value.isFocusable = false - binding.save.visibility = GONE - binding.disabled.visibility = VISIBLE - } - binding.file.text = pref.prefLabel - binding.key.text = pref.key - binding.value.setText(pref.value.toString()) - binding.value.setSelection(pref.value.toString().length) - binding.save.setDebounceClickListener { - onSave.invoke(pref, binding.value.text.toString().convert(pref.value)) - } - binding.cta.setDebounceClickListener { - contentSharer.share( - Shareable( - content = "${pref.key} : ${pref.value}", - title = "Share Shared Preference", - fileName = "Preference data from Pluto" - ) - ) - } - show() - } - - private fun setHint(value: Any?): CharSequence = when (value) { - is Int, - is Long -> "12345" - is Float -> "1234.89" - is Boolean -> "true" - else -> "abcde 123" - } - - private fun isPrefTypeSupported(value: Any?): Boolean = value !is Set<*> - - private fun handleKeypad(value: Any?): Int = when (value) { - is Int, - is Long -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED - is Float -> InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_NUMBER_FLAG_DECIMAL - else -> InputType.TYPE_CLASS_TEXT - } -} - -private fun String.convert(value: Any?): Any = when (value) { - is Int -> this.toInt() - is Long -> this.toLong() - is Float -> this.toFloat() - is Boolean -> this.toBoolean() - else -> this -} - -private fun String.isValid(value: Any?): Boolean = when (value) { - is Boolean -> this.lowercase(Locale.getDefault()) == "true" || this.lowercase(Locale.getDefault()) == "false" - is Int, - is Float, - is Long -> this.isNotEmpty() - else -> true -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefFragment.kt deleted file mode 100644 index 1f8a6d6ac..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefFragment.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.mocklets.pluto.modules.preferences.ui - -import android.os.Bundle -import android.view.View -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.delayedLaunchWhenResumed -import com.mocklets.pluto.core.extensions.linearLayoutManager -import com.mocklets.pluto.core.extensions.showMoreOptions -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.CustomItemDecorator -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.routing.Screens -import com.mocklets.pluto.core.ui.routing.lazyRouter -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoFragmentSharedPrefBinding -import com.mocklets.pluto.modules.preferences.SharedPrefRepo - -internal class SharedPrefFragment : Fragment(R.layout.pluto___fragment_shared_pref) { - - private val binding by viewBinding(PlutoFragmentSharedPrefBinding::bind) - private val viewModel: SharedPrefViewModel by activityViewModels() - private lateinit var editDialog: SharedPrefEditDialog - - private val prefAdapter: BaseAdapter by lazy { SharedPrefAdapter(onActionListener) } - private val router by lazyRouter() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.refresh() - binding.list.apply { - adapter = prefAdapter - addItemDecoration(CustomItemDecorator(requireContext())) - } - editDialog = SharedPrefEditDialog(this) { pair, value -> - SharedPrefRepo.set(requireContext(), pair, value) - lifecycleScope.delayedLaunchWhenResumed(DIALOG_DISMISS_DELAY) { - viewModel.refresh() - editDialog.dismiss() - } - } -// filterDialog = SharedPrefFilterDilog(this) - binding.search.addTextChangedListener { editable -> - lifecycleScope.launchWhenResumed { - editable?.toString()?.let { - Pluto.session.preferencesSearchText = it - prefAdapter.list = filteredPrefs(it) - if (it.isEmpty()) { - binding.list.linearLayoutManager()?.scrollToPositionWithOffset(0, 0) - } - } - } - } - binding.more.setDebounceClickListener { - requireContext().showMoreOptions(it, R.menu.pluto___popup_menu_shared_pref) { item -> - when (item.itemId) { - R.id.filter -> router.navigate(Screens.SharedPrefFilter) - } - } - } - binding.search.setText(Pluto.session.preferencesSearchText) - viewModel.preferences.removeObserver(sharedPrefObserver) - viewModel.preferences.observe(viewLifecycleOwner, sharedPrefObserver) - } - - private fun filteredPrefs(search: String): List { - var list = emptyList() - viewModel.preferences.value?.let { - list = it.filter { pref -> - pref.key.contains(search, true) - } - } - binding.noItemText.visibility = if (list.isEmpty()) View.VISIBLE else View.GONE - return list - } - - private val sharedPrefObserver = Observer> { - prefAdapter.list = filteredPrefs(binding.search.text.toString()) - } - - private val onActionListener = object : DiffAwareAdapter.OnActionListener { - override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder?) { - if (data is SharedPrefKeyValuePair) { - editDialog.show(data) - } - } - } - - private companion object { - const val DIALOG_DISMISS_DELAY = 200L - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefKeyValueItemHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefKeyValueItemHolder.kt deleted file mode 100644 index c8ffbaddc..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefKeyValueItemHolder.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.mocklets.pluto.modules.preferences.ui - -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.createSpan -import com.mocklets.pluto.databinding.PlutoItemSharedPrefKeyValueBinding - -internal class SharedPrefKeyValueItemHolder( - parent: ViewGroup, - actionListener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_shared_pref_key_value), actionListener) { - - private val binding = PlutoItemSharedPrefKeyValueBinding.bind(itemView) - private val key = binding.key - private val value = binding.value - private val file = binding.file - - override fun onBind(item: ListItem) { - if (item is SharedPrefKeyValuePair) { - key.text = item.key - file.visibility = if (item.isDefault) GONE else VISIBLE - val fileName = item.prefLabel - file.text = if (fileName != null) { - if (fileName.length > MAX_FILENAME_LENGTH) { - "${fileName.substring(0, MAX_FILENAME_LENGTH - 2)}..." - } else { - fileName - } - } else { - itemView.context.createSpan { - append(fontColor(light(italic("null")), context.color(R.color.pluto___text_dark_40))) - } - } - item.value?.let { value.text = it.toString() } - - itemView.setDebounceClickListener { - onAction("click") - } - } - } - - companion object { - const val MAX_FILENAME_LENGTH = 18 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefViewModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefViewModel.kt deleted file mode 100644 index c8cdaf113..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/SharedPrefViewModel.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.mocklets.pluto.modules.preferences.ui - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.mocklets.pluto.modules.preferences.SharedPrefRepo - -internal class SharedPrefViewModel(application: Application) : AndroidViewModel(application) { - - val preferences: LiveData> - get() = _preferences - private val _preferences = MutableLiveData>() - - fun refresh() { - _preferences.postValue(SharedPrefRepo.get(getApplication())) - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/filter/SharedPrefFilterFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/filter/SharedPrefFilterFragment.kt deleted file mode 100644 index de7889476..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/filter/SharedPrefFilterFragment.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.mocklets.pluto.modules.preferences.ui.filter - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.dp -import com.mocklets.pluto.core.extensions.toast -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.CustomItemDecorator -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoFragmentSharedPrefFilterBinding -import com.mocklets.pluto.modules.preferences.SharedPrefRepo -import com.mocklets.pluto.modules.preferences.getSharePreferencesFiles -import com.mocklets.pluto.modules.preferences.ui.SharedPrefAdapter -import com.mocklets.pluto.modules.preferences.ui.SharedPrefFile -import com.mocklets.pluto.modules.preferences.ui.SharedPrefViewModel - -internal class SharedPrefFilterFragment : Fragment(R.layout.pluto___fragment_shared_pref_filter) { - - private val binding by viewBinding(PlutoFragmentSharedPrefFilterBinding::bind) - private val viewModel: SharedPrefViewModel by activityViewModels() - - private val prefAdapter: BaseAdapter by lazy { SharedPrefAdapter(onActionListener) } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.list.apply { - adapter = prefAdapter - addItemDecoration(CustomItemDecorator(requireContext(), DECORATOR_DIVIDER_PADDING)) - } - binding.close.setDebounceClickListener { - activity?.onBackPressed() - } - - binding.clear.setDebounceClickListener { - SharedPrefRepo.deSelectAll() - prefAdapter.notifyDataSetChanged() - requireContext().toast(requireContext().getString(R.string.pluto___preferences_cleared)) - } - - prefAdapter.list = context?.getSharePreferencesFiles() ?: emptyList() - } - - override fun onDestroyView() { - super.onDestroyView() - viewModel.refresh() - } - - private val onActionListener = object : DiffAwareAdapter.OnActionListener { - override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder?) { - if (data is SharedPrefFile) { - SharedPrefRepo.updateSelectedPreferenceFile(data) - holder?.absoluteAdapterPosition?.let { prefAdapter.notifyItemChanged(it) } - } - } - } - - private companion object { - val DECORATOR_DIVIDER_PADDING = 16f.dp.toInt() - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/filter/SharedPrefFilterItemHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/filter/SharedPrefFilterItemHolder.kt deleted file mode 100644 index dc8646069..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/preferences/ui/filter/SharedPrefFilterItemHolder.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.mocklets.pluto.modules.preferences.ui.filter - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoItemSharedPrefFilterBinding -import com.mocklets.pluto.modules.preferences.SharedPrefRepo -import com.mocklets.pluto.modules.preferences.ui.SharedPrefFile - -internal class SharedPrefFilterItemHolder( - parent: ViewGroup, - listener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_shared_pref_filter), listener) { - - private val binding = PlutoItemSharedPrefFilterBinding.bind(itemView) - private val title = binding.title - private val checkbox = binding.checkbox - - override fun onBind(item: ListItem) { - if (item is SharedPrefFile) { - title.setSpan { - if (item.isDefault) { - append(italic(light(fontColor(item.label, context.color(R.color.pluto___text_dark_60))))) - } else { - append(item.label) - } - } - itemView.setDebounceClickListener { - onAction("click") - } - checkbox.isSelected = SharedPrefRepo.getSelectedPreferenceFiles(context).contains(item) - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/settings/DataModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/settings/DataModel.kt deleted file mode 100644 index a9179fc8d..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/settings/DataModel.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.mocklets.pluto.modules.settings - -import com.mocklets.pluto.core.ui.list.ListItem - -data class SettingsEasyAccessEntity( - val label: String = "easy_access" -) : ListItem() - -data class SettingsClearDataEntity( - val id: String, - val label: String -) : ListItem() - -data class SettingsSharedPrefEntity( - val label: String = "shared_pref" -) : ListItem() - -data class SettingsLinkMockletsEntity( - val label: String = "mocklets" -) : ListItem() - -data class SettingsEasyAccessPopupAppearanceEntity( - val type: String -) : ListItem() - -data class SettingsResetAllEntity( - val type: String = "rest all" -) : ListItem() diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/settings/SettingsAdapter.kt b/pluto/src/main/java/com/mocklets/pluto/modules/settings/SettingsAdapter.kt deleted file mode 100644 index 79f9a8f04..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/settings/SettingsAdapter.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.mocklets.pluto.modules.settings - -import android.view.ViewGroup -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.modules.settings.holders.SettingsClearDataHolder -import com.mocklets.pluto.modules.settings.holders.SettingsEasyAccessHolder -import com.mocklets.pluto.modules.settings.holders.SettingsEasyAccessPopupAppearanceHolder -import com.mocklets.pluto.modules.settings.holders.SettingsMockletsLinkHolder -import com.mocklets.pluto.modules.settings.holders.SettingsResetAllHolder -import com.mocklets.pluto.modules.settings.holders.SettingsSharePreferenceHolder - -internal class SettingsAdapter(private val listener: OnActionListener) : BaseAdapter() { - override fun getItemViewType(item: ListItem): Int? { - return when (item) { - is SettingsEasyAccessEntity -> ITEM_TYPE_EASY_ACCESS - is SettingsClearDataEntity -> ITEM_TYPE_CLEAR_CRASHES - is SettingsSharedPrefEntity -> ITEM_TYPE_SHARED_PREF - is SettingsLinkMockletsEntity -> ITEM_TYPE_MOCKLETS - is SettingsEasyAccessPopupAppearanceEntity -> ITEM_TYPE_EASY_ACCESS_APPEARANCE - is SettingsResetAllEntity -> ITEM_TYPE_RESET_ALL - else -> null - } - } - - override fun onViewHolderCreated(parent: ViewGroup, viewType: Int): DiffAwareHolder? { - return when (viewType) { - ITEM_TYPE_EASY_ACCESS -> SettingsEasyAccessHolder(parent, listener) - ITEM_TYPE_CLEAR_CRASHES -> SettingsClearDataHolder(parent, listener) - ITEM_TYPE_SHARED_PREF -> SettingsSharePreferenceHolder(parent, listener) - ITEM_TYPE_MOCKLETS -> SettingsMockletsLinkHolder(parent, listener) - ITEM_TYPE_EASY_ACCESS_APPEARANCE -> SettingsEasyAccessPopupAppearanceHolder(parent, listener) - ITEM_TYPE_RESET_ALL -> SettingsResetAllHolder(parent, listener) - else -> null - } - } - - companion object { - const val ITEM_TYPE_EASY_ACCESS = 1000 - const val ITEM_TYPE_CLEAR_CRASHES = 1001 - const val ITEM_TYPE_SHARED_PREF = 1002 - const val ITEM_TYPE_MOCKLETS = 1003 - const val ITEM_TYPE_EASY_ACCESS_APPEARANCE = 1004 - const val ITEM_TYPE_RESET_ALL = 1005 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/settings/SettingsFragment.kt b/pluto/src/main/java/com/mocklets/pluto/modules/settings/SettingsFragment.kt deleted file mode 100644 index 698f0cc55..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/settings/SettingsFragment.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.mocklets.pluto.modules.settings - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.View -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer -import com.mocklets.pluto.BuildConfig -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.dp -import com.mocklets.pluto.core.extensions.openOverlaySettings -import com.mocklets.pluto.core.extensions.toast -import com.mocklets.pluto.core.preferences.Preferences -import com.mocklets.pluto.core.ui.list.BaseAdapter -import com.mocklets.pluto.core.ui.list.CustomItemDecorator -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.routing.Screens -import com.mocklets.pluto.core.ui.routing.lazyRouter -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.core.ui.spannable.setSpan -import com.mocklets.pluto.databinding.PlutoFragmentSettingsBinding -import com.mocklets.pluto.modules.exceptions.ui.CrashesViewModel - -internal class SettingsFragment : Fragment(R.layout.pluto___fragment_settings) { - - private val binding by viewBinding(PlutoFragmentSettingsBinding::bind) - private val viewModel: SettingsViewModel by viewModels() - private val exceptionViewModel: CrashesViewModel by activityViewModels() - private val router by lazyRouter() - private val settingsAdapter: BaseAdapter by lazy { SettingsAdapter(onActionListener) } - - override fun onResume() { - super.onResume() - viewModel.generate(context) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.list.apply { - adapter = settingsAdapter - addItemDecoration(CustomItemDecorator(context, DECORATOR_DIVIDER_PADDING)) - } - viewModel.list.removeObserver(settingsObserver) - viewModel.list.observe(viewLifecycleOwner, settingsObserver) - - binding.about.setDebounceClickListener { - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://pluto.mocklets.com")) - startActivity(browserIntent) - } - - binding.version.setSpan { - append(fontColor(regular("v "), context.color(R.color.pluto___text_dark_40))) - append(semiBold(BuildConfig.VERSION_NAME)) - } - - binding.close.setDebounceClickListener { - activity?.onBackPressed() - } - } - - private val settingsObserver = Observer> { - settingsAdapter.list = it - } - - private val onActionListener = object : DiffAwareAdapter.OnActionListener { - override fun onAction(action: String, data: ListItem, holder: DiffAwareHolder?) { - when (data) { - is SettingsEasyAccessEntity -> context?.openOverlaySettings() - is SettingsEasyAccessPopupAppearanceEntity -> { - when (data.type) { - "mode" -> { - val current = Preferences(context!!).isDarkAccessPopup - Preferences(context!!).isDarkAccessPopup = !current - } - "handed" -> { - val current = Preferences(context!!).isRightHandedAccessPopup - Preferences(context!!).isRightHandedAccessPopup = !current - } - else -> { - check(!BuildConfig.DEBUG) { - "unsupported appearance type" - } - } - } - settingsAdapter.notifyItemChanged(holder?.absoluteAdapterPosition ?: 0) - } - is SettingsSharedPrefEntity -> router.navigate(Screens.SharedPrefFilter) - is SettingsResetAllEntity -> { - viewModel.resetAll() - exceptionViewModel.deleteAll() - context?.toast(context!!.getString(R.string.pluto___reset_all_success_message)) - } - } - } - } - - private companion object { - val DECORATOR_DIVIDER_PADDING = 16f.dp.toInt() - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/settings/SettingsViewModel.kt b/pluto/src/main/java/com/mocklets/pluto/modules/settings/SettingsViewModel.kt deleted file mode 100644 index a35a54a47..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/settings/SettingsViewModel.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.mocklets.pluto.modules.settings - -import android.app.Application -import android.content.Context -import android.os.Build -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.modules.logging.LogsRepo -import com.mocklets.pluto.modules.network.NetworkCallsRepo -import com.mocklets.pluto.modules.network.proxy.NetworkProxyRepo -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -internal class SettingsViewModel(application: Application) : AndroidViewModel(application) { - - val list: LiveData> - get() = _list - private val _list = MutableLiveData>() - - fun generate(context: Context?) { - context?.apply { - val list = arrayListOf() - - val isOSAboveM = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - if (isOSAboveM) { - list.add(SettingsEasyAccessEntity()) - } - list.add(SettingsEasyAccessPopupAppearanceEntity("mode")) - list.add(SettingsEasyAccessPopupAppearanceEntity("handed")) - -// list.add(SettingsLinkMockletsEntity()) todo enable after Mocklets is integrated - - list.add(SettingsSharedPrefEntity()) - list.add(SettingsResetAllEntity()) - _list.postValue(list) - } - } - - fun resetAll() { - viewModelScope.launch(Dispatchers.IO) { - NetworkCallsRepo.deleteAll() - LogsRepo.deleteAll() - NetworkProxyRepo.deleteAll() - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsClearDataHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsClearDataHolder.kt deleted file mode 100644 index baed8f387..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsClearDataHolder.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.mocklets.pluto.modules.settings.holders - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoItemSettingsClearDataBinding -import com.mocklets.pluto.modules.settings.SettingsClearDataEntity - -internal class SettingsClearDataHolder( - parent: ViewGroup, - listener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_clear_data), listener) { - - private val binding = PlutoItemSettingsClearDataBinding.bind(itemView) - private val title = binding.title - private val cta = binding.cta - - override fun onBind(item: ListItem) { - if (item is SettingsClearDataEntity) { - title.text = item.label - cta.setDebounceClickListener { - onAction(item.id) - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsEasyAccessHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsEasyAccessHolder.kt deleted file mode 100644 index fc16000cb..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsEasyAccessHolder.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.mocklets.pluto.modules.settings.holders - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.canDrawOverlays -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoItemSettingsEasyAccessBinding -import com.mocklets.pluto.modules.settings.SettingsEasyAccessEntity - -internal class SettingsEasyAccessHolder( - parent: ViewGroup, - listener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_easy_access), listener) { - - private val binding = PlutoItemSettingsEasyAccessBinding.bind(itemView) - private val checkbox = binding.checkbox - - override fun onBind(item: ListItem) { - if (item is SettingsEasyAccessEntity) { - checkbox.isSelected = itemView.context.canDrawOverlays() - itemView.setDebounceClickListener { - onAction("click") - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsEasyAccessPopupAppearanceHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsEasyAccessPopupAppearanceHolder.kt deleted file mode 100644 index 3f9bba26a..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsEasyAccessPopupAppearanceHolder.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.mocklets.pluto.modules.settings.holders - -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.canDrawOverlays -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.preferences.Preferences -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoItemSettingsEasyAccessAppearanceBinding -import com.mocklets.pluto.modules.settings.SettingsEasyAccessPopupAppearanceEntity - -internal class SettingsEasyAccessPopupAppearanceHolder( - parent: ViewGroup, - listener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_easy_access_appearance), listener) { - - private val binding = PlutoItemSettingsEasyAccessAppearanceBinding.bind(itemView) - private val title = binding.title - private val checkbox = binding.checkbox - private val disableOverlay = binding.disableOverlay - - override fun onBind(item: ListItem) { - if (item is SettingsEasyAccessPopupAppearanceEntity) { - disableOverlay.visibility = if (itemView.context.canDrawOverlays()) GONE else VISIBLE - title.text = context.getString( - when (item.type) { - "mode" -> R.string.pluto___settings_easy_access_appearance_mode_title - "handed" -> R.string.pluto___settings_easy_access_appearance_handed_title - else -> error("unsupported appearance type") - } - ) - checkbox.isSelected = - when (item.type) { - "mode" -> Preferences(context).isDarkAccessPopup - "handed" -> Preferences(context).isRightHandedAccessPopup - else -> error("unsupported appearance type") - } - - if (itemView.context.canDrawOverlays()) { - itemView.setDebounceClickListener { - onAction("click") - } - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsMockletsLinkHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsMockletsLinkHolder.kt deleted file mode 100644 index 65abc7c0c..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsMockletsLinkHolder.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.mocklets.pluto.modules.settings.holders - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoItemSettingsLinkMockletsBinding -import com.mocklets.pluto.modules.settings.SettingsSharedPrefEntity - -internal class SettingsMockletsLinkHolder( - parent: ViewGroup, - listener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_link_mocklets), listener) { - - private val binding = PlutoItemSettingsLinkMockletsBinding.bind(itemView) - - override fun onBind(item: ListItem) { - if (item is SettingsSharedPrefEntity) { - binding.root.setDebounceClickListener { - onAction("click") - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsResetAllHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsResetAllHolder.kt deleted file mode 100644 index b45b447eb..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsResetAllHolder.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.mocklets.pluto.modules.settings.holders - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoItemSettingsResetAllBinding -import com.mocklets.pluto.modules.settings.SettingsResetAllEntity - -internal class SettingsResetAllHolder( - parent: ViewGroup, - listener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_reset_all), listener) { - - private val binding = PlutoItemSettingsResetAllBinding.bind(itemView) - - override fun onBind(item: ListItem) { - if (item is SettingsResetAllEntity) { - binding.root.setDebounceClickListener { - onAction("click") - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsSharePreferenceHolder.kt b/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsSharePreferenceHolder.kt deleted file mode 100644 index 6e4274a26..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/settings/holders/SettingsSharePreferenceHolder.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.mocklets.pluto.modules.settings.holders - -import android.view.ViewGroup -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.ui.list.DiffAwareAdapter -import com.mocklets.pluto.core.ui.list.DiffAwareHolder -import com.mocklets.pluto.core.ui.list.ListItem -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoItemSettingsSharedPrefBinding -import com.mocklets.pluto.modules.settings.SettingsSharedPrefEntity - -internal class SettingsSharePreferenceHolder( - parent: ViewGroup, - listener: DiffAwareAdapter.OnActionListener -) : DiffAwareHolder(parent.inflate(R.layout.pluto___item_settings_shared_pref), listener) { - - private val binding = PlutoItemSettingsSharedPrefBinding.bind(itemView) - - override fun onBind(item: ListItem) { - if (item is SettingsSharedPrefEntity) { - binding.root.setDebounceClickListener { - onAction("click") - } - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/setup/SetupNotification.kt b/pluto/src/main/java/com/mocklets/pluto/modules/setup/SetupNotification.kt deleted file mode 100644 index 44abd5591..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/setup/SetupNotification.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.mocklets.pluto.modules.setup - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import com.mocklets.pluto.R -import com.mocklets.pluto.core.notification.NotificationUtil -import com.mocklets.pluto.ui.PlutoActivity - -internal class SetupNotification(private val context: Context) { - - private val notificationUtil = NotificationUtil(context) - - private val clientAppName: String = context.packageManager.getApplicationLabel( - context.packageManager.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA) - ) as String - - fun add() { - val notificationIntent = Intent(context, PlutoActivity::class.java) - val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) - } else { - PendingIntent.getActivity(context, 0, notificationIntent, 0) - } - - notificationUtil.notify( - title = context.getString(R.string.pluto___notification_title, clientAppName), - text = context.getString(R.string.pluto___notification_subtitle), - intent = pendingIntent, - isOngoing = false, - isAutoCancel = false - ) - } - - fun remove() { - notificationUtil.cancel() - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/EasyAccessSetupDialog.kt b/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/EasyAccessSetupDialog.kt deleted file mode 100644 index 682d64d03..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/EasyAccessSetupDialog.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.mocklets.pluto.modules.setup.easyaccess - -import android.content.Context -import android.graphics.drawable.ColorDrawable -import android.view.View -import android.widget.FrameLayout -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.mocklets.pluto.R -import com.mocklets.pluto.core.DeviceInfo -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.preferences.Preferences -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoLayoutEasyAccessSetupBinding - -internal class EasyAccessSetupDialog(context: Context, onEnabled: () -> Unit) : - BottomSheetDialog(context, R.style.PlutoBottomSheetDialogTheme) { - - private val sheetView: View = context.inflate(R.layout.pluto___layout_easy_access_setup) - private val binding = PlutoLayoutEasyAccessSetupBinding.bind(sheetView) - private val deviceInfo = DeviceInfo(context) - private val preferences = Preferences(context) - - init { - setContentView(sheetView) - (sheetView.parent as View).background = ColorDrawable(context.color(R.color.pluto___transparent)) - - this.setOnShowListener { dialog -> - val d = dialog as BottomSheetDialog - - val bottomSheet = d.findViewById(R.id.design_bottom_sheet) as FrameLayout? - val behavior = BottomSheetBehavior.from(bottomSheet!!) - behavior.apply { - state = BottomSheetBehavior.STATE_EXPANDED - isHideable = false - skipCollapsed = true - peekHeight = deviceInfo.height - } - - dialog.setCancelable(false) - dialog.setCanceledOnTouchOutside(false) - } - - binding.cta.setDebounceClickListener { - dismiss() - onEnabled.invoke() - } - - binding.dismiss.setDebounceClickListener { - dismiss() - } - - setOnShowListener { - preferences.isEasyAccessSetupDialogShown = true - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/OnPopupInteractionListener.kt b/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/OnPopupInteractionListener.kt deleted file mode 100644 index 3742ad687..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/OnPopupInteractionListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.mocklets.pluto.modules.setup.easyaccess - -import android.view.WindowManager - -internal interface OnPopupInteractionListener { - fun onClick() - fun onLayoutParamsUpdated(params: WindowManager.LayoutParams) -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/Popup.kt b/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/Popup.kt deleted file mode 100644 index 72c6693b0..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/Popup.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.mocklets.pluto.modules.setup.easyaccess - -import android.app.Service -import android.content.Context -import android.content.Intent -import android.view.WindowManager -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.canDrawOverlays -import com.mocklets.pluto.core.extensions.toast -import com.mocklets.pluto.ui.PlutoActivity - -internal class Popup(context: Context, shouldShowIntroToast: Boolean) { - - private var isIntroToastAlreadyShown = false - - init { - isIntroToastAlreadyShown = !shouldShowIntroToast - } - - private val interactionListener = object : OnPopupInteractionListener { - override fun onClick() { - val intent = Intent(context, PlutoActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } - - override fun onLayoutParamsUpdated(params: WindowManager.LayoutParams) { - popupViewManager.view?.parent?.let { - windowManager.updateViewLayout(popupViewManager.view, params) - } - } - } - - private val popupViewManager: PopupViewManager = PopupViewManager(context, interactionListener) - private val windowManager: WindowManager = context.getSystemService(Service.WINDOW_SERVICE) as WindowManager - - internal fun add(context: Context) { - if (context.canDrawOverlays()) { - popupViewManager.addView(context, windowManager) - } else { - if (!isIntroToastAlreadyShown) { - context.toast(context.getString(R.string.pluto___welcome_toast)) - isIntroToastAlreadyShown = true - } - } - } - - internal fun remove() { - popupViewManager.removeView(windowManager) - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/PopupViewManager.kt b/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/PopupViewManager.kt deleted file mode 100644 index d941c2294..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/modules/setup/easyaccess/PopupViewManager.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.mocklets.pluto.modules.setup.easyaccess - -import android.content.Context -import android.graphics.PixelFormat -import android.os.Build -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.view.WindowManager -import com.mocklets.pluto.R -import com.mocklets.pluto.core.DeviceInfo -import com.mocklets.pluto.core.extensions.color -import com.mocklets.pluto.core.extensions.inflate -import com.mocklets.pluto.core.preferences.Preferences -import com.mocklets.pluto.core.ui.hapticFeedback -import com.mocklets.pluto.core.ui.soundFeedback -import com.mocklets.pluto.databinding.PlutoLayoutPopupBinding -import kotlin.math.abs - -internal class PopupViewManager( - context: Context, - private val listener: OnPopupInteractionListener -) { - private val deviceInfo = DeviceInfo(context) - private val dragUpLimit = deviceInfo.height * DRAG_UP_THRESHOLD - private val dragDownLimit = deviceInfo.height * DRAG_DOWN_THRESHOLD - - var view: View? = null - val layoutParams = getInitialLayoutParams(context) - - private fun initView(context: Context, view: View) { - view.setOnTouchListener(object : View.OnTouchListener { - private var lastAction = 0 - private var initialX = 0 - private var initialY = 0 - private var initialTouchX = 0f - private var initialTouchY = 0f - - override fun onTouch(v: View?, event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_DOWN -> { - // remember the initial position. - initialX = layoutParams.x - initialY = layoutParams.y - // get the touch location - initialTouchX = event.rawX - initialTouchY = event.rawY - lastAction = event.action - return true - } - MotionEvent.ACTION_UP -> { - if (lastAction == MotionEvent.ACTION_DOWN) { - view.hapticFeedback(true) - view.soundFeedback() - listener.onClick() - } - lastAction = event.action - return true - } - MotionEvent.ACTION_MOVE -> { - val movementX = event.rawX - initialTouchX - val movementY = event.rawY - initialTouchY - - if (abs(movementX) > 1 || abs(movementY) > 1) { -// layoutParams.x = initialX + movementX.toInt() - val currentY = initialY + (event.rawY - initialTouchY).toInt() - if (currentY > dragUpLimit && currentY < dragDownLimit) { - layoutParams.y = currentY - - listener.onLayoutParamsUpdated(layoutParams) - lastAction = event.action - return true - } - return false - } - return false - } - } - return false - } - }) - - view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View?) { - v?.let { - val binding = PlutoLayoutPopupBinding.bind(it) - binding.card.setCardBackgroundColor( - context.color(if (Preferences(context).isDarkAccessPopup) R.color.pluto___dark else R.color.pluto___app_bg) - ) - } - val gravityHorizontal = - if (Preferences(context).isRightHandedAccessPopup) Gravity.END else Gravity.START - layoutParams.gravity = gravityHorizontal or Gravity.TOP - listener.onLayoutParamsUpdated(layoutParams) - } - - override fun onViewDetachedFromWindow(v: View?) { - } - }) - } - - private fun getInitialLayoutParams(context: Context): WindowManager.LayoutParams { - val params: WindowManager.LayoutParams - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - params = WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.TYPE_PHONE, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL - or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH - or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, - PixelFormat.TRANSLUCENT - ) - } else { - params = WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL - or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH - or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, - PixelFormat.TRANSLUCENT - ) - } - - val gravityHorizontal = if (Preferences(context).isRightHandedAccessPopup) Gravity.END else Gravity.START - params.gravity = gravityHorizontal or Gravity.TOP - params.x = (context.resources.getDimension(R.dimen.pluto___popup_bubble_width) * INIT_THRESHOLD_X).toInt() - params.y = (deviceInfo.height * INIT_THRESHOLD_Y).toInt() - - return params - } - - fun addView(context: Context, windowManager: WindowManager) { - if (view == null) { - view = context.inflate(R.layout.pluto___layout_popup) - view?.let { - initView(context, it) - if (it.parent == null) { - windowManager.addView(it, layoutParams) - } - } - } - } - - fun removeView(windowManager: WindowManager) { - view?.parent?.let { - windowManager.removeView(view) - view = null - } - } - - companion object { - const val DRAG_UP_THRESHOLD = 0.03 - const val DRAG_DOWN_THRESHOLD = 0.9 - const val INIT_THRESHOLD_X = -0.75 - const val INIT_THRESHOLD_Y = 0.65 - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/ui/AboutFragment.kt b/pluto/src/main/java/com/mocklets/pluto/ui/AboutFragment.kt deleted file mode 100644 index 2b23d7764..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/ui/AboutFragment.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.mocklets.pluto.ui - -import androidx.fragment.app.Fragment -import com.mocklets.pluto.R - -internal class AboutFragment : Fragment(R.layout.pluto___fragment_about) diff --git a/pluto/src/main/java/com/mocklets/pluto/ui/BaseFragment.kt b/pluto/src/main/java/com/mocklets/pluto/ui/BaseFragment.kt deleted file mode 100644 index 022a5b5cf..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/ui/BaseFragment.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.mocklets.pluto.ui - -import android.os.Bundle -import android.view.View -import androidx.annotation.Keep -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.tabs.TabLayout.MODE_SCROLLABLE -import com.google.android.material.tabs.TabLayoutMediator -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.R -import com.mocklets.pluto.core.binding.viewBinding -import com.mocklets.pluto.core.extensions.delayedLaunchWhenResumed -import com.mocklets.pluto.core.extensions.hideKeyboard -import com.mocklets.pluto.core.ui.routing.RouterAction -import com.mocklets.pluto.core.ui.routing.Screens -import com.mocklets.pluto.core.ui.routing.lazyRouter -import com.mocklets.pluto.core.ui.setDebounceClickListener -import com.mocklets.pluto.databinding.PlutoFragmentBaseBinding - -@Keep -internal class BaseFragment : Fragment(R.layout.pluto___fragment_base) { - - private val binding by viewBinding(PlutoFragmentBaseBinding::bind) - private val router by lazyRouter() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupPager() - binding.close.setDebounceClickListener { router.perform(RouterAction.BackToApp("top_back")) } - binding.settings.setDebounceClickListener(haptic = true) { router.navigate(Screens.Settings) } - binding.appState.setDebounceClickListener(haptic = true) { router.navigate(Screens.AppState) } - } - - private fun setupPager() { - val sectionsPagerAdapter = SectionsPagerAdapter(this) - binding.viewPager.adapter = sectionsPagerAdapter - binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - binding.viewPager.setCurrentItem(Pluto.session.selectTabIndex, false) - binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageScrollStateChanged(state: Int) { - } - - override fun onPageScrolled(position: Int, offset: Float, offsetPixels: Int) { - lifecycleScope.delayedLaunchWhenResumed(SMOOTH_TRANSITION_DELAY) { activity!!.hideKeyboard() } - Pluto.session.selectTabIndex = position - } - - override fun onPageSelected(position: Int) { - } - }) - - TabLayoutMediator(binding.tabs, binding.viewPager) { tab, position -> - tab.text = context?.getString(TAB_TITLES[position]) - }.attach() - binding.tabs.tabMode = MODE_SCROLLABLE - binding.tabs.isInlineLabel = false - } - - private companion object { - const val SMOOTH_TRANSITION_DELAY = 200L - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/ui/PlutoActivity.kt b/pluto/src/main/java/com/mocklets/pluto/ui/PlutoActivity.kt deleted file mode 100644 index 1c2780624..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/ui/PlutoActivity.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.mocklets.pluto.ui - -import android.content.Intent -import android.os.Bundle -import androidx.activity.viewModels -import androidx.annotation.Keep -import androidx.fragment.app.FragmentActivity -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.R -import com.mocklets.pluto.core.extensions.canDrawOverlays -import com.mocklets.pluto.core.extensions.openOverlaySettings -import com.mocklets.pluto.core.preferences.Preferences -import com.mocklets.pluto.core.sharing.ContentShare -import com.mocklets.pluto.core.ui.routing.RouteManager -import com.mocklets.pluto.modules.network.proxy.NetworkProxyViewModel -import com.mocklets.pluto.modules.network.proxy.ui.NetworkProxySettingsFragment.Companion.IN_APP_BROWSER_RESULT_CODE -import com.mocklets.pluto.modules.setup.easyaccess.EasyAccessSetupDialog - -@Keep -internal class PlutoActivity : FragmentActivity(R.layout.pluto___activity_pluto) { - - private lateinit var routeManager: RouteManager - private lateinit var contentShareHelper: ContentShare - private val networkProxyViewModel: NetworkProxyViewModel by viewModels() - private lateinit var preferences: Preferences - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - routeManager = RouteManager(this, R.id.container) - contentShareHelper = ContentShare(this) - preferences = Preferences(this) - } - - override fun onResume() { - super.onResume() - if (!canDrawOverlays() && !preferences.isEasyAccessSetupDialogShown) { - showEasyAccessSetup() - } - } - - override fun onBackPressed() { - if (!routeManager.onBackPressed()) { - super.onBackPressed() - } - } - - private fun showEasyAccessSetup() { - EasyAccessSetupDialog(this) { openOverlaySettings() }.show() - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == IN_APP_BROWSER_RESULT_CODE) { - Pluto.activity.customTabClosed() - networkProxyViewModel.onInAppBrowserClose() - } - } -} diff --git a/pluto/src/main/java/com/mocklets/pluto/ui/SectionsPagerAdapter.kt b/pluto/src/main/java/com/mocklets/pluto/ui/SectionsPagerAdapter.kt deleted file mode 100644 index 9c3a502ae..000000000 --- a/pluto/src/main/java/com/mocklets/pluto/ui/SectionsPagerAdapter.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.mocklets.pluto.ui - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.mocklets.pluto.R -import com.mocklets.pluto.modules.exceptions.ui.CrashesFragment -import com.mocklets.pluto.modules.logging.ui.LogsFragment -import com.mocklets.pluto.modules.network.ui.NetworkFragment -import com.mocklets.pluto.modules.preferences.ui.SharedPrefFragment - -internal val TAB_TITLES = arrayOf( - R.string.pluto___tab_network, - R.string.pluto___tab_logger, - R.string.pluto___tab_crash, - R.string.pluto___tab_shared_pref -) - -internal class SectionsPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { - - override fun getItemCount(): Int { - return TAB_TITLES.size - } - - override fun createFragment(position: Int): Fragment { - return when (TAB_TITLES[position]) { - R.string.pluto___tab_logger -> LogsFragment() - R.string.pluto___tab_network -> NetworkFragment() - R.string.pluto___tab_crash -> CrashesFragment() - R.string.pluto___tab_shared_pref -> SharedPrefFragment() - else -> NetworkFragment() - } - } -} diff --git a/pluto/src/main/res/color/pluto___bg_strip_button_dark.xml b/pluto/src/main/res/color/pluto___bg_strip_button_dark.xml deleted file mode 100644 index e17bf1e56..000000000 --- a/pluto/src/main/res/color/pluto___bg_strip_button_dark.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/drawable/pluto___bg_bottom_sheet_content.xml b/pluto/src/main/res/drawable/pluto___bg_bottom_sheet_content.xml deleted file mode 100644 index 1c754b7b9..000000000 --- a/pluto/src/main/res/drawable/pluto___bg_bottom_sheet_content.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/drawable/pluto___bg_section_light.xml b/pluto/src/main/res/drawable/pluto___bg_section_light.xml deleted file mode 100644 index 57700bcd1..000000000 --- a/pluto/src/main/res/drawable/pluto___bg_section_light.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/drawable/pluto___ic_anr_warning.xml b/pluto/src/main/res/drawable/pluto___ic_anr_warning.xml deleted file mode 100644 index 6601a2991..000000000 --- a/pluto/src/main/res/drawable/pluto___ic_anr_warning.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/pluto/src/main/res/drawable/pluto___ic_check_light.xml b/pluto/src/main/res/drawable/pluto___ic_check_light.xml deleted file mode 100644 index 33bf8027c..000000000 --- a/pluto/src/main/res/drawable/pluto___ic_check_light.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/pluto/src/main/res/drawable/pluto___ic_debugger.xml b/pluto/src/main/res/drawable/pluto___ic_debugger.xml deleted file mode 100644 index c2ee660d0..000000000 --- a/pluto/src/main/res/drawable/pluto___ic_debugger.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/pluto/src/main/res/drawable/pluto___ic_delete.xml b/pluto/src/main/res/drawable/pluto___ic_delete.xml deleted file mode 100644 index 3226ffc77..000000000 --- a/pluto/src/main/res/drawable/pluto___ic_delete.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/pluto/src/main/res/drawable/pluto___ic_key.xml b/pluto/src/main/res/drawable/pluto___ic_key.xml deleted file mode 100644 index 044344240..000000000 --- a/pluto/src/main/res/drawable/pluto___ic_key.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/pluto/src/main/res/drawable/pluto___ic_more_vertical.xml b/pluto/src/main/res/drawable/pluto___ic_more_vertical.xml deleted file mode 100644 index c523ce8f0..000000000 --- a/pluto/src/main/res/drawable/pluto___ic_more_vertical.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/pluto/src/main/res/drawable/pluto___ic_proxy_base_request.xml b/pluto/src/main/res/drawable/pluto___ic_proxy_base_request.xml deleted file mode 100644 index 93c1f5098..000000000 --- a/pluto/src/main/res/drawable/pluto___ic_proxy_base_request.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/pluto/src/main/res/drawable/pluto___ic_settings.xml b/pluto/src/main/res/drawable/pluto___ic_settings.xml deleted file mode 100644 index bebe968b7..000000000 --- a/pluto/src/main/res/drawable/pluto___ic_settings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/pluto/src/main/res/drawable/pluto___ic_user_properties.xml b/pluto/src/main/res/drawable/pluto___ic_user_properties.xml deleted file mode 100644 index f07c47784..000000000 --- a/pluto/src/main/res/drawable/pluto___ic_user_properties.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/pluto/src/main/res/layout/pluto___activity_pluto.xml b/pluto/src/main/res/layout/pluto___activity_pluto.xml deleted file mode 100644 index cd0614947..000000000 --- a/pluto/src/main/res/layout/pluto___activity_pluto.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_about.xml b/pluto/src/main/res/layout/pluto___fragment_about.xml deleted file mode 100644 index c1e248490..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_about.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_app_state.xml b/pluto/src/main/res/layout/pluto___fragment_app_state.xml deleted file mode 100644 index 53c3bbd57..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_app_state.xml +++ /dev/null @@ -1,247 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_base.xml b/pluto/src/main/res/layout/pluto___fragment_base.xml deleted file mode 100644 index 3a5d62233..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_base.xml +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_crash_details.xml b/pluto/src/main/res/layout/pluto___fragment_crash_details.xml deleted file mode 100644 index 81c3059bd..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_crash_details.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_crashes.xml b/pluto/src/main/res/layout/pluto___fragment_crashes.xml deleted file mode 100644 index 03debdd00..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_crashes.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_logs.xml b/pluto/src/main/res/layout/pluto___fragment_logs.xml deleted file mode 100644 index 3735a075f..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_logs.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_network.xml b/pluto/src/main/res/layout/pluto___fragment_network.xml deleted file mode 100644 index 3d19433b9..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_network.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_network_details_overview.xml b/pluto/src/main/res/layout/pluto___fragment_network_details_overview.xml deleted file mode 100644 index 239bb92ac..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_network_details_overview.xml +++ /dev/null @@ -1,282 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_network_details_request.xml b/pluto/src/main/res/layout/pluto___fragment_network_details_request.xml deleted file mode 100644 index 28268b480..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_network_details_request.xml +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_network_details_response.xml b/pluto/src/main/res/layout/pluto___fragment_network_details_response.xml deleted file mode 100644 index 23fff94ee..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_network_details_response.xml +++ /dev/null @@ -1,175 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_network_proxy_settings.xml b/pluto/src/main/res/layout/pluto___fragment_network_proxy_settings.xml deleted file mode 100644 index 27b44a0c8..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_network_proxy_settings.xml +++ /dev/null @@ -1,380 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_network_proxy_settings_list.xml b/pluto/src/main/res/layout/pluto___fragment_network_proxy_settings_list.xml deleted file mode 100644 index c7f47590d..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_network_proxy_settings_list.xml +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_settings.xml b/pluto/src/main/res/layout/pluto___fragment_settings.xml deleted file mode 100644 index 7e46dd66b..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_settings.xml +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_shared_pref.xml b/pluto/src/main/res/layout/pluto___fragment_shared_pref.xml deleted file mode 100644 index 3151511c6..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_shared_pref.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___fragment_shared_pref_filter.xml b/pluto/src/main/res/layout/pluto___fragment_shared_pref_filter.xml deleted file mode 100644 index ae3da629a..000000000 --- a/pluto/src/main/res/layout/pluto___fragment_shared_pref_filter.xml +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_anr_thread_state.xml b/pluto/src/main/res/layout/pluto___item_anr_thread_state.xml deleted file mode 100644 index 6aa64bfa8..000000000 --- a/pluto/src/main/res/layout/pluto___item_anr_thread_state.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/pluto/src/main/res/layout/pluto___item_app_state.xml b/pluto/src/main/res/layout/pluto___item_app_state.xml deleted file mode 100644 index 25c45aea7..000000000 --- a/pluto/src/main/res/layout/pluto___item_app_state.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_crash.xml b/pluto/src/main/res/layout/pluto___item_crash.xml deleted file mode 100644 index 877838a29..000000000 --- a/pluto/src/main/res/layout/pluto___item_crash.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_crash_details_device.xml b/pluto/src/main/res/layout/pluto___item_crash_details_device.xml deleted file mode 100644 index 42be44b00..000000000 --- a/pluto/src/main/res/layout/pluto___item_crash_details_device.xml +++ /dev/null @@ -1,359 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_crash_details_header.xml b/pluto/src/main/res/layout/pluto___item_crash_details_header.xml deleted file mode 100644 index 81f9f7192..000000000 --- a/pluto/src/main/res/layout/pluto___item_crash_details_header.xml +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_crash_details_thread.xml b/pluto/src/main/res/layout/pluto___item_crash_details_thread.xml deleted file mode 100644 index 0f50b2a2c..000000000 --- a/pluto/src/main/res/layout/pluto___item_crash_details_thread.xml +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_crash_details_thread_states.xml b/pluto/src/main/res/layout/pluto___item_crash_details_thread_states.xml deleted file mode 100644 index a8c9c916e..000000000 --- a/pluto/src/main/res/layout/pluto___item_crash_details_thread_states.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_log.xml b/pluto/src/main/res/layout/pluto___item_log.xml deleted file mode 100644 index 847088fa0..000000000 --- a/pluto/src/main/res/layout/pluto___item_log.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_network.xml b/pluto/src/main/res/layout/pluto___item_network.xml deleted file mode 100644 index 7a2346407..000000000 --- a/pluto/src/main/res/layout/pluto___item_network.xml +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_network_proxy_settings.xml b/pluto/src/main/res/layout/pluto___item_network_proxy_settings.xml deleted file mode 100644 index 1064ae215..000000000 --- a/pluto/src/main/res/layout/pluto___item_network_proxy_settings.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_settings_clear_data.xml b/pluto/src/main/res/layout/pluto___item_settings_clear_data.xml deleted file mode 100644 index d81676048..000000000 --- a/pluto/src/main/res/layout/pluto___item_settings_clear_data.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_settings_link_mocklets.xml b/pluto/src/main/res/layout/pluto___item_settings_link_mocklets.xml deleted file mode 100644 index ca56d49aa..000000000 --- a/pluto/src/main/res/layout/pluto___item_settings_link_mocklets.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_settings_shared_pref.xml b/pluto/src/main/res/layout/pluto___item_settings_shared_pref.xml deleted file mode 100644 index 07790d928..000000000 --- a/pluto/src/main/res/layout/pluto___item_settings_shared_pref.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_shared_pref_filter.xml b/pluto/src/main/res/layout/pluto___item_shared_pref_filter.xml deleted file mode 100644 index 3efa84573..000000000 --- a/pluto/src/main/res/layout/pluto___item_shared_pref_filter.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___item_shared_pref_key_value.xml b/pluto/src/main/res/layout/pluto___item_shared_pref_key_value.xml deleted file mode 100644 index 0b3f6e3c6..000000000 --- a/pluto/src/main/res/layout/pluto___item_shared_pref_key_value.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___layout_easy_access_setup.xml b/pluto/src/main/res/layout/pluto___layout_easy_access_setup.xml deleted file mode 100644 index 52b710885..000000000 --- a/pluto/src/main/res/layout/pluto___layout_easy_access_setup.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___layout_log_details.xml b/pluto/src/main/res/layout/pluto___layout_log_details.xml deleted file mode 100644 index b535dec29..000000000 --- a/pluto/src/main/res/layout/pluto___layout_log_details.xml +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___layout_network_call_details.xml b/pluto/src/main/res/layout/pluto___layout_network_call_details.xml deleted file mode 100644 index da508711f..000000000 --- a/pluto/src/main/res/layout/pluto___layout_network_call_details.xml +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___layout_popup.xml b/pluto/src/main/res/layout/pluto___layout_popup.xml deleted file mode 100644 index f302e4ded..000000000 --- a/pluto/src/main/res/layout/pluto___layout_popup.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___layout_shared_pref_edit.xml b/pluto/src/main/res/layout/pluto___layout_shared_pref_edit.xml deleted file mode 100644 index 4d0a902e4..000000000 --- a/pluto/src/main/res/layout/pluto___layout_shared_pref_edit.xml +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/layout/pluto___stub_copy_curl.xml b/pluto/src/main/res/layout/pluto___stub_copy_curl.xml deleted file mode 100644 index 9795a903b..000000000 --- a/pluto/src/main/res/layout/pluto___stub_copy_curl.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - diff --git a/pluto/src/main/res/layout/pluto___stub_crash_report.xml b/pluto/src/main/res/layout/pluto___stub_crash_report.xml deleted file mode 100644 index 72fad1d1b..000000000 --- a/pluto/src/main/res/layout/pluto___stub_crash_report.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - diff --git a/pluto/src/main/res/layout/pluto___stub_network_settings.xml b/pluto/src/main/res/layout/pluto___stub_network_settings.xml deleted file mode 100644 index a7cf924aa..000000000 --- a/pluto/src/main/res/layout/pluto___stub_network_settings.xml +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/menu/pluto___popup_menu_crashes.xml b/pluto/src/main/res/menu/pluto___popup_menu_crashes.xml deleted file mode 100644 index 42d9c6b51..000000000 --- a/pluto/src/main/res/menu/pluto___popup_menu_crashes.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/pluto/src/main/res/menu/pluto___popup_menu_logger.xml b/pluto/src/main/res/menu/pluto___popup_menu_logger.xml deleted file mode 100644 index 5db83a47e..000000000 --- a/pluto/src/main/res/menu/pluto___popup_menu_logger.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/pluto/src/main/res/menu/pluto___popup_menu_network.xml b/pluto/src/main/res/menu/pluto___popup_menu_network.xml deleted file mode 100644 index 5086fb580..000000000 --- a/pluto/src/main/res/menu/pluto___popup_menu_network.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/main/res/menu/pluto___popup_menu_shared_pref.xml b/pluto/src/main/res/menu/pluto___popup_menu_shared_pref.xml deleted file mode 100644 index 7b46240cb..000000000 --- a/pluto/src/main/res/menu/pluto___popup_menu_shared_pref.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/pluto/src/main/res/values/attr.xml b/pluto/src/main/res/values/attr.xml deleted file mode 100644 index da2ff5b10..000000000 --- a/pluto/src/main/res/values/attr.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/pluto/src/main/res/values/colors.xml b/pluto/src/main/res/values/colors.xml deleted file mode 100644 index f6e8bc0f3..000000000 --- a/pluto/src/main/res/values/colors.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - #008577 - #00574B - @color/pluto___text_dark_60 - - #f8f8f8 - #ffffff - #11141c - #0D11141c - #3311141c - #6611141c - #9911141c - #cc11141c - - #231f40 - #66231f40 - #99231f40 - #cc231f40 - @color/pluto___dark - #ccffffff - #e50914 - #ff5733 - #0Dff5733 - #99ff5733 - #ccff5733 - #ff9100 - #0Dff9100 - #009463 - #20c997 - #00000000 - #eeecec - #73a964 - #1873a964 - #0D73a964 - - #f5f5f5 - #366BD1 - #cc366BD1 - #66366BD1 - - #aaff9100 - #0c192b - diff --git a/pluto/src/main/res/values/dimens.xml b/pluto/src/main/res/values/dimens.xml deleted file mode 100644 index 1c1d4a1b0..000000000 --- a/pluto/src/main/res/values/dimens.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - 50dp - 25dp - 140dp - 44dp - - 1dp - - 2dp - 4dp - 8dp - 12dp - 16dp - 20dp - 24dp - 28dp - 32dp - 36dp - 40dp - 72dp - - 10sp - 11sp - 12sp - 13sp - 14sp - 15sp - 16sp - 18sp - 20sp - 24sp - 30sp - - 48dp - \ No newline at end of file diff --git a/pluto/src/main/res/values/strings.xml b/pluto/src/main/res/values/strings.xml deleted file mode 100644 index 18c428154..000000000 --- a/pluto/src/main/res/values/strings.xml +++ /dev/null @@ -1,144 +0,0 @@ - - Pluto - - Logger - Network - Crashes & ANRs - Shared Preferences - Search log - Search key - Search API Call - Search Exceptions - Update Value - Delete - Share - Thread - Thread States - Priority - is_Daemon - App State - Device Info - App Version - Android OS - Android API Level - Orientation - is_Rooted - Height - Size - Density - Width - updating StringSet preferences is not supported. - Overview - Request - Response - showing response from - Headers - Query Params - Body - Exception - ~ No Headers - 200 - In Progress - Failed - URL - ~ Waiting for response - Method - is_SSL - Protocol - Requested at - Response received at - Delay (milliseconds) - ~ NA - Pluto is initialised successfully!\nTap on notification to view. - Enable Easy Access - Enable this settings to add an Easy Access floating handler over your application to access the library. - Enable settings - Dismiss - No Logs printed. - No API call detected. - No search result found. - Settings - About - Enable Easy Access - Clear all crash logs - Update - Show Shared Preference for - Configure which Shared Preference files are displayed in the Shared Preferences tab. - Link Mocklets Account - Link - Connect you Mocklets account to manage your network proxy setting efficiently - See All - Network Proxy Settings - Delete All Calls - Delete All Logs - Delete All Crash logs - Filter Settings - No Crashes. - Request URL - Redirect call to - Save Settings - Choose API from Mocklets - Enter the https request URL - You can either enter the mock API url manually\nor - Setup API Proxy for future calls - Update Settings - App Properties - Click the property to copy the value. - No App Properties set - Learn more - Edit Preference - Log Details - Select Preference to see the data from it. - No Preference found.\nAdd a preference or check filter settings. - showing preference from - Event Attributes - Oops! this looks like our miss. - Tap here to report this crash to us, so that you don\'t have to face this crash again. - open Pluto - Exit Pluto - Open App Properties - Open Settings - exit settings - Exit app properties - Exit Crash details - open crash menu - exit network call details - open shared preferences menu - exit network proxy settings - exit network proxy edit - open network menu - open logs menu - Set Dark mode for Access popup - Enable Right handed mode for Access popup - Pluto is debugging %s - Tap here to view complete debug report - \n\n--- Unexpected end of content --- - \n\n--- Content truncated --- - Reset all data - This is clear all the data and app state. Choose this if the library is stuck in an unexpected error. - Reset - Library data reset successful - Pluto detected a crash in %s - Tap here to see crash details. - Share Request cURL - cURL code copied! - Clear - Preference selection cleared! - Search API content - Main thread unresponsive for +%d ms - ANR detected - Search App Property - exit network proxy list screen - No Network Proxy found - New - Changes will be reflected in future API calls - https:// - URL auto filled by Mocklets selection - Search proxy settings - Share as Text - Share as File - Copy to Clipboard - Prefer this option while sharing on Slack. - ~ Laaarge response body,\nworking hard to process it… - ~~ CONTENT TRUNCATED ~~ - diff --git a/pluto/src/main/res/values/styles.xml b/pluto/src/main/res/values/styles.xml deleted file mode 100644 index f0939ffb6..000000000 --- a/pluto/src/main/res/values/styles.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pluto/src/test/java/com/mocklets/pluto/ExampleUnitTest.kt b/pluto/src/test/java/com/mocklets/pluto/ExampleUnitTest.kt deleted file mode 100644 index 9fafb3156..000000000 --- a/pluto/src/test/java/com/mocklets/pluto/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.mocklets.pluto - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/publishTasks.gradle.kts b/publishTasks.gradle.kts new file mode 100644 index 000000000..e35a381a8 --- /dev/null +++ b/publishTasks.gradle.kts @@ -0,0 +1,56 @@ +import java.util.Properties + +/** + * Usage + * ./gradlew publishOnMavenCentral -PshouldRelease=false + * + * shouldRelease=true will publish & release the build. no manual intervention needed + * shouldRelease=false will only publish the build, need to manually release from https://central.sonatype.com/publishing/deployments + */ +tasks.register("publishOnMavenCentral") { + val shouldRelease = project.findProperty("shouldRelease")?.toString()?.toBoolean() ?: false + + doLast { + val gradleFile = file("$rootDir/gradle.properties") + val credentialsFile = file("$rootDir/mavenCredentials.properties") + + if (!credentialsFile.exists()) { + throw GradleException("❌ Credential file not found: $credentialsFile") + } + + // Read existing gradle.properties content into a mutable map + val gradleProperties = Properties().apply { load(gradleFile.inputStream()) }.toMutableMap() + + // Read credentials from credential.properties + val credentials = Properties().apply { load(credentialsFile.inputStream()) } + + // Backup original gradle.properties content + val originalContent = gradleFile.readText() + + // Override common keys & keep non-overlapping keys untouched + credentials.forEach { (key, value) -> gradleProperties[key.toString()] = value.toString() } + gradleProperties["signing.secretKeyRingFile"] = "$rootDir/secring.gpg" + + // Write updated gradle.properties back to file + gradleFile.writer().use { writer -> + gradleProperties.forEach { (key, value) -> writer.write("$key=$value\n") } + } + + try { + val releaseCommand = + if (shouldRelease) "publishAndReleaseToMavenCentral" else "publishToMavenCentral" + val releaseCommandMessage = if (shouldRelease) "Publish & Release" else "Publish" + // Run the Gradle publish command + println("πŸ”Ή Running Gradle publish task : $releaseCommandMessage") + project.exec { + commandLine("./gradlew", releaseCommand, "--no-configuration-cache") + } + println("βœ… $releaseCommandMessage successful!") + println("Validate the deployment at https://central.sonatype.com/publishing/deployments") + } finally { + // Revert gradle.properties to original state + println("πŸ”„ Reverting gradle.properties...") + gradleFile.writeText(originalContent) + } + } +} diff --git a/sample/.gitignore b/sample/.gitignore index 796b96d1c..92910a0ba 100644 --- a/sample/.gitignore +++ b/sample/.gitignore @@ -1 +1,2 @@ /build +/.idea/ diff --git a/sample/build.gradle b/sample/build.gradle deleted file mode 100644 index e48adc89a..000000000 --- a/sample/build.gradle +++ /dev/null @@ -1,72 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' - -android { - compileSdkVersion 29 - buildToolsVersion "29.0.3" - - buildFeatures { - viewBinding true - } - - defaultConfig { - applicationId "com.sampleapp" - minSdkVersion 19 - targetSdkVersion 29 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - debug { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - } -} - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation project(path: ':pluto') - - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'androidx.appcompat:appcompat:1.3.0' - implementation 'androidx.core:core-ktx:1.3.2' - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' - implementation 'com.google.android.material:material:1.4.0' - implementation 'androidx.annotation:annotation:1.2.0' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - - implementation 'androidx.lifecycle:lifecycle-common-java8:2.3.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha02' - implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8" - - implementation 'com.squareup.retrofit2:retrofit:2.7.1' - implementation 'com.google.code.gson:gson:2.8.6' - implementation 'com.squareup.retrofit2:converter-gson:2.7.1' - implementation 'com.squareup.okhttp3:logging-interceptor:3.10.0' - implementation 'com.squareup.okhttp3:okhttp:3.14.4' - implementation 'com.squareup.okio:okio:2.4.3' - - debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' -} diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts new file mode 100644 index 000000000..427a2253d --- /dev/null +++ b/sample/build.gradle.kts @@ -0,0 +1,144 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.ktlint) + alias(libs.plugins.kotlin.serialization) +} + +val version = Versioning.loadVersioningData() +val verCode = version["code"] as Int +val verPublish = version["publish"] as String + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + buildFeatures { + viewBinding = true + buildConfig = true + } + + defaultConfig { + applicationId = "com.sampleapp" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = verCode + versionName = verPublish + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + /** + * build type with lib modules + */ + getByName("debug") { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + ) + } + create("debugMaven") { + initWith(getByName("debug")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks.add("debug") + } + + /** + * build type with lib-no-op modules + */ + create("debugNoOp") { + initWith(getByName("debug")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks.add("debug") + } + create("debugNoOpMaven") { + initWith(getByName("debug")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks.add("debug") + } + + getByName("release") { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.get() + } + namespace = "com.sampleapp" +} + +dependencies { + /* Local Op dependencies */ + "debugImplementation"(project(":pluto:lib")) + "debugImplementation"(project(":pluto-plugins:bundle:lib")) + + /* Maven Op dependencies */ + "debugMavenImplementation"("com.androidpluto:pluto:$verPublish") + "debugMavenImplementation"("com.androidpluto.plugins:bundle-core:$verPublish") + + /* Local NoOp dependencies */ + "debugNoOpImplementation"(project(":pluto:lib-no-op")) + "debugNoOpImplementation"(project(":pluto-plugins:bundle:lib-no-op")) + + /* Maven NoOp dependencies */ + "debugNoOpMavenImplementation"("com.androidpluto:pluto-no-op:$verPublish") + "debugNoOpMavenImplementation"("com.androidpluto.plugins:bundle-core-no-op:$verPublish") + + "releaseImplementation"("com.androidpluto:pluto:$verPublish") + "releaseImplementation"("com.androidpluto.plugins:bundle-core:$verPublish") + + /** + * Other dependencies + */ + implementation(libs.kotlin.stdlib.jdk8) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.core) + implementation(libs.androidx.constraintlayout) + implementation(libs.google.material) + implementation(libs.androidx.annotation) + + implementation(libs.androidx.lifecycle.common) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.lifecycle.runtime) + + implementation(libs.kotlinx.coroutines.core) + + /** + * Network plugin test dependency + */ + implementation(libs.retrofit) + implementation(libs.retrofit.converter.moshi) + implementation(libs.okhttp.logging.interceptor) + + implementation(libs.ktor.client.core.jvm) + implementation(libs.ktor.client.android) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.okhttp) + implementation(libs.okio) + + implementation(libs.moshi) + ksp(libs.moshi.codegen) + + implementation(libs.datastore.preferences) + debugImplementation(libs.leakcanary.android) + + implementation(libs.androidx.navigation.fragment) + + implementation(libs.room) + ksp(libs.room.compiler) +} diff --git a/sample/detekt-baseline.xml b/sample/detekt-baseline.xml new file mode 100644 index 000000000..2b22ba55b --- /dev/null +++ b/sample/detekt-baseline.xml @@ -0,0 +1,11 @@ + + + + + ForbiddenPublicDataClass:PluginListAdapter.kt$PluginListItem$PluginListItem + TopLevelPropertyNaming:NetworkCalls.kt$private const val CONVERSION_FAILURE = "response_conversion_failure" + TopLevelPropertyNaming:NetworkCalls.kt$private const val DEFAULT_ERROR_MESSAGE = "Something went wrong!" + TopLevelPropertyNaming:NetworkCalls.kt$private const val EMPTY_ERROR_MESSAGE = "empty error response" + TopLevelPropertyNaming:NetworkCalls.kt$private const val UPSTREAM_FAILURE = "upstream_failure" + + diff --git a/sample/proguard-rules.pro b/sample/proguard-rules.pro index 76e097b12..a1bd10b11 100644 --- a/sample/proguard-rules.pro +++ b/sample/proguard-rules.pro @@ -32,4 +32,4 @@ -assumenosideeffects class kotlin.jvm.internal.Intrinsics { static void checkParameterIsNotNull(java.lang.Object, java.lang.String); } --keep class com.mocklets.pluto.** { *; } \ No newline at end of file +-keep class com.pluto.** { *; } \ No newline at end of file diff --git a/sample/src/androidTest/java/com/sampleapp/ExampleInstrumentedTest.kt b/sample/src/androidTest/java/com/sampleapp/ExampleInstrumentedTest.kt deleted file mode 100644 index 03d1a8d14..000000000 --- a/sample/src/androidTest/java/com/sampleapp/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.sampleapp - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.sampleapp", appContext.packageName) - } -} diff --git a/sample/src/debug/java/com/sampleapp/pluto/DataSourcePluginGroup.kt b/sample/src/debug/java/com/sampleapp/pluto/DataSourcePluginGroup.kt new file mode 100644 index 000000000..9bee841f9 --- /dev/null +++ b/sample/src/debug/java/com/sampleapp/pluto/DataSourcePluginGroup.kt @@ -0,0 +1,25 @@ +package com.sampleapp.pluto + +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginGroup +import com.pluto.plugin.PluginGroupConfiguration +import com.pluto.plugins.datastore.pref.PlutoDatastorePreferencesPlugin +import com.pluto.plugins.preferences.PlutoSharePreferencesPlugin +import com.pluto.plugins.rooms.db.PlutoRoomsDatabasePlugin + +class DataSourcePluginGroup : PluginGroup(ID) { + + companion object { + const val ID = "datasource-group" + } + + override fun getConfig(): PluginGroupConfiguration = PluginGroupConfiguration( + name = "DataSource Group" + ) + + override fun getPlugins(): List = listOf( + PlutoSharePreferencesPlugin(), + PlutoDatastorePreferencesPlugin(), + PlutoRoomsDatabasePlugin() + ) +} diff --git a/sample/src/debugMaven/java/com/sampleapp/pluto/DataSourcePluginGroup.kt b/sample/src/debugMaven/java/com/sampleapp/pluto/DataSourcePluginGroup.kt new file mode 100644 index 000000000..9bee841f9 --- /dev/null +++ b/sample/src/debugMaven/java/com/sampleapp/pluto/DataSourcePluginGroup.kt @@ -0,0 +1,25 @@ +package com.sampleapp.pluto + +import com.pluto.plugin.Plugin +import com.pluto.plugin.PluginGroup +import com.pluto.plugin.PluginGroupConfiguration +import com.pluto.plugins.datastore.pref.PlutoDatastorePreferencesPlugin +import com.pluto.plugins.preferences.PlutoSharePreferencesPlugin +import com.pluto.plugins.rooms.db.PlutoRoomsDatabasePlugin + +class DataSourcePluginGroup : PluginGroup(ID) { + + companion object { + const val ID = "datasource-group" + } + + override fun getConfig(): PluginGroupConfiguration = PluginGroupConfiguration( + name = "DataSource Group" + ) + + override fun getPlugins(): List = listOf( + PlutoSharePreferencesPlugin(), + PlutoDatastorePreferencesPlugin(), + PlutoRoomsDatabasePlugin() + ) +} diff --git a/sample/src/debugNoOp/java/com/sampleapp/pluto/DataSourcePluginGroup.kt b/sample/src/debugNoOp/java/com/sampleapp/pluto/DataSourcePluginGroup.kt new file mode 100644 index 000000000..4d7ccff99 --- /dev/null +++ b/sample/src/debugNoOp/java/com/sampleapp/pluto/DataSourcePluginGroup.kt @@ -0,0 +1,3 @@ +package com.sampleapp.pluto + +class DataSourcePluginGroup diff --git a/sample/src/debugNoOpMaven/java/com/sampleapp/pluto/DataSourcePluginGroup.kt b/sample/src/debugNoOpMaven/java/com/sampleapp/pluto/DataSourcePluginGroup.kt new file mode 100644 index 000000000..4d7ccff99 --- /dev/null +++ b/sample/src/debugNoOpMaven/java/com/sampleapp/pluto/DataSourcePluginGroup.kt @@ -0,0 +1,3 @@ +package com.sampleapp.pluto + +class DataSourcePluginGroup diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index b7bcb1629..aaaa7a649 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -1,23 +1,23 @@ - + - + android:name=".functions.layoutinspector.DemoLayoutInspectorActivity" + android:exported="false" /> + - diff --git a/sample/src/main/java/com/sampleapp/AskPermissionFragment.kt b/sample/src/main/java/com/sampleapp/AskPermissionFragment.kt new file mode 100644 index 000000000..3cdb52ced --- /dev/null +++ b/sample/src/main/java/com/sampleapp/AskPermissionFragment.kt @@ -0,0 +1,45 @@ +package com.sampleapp + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.sampleapp.databinding.FragmentAskPermisionBinding + +class AskPermissionFragment : BottomSheetDialogFragment() { + + private var _binding: FragmentAskPermisionBinding? = null + private val binding + get() = _binding!! + + override fun getTheme(): Int = R.style.DemoBottomSheetDialog + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentAskPermisionBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.setOnShowListener { + val dialog = it as BottomSheetDialog + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + dialog.behavior.peekHeight = requireContext().getScreen().second + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + binding.root.setOnClickListener { + dismiss() + requireContext().openOverlaySettings() + } + } +} diff --git a/sample/src/main/java/com/sampleapp/ContainerFragment.kt b/sample/src/main/java/com/sampleapp/ContainerFragment.kt new file mode 100644 index 000000000..b33055625 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/ContainerFragment.kt @@ -0,0 +1,63 @@ +package com.sampleapp + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.pluto.Pluto +import com.sampleapp.databinding.FragmentContainerBinding + +class ContainerFragment : BottomSheetDialogFragment() { + + private var _binding: FragmentContainerBinding? = null + private val binding + get() = _binding!! + + override fun getTheme(): Int = R.style.DemoBottomSheetDialog + private val functionInfo: FunctionsModel + get() = FunctionsModel(id = requireArguments().getString(FUNCTION_ID)!!, label = requireArguments().getString(FUNCTION_LABEL)!!) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentContainerBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.setOnShowListener { + val dialog = it as BottomSheetDialog + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + dialog.behavior.peekHeight = requireContext().getScreen().second + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + addFunctionFragment() + binding.close.setOnClickListener { + dismiss() + } + binding.pluginCta.setOnClickListener { + Pluto.open(functionInfo.id) + } + binding.title.text = functionInfo.label + } + + private fun addFunctionFragment() { + childFragmentManager.beginTransaction() + .add(R.id.container, SupportedPlugins.getDemoFragment(functionInfo.id)) + .commit() + } + + companion object { + const val FUNCTION_ID = "functionId" + const val FUNCTION_LABEL = "functionLabel" + } +} diff --git a/sample/src/main/java/com/sampleapp/JavaTest.java b/sample/src/main/java/com/sampleapp/JavaTest.java new file mode 100644 index 000000000..4598278ce --- /dev/null +++ b/sample/src/main/java/com/sampleapp/JavaTest.java @@ -0,0 +1,18 @@ +package com.sampleapp; + +import com.pluto.Pluto; + +public class JavaTest { + + public void showNotch(boolean state) { + Pluto.INSTANCE.showNotch(state); + } + + public void open(String id) { + Pluto.INSTANCE.open(id); + } + + public void open() { + Pluto.INSTANCE.open(); + } +} diff --git a/sample/src/main/java/com/sampleapp/MainActivity.kt b/sample/src/main/java/com/sampleapp/MainActivity.kt index aa55b01a1..1ef8ab601 100644 --- a/sample/src/main/java/com/sampleapp/MainActivity.kt +++ b/sample/src/main/java/com/sampleapp/MainActivity.kt @@ -3,106 +3,66 @@ package com.sampleapp import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.ViewModelProvider -import com.mocklets.pluto.PlutoLog +import com.google.android.material.chip.Chip +import com.pluto.Pluto +import com.pluto.plugins.layoutinspector.PlutoLayoutInspectorPlugin +import com.sampleapp.ContainerFragment.Companion.FUNCTION_ID +import com.sampleapp.ContainerFragment.Companion.FUNCTION_LABEL import com.sampleapp.databinding.ActivityMainBinding +import com.sampleapp.functions.layoutinspector.DemoLayoutInspectorActivity class MainActivity : AppCompatActivity() { - private lateinit var binding: ActivityMainBinding - private val networkViewModel by lazy { ViewModelProvider(this).get(NetworkViewModel::class.java) } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityMainBinding.inflate(layoutInflater) + val binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) - PlutoLog.v(TAG_ACTION, "MainActivity onCreate") - Test().javaTest() - - handleAPIManageCTAs() - handleExceptionCTAs() - handleSharedPrefCTAs() - handleAppPropertiesCTAs() - } - - private fun handleAppPropertiesCTAs() { - binding.appPropertiesCta.setOnClickListener { - saveAppProperties() - startActivity(Intent(this, SecondActivity::class.java)) - } - } - - private fun handleSharedPrefCTAs() { - binding.sharedPrefCta.setOnClickListener { - resetSharedPreferences(this) - } - } - private fun handleExceptionCTAs() { - binding.exceptionCta.setOnClickListener { - throw NullPointerException("Custom Exception") + binding.version.text = + String.format(getString(R.string.version_label), BuildConfig.VERSION_NAME) + SupportedPlugins.get().forEach { + val chip = Chip(this).apply { + text = it.label + setTextAppearance(R.style.ChipTextStyle) + textStartPadding = CHIP_PADDING + textEndPadding = CHIP_PADDING + setOnClickListener { _ -> + if (it.id == PlutoLayoutInspectorPlugin.ID) { + startActivity(Intent(this@MainActivity, DemoLayoutInspectorActivity::class.java)) + } else { + ContainerFragment().apply { + arguments = Bundle().apply { + putString(FUNCTION_ID, it.id) + putString(FUNCTION_LABEL, it.label) + } + show(supportFragmentManager, "container") + } + } + } + } + binding.functionGroup.addView(chip) } - - binding.deadlockCta.setOnClickListener { - TestingThreadANR.testDeadLock() + binding.clearLogsCta.setOnClickListener { Pluto.clearLogs() } + binding.openPlutoCta.setOnClickListener { Pluto.open() } + binding.showNotchCta.setOnClickListener { + if (canDrawOverlays()) { + Pluto.showNotch(true) + } else { + AskPermissionFragment().show(supportFragmentManager, "permission") + } } - - binding.sleepCta.setOnClickListener { - TestingThreadANR.testSleep() - } - - binding.infiniteLoopCta.setOnClickListener { - TestingThreadANR.testInfiniteLoop() + binding.hideNotchCta.setOnClickListener { + if (canDrawOverlays()) { + Pluto.showNotch(false) + } else { + AskPermissionFragment().show(supportFragmentManager, "permission") + } } + binding.suggestCta.setOnClickListener { openBrowser("https://twitter.com/intent/tweet?text=@srtv_prateek+@android_pluto") } + binding.developCta.setOnClickListener { openBrowser("https://github.com/androidPluto/pluto/wiki/Develop-Custom-Pluto-Plugins-(Beta)") } } - private fun handleAPIManageCTAs() { - binding.postCall.setOnClickListener { - PlutoLog.event( - TAG_CLICK, "post_call_cta", getAttrMap() - ) - networkViewModel.post() - } - - binding.getCall.setOnClickListener { - PlutoLog.event( - TAG_CLICK, "get_call_cta", getAttrMap() - ) - networkViewModel.get() - } - - binding.xmlCall.setOnClickListener { - PlutoLog.event( - TAG_CLICK, "xml_call_cta", getAttrMap() - ) - networkViewModel.xml() - } - - binding.formEncodedCall.setOnClickListener { - PlutoLog.event( - TAG_CLICK, "form_url_encoded_call_cta", getAttrMap() - ) - networkViewModel.form() - } - } - - override fun onStart() { - super.onStart() - PlutoLog.v(TAG_ACTION, "MainActivity onStart") - } - - override fun onResume() { - super.onResume() - PlutoLog.v(TAG_ACTION, "MainActivity onResume") - } - - private fun getAttrMap(): HashMap = hashMapOf( - "screen" to "MainActivity", - "timestamp" to System.currentTimeMillis() - ) - companion object { - const val TAG_ACTION = "action" - const val TAG_CLICK = "click_event" + const val CHIP_PADDING = 16f } } diff --git a/sample/src/main/java/com/sampleapp/NetworkViewModel.kt b/sample/src/main/java/com/sampleapp/NetworkViewModel.kt deleted file mode 100644 index 163fb3668..000000000 --- a/sample/src/main/java/com/sampleapp/NetworkViewModel.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.sampleapp - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.google.gson.Gson -import com.google.gson.JsonObject -import com.sampleapp.network.ApiService -import com.sampleapp.network.Network -import com.sampleapp.network.ResponseWrapper -import com.sampleapp.network.enqueue -import kotlinx.coroutines.launch -import okhttp3.MediaType -import okhttp3.RequestBody - -class NetworkViewModel : ViewModel() { - - private val apiService: ApiService by Network.getService() - private val responseData = MutableLiveData>() - - fun get() { - val label = "GET call" - viewModelScope.launch { - val auth = enqueue { apiService.get() } - responseData.postValue( - when (auth) { - is ResponseWrapper.Success -> Pair(label, auth.body) - is ResponseWrapper.Failure -> { - val string = Gson().toJson(auth.error) - Pair(label, Gson().fromJson(string, JsonObject::class.java)) - } - } - ) - } - } - - fun post() { - val label = "POST call" - viewModelScope.launch { - val auth = enqueue { - apiService.post( - hashMapOf( - "user" to "John Smith", - "email" to "john.smith@gmail.com" - ) - ) - } - responseData.postValue( - when (auth) { - is ResponseWrapper.Success -> Pair(label, auth.body) - is ResponseWrapper.Failure -> { - val string = Gson().toJson(auth.error) - Pair(label, Gson().fromJson(string, JsonObject::class.java)) - } - } - ) - } - } - - fun xml() { - val label = "XML Response call" - val requestBodyText = - """byehello""".trimMargin() - val requestBody = - RequestBody.create(MediaType.parse("text/xml"), requestBodyText) - - viewModelScope.launch { - val auth = enqueue { - apiService.xml(requestBody) - } - - responseData.postValue( - when (auth) { - is ResponseWrapper.Success -> Pair(label, auth.body) - is ResponseWrapper.Failure -> { - val string = Gson().toJson(auth.error) - Pair(label, Gson().fromJson(string, JsonObject::class.java)) - } - } - ) - } - } - - fun form() { - val label = "Form URL Encoded call" - viewModelScope.launch { - val auth = enqueue { - apiService.form(title = "sample title", diff = "sample diff") - } - responseData.postValue( - when (auth) { - is ResponseWrapper.Success -> Pair(label, auth.body) - is ResponseWrapper.Failure -> { - val string = Gson().toJson(auth.error) - Pair(label, Gson().fromJson(string, JsonObject::class.java)) - } - } - ) - } - } -} diff --git a/sample/src/main/java/com/sampleapp/SampleApp.kt b/sample/src/main/java/com/sampleapp/SampleApp.kt index 0dd172563..3e3ca89c7 100644 --- a/sample/src/main/java/com/sampleapp/SampleApp.kt +++ b/sample/src/main/java/com/sampleapp/SampleApp.kt @@ -1,25 +1,104 @@ package com.sampleapp import android.app.Application +import android.os.StrictMode +import android.os.StrictMode.ThreadPolicy +import android.os.StrictMode.VmPolicy import android.util.Log -import com.mocklets.pluto.Pluto -import com.mocklets.pluto.PlutoLog -import com.mocklets.pluto.modules.exceptions.ANRException -import com.mocklets.pluto.modules.exceptions.ANRListener +import com.pluto.Pluto +import com.pluto.plugins.datastore.pref.PlutoDatastoreWatcher +import com.pluto.plugins.exceptions.PlutoExceptions +import com.pluto.plugins.exceptions.PlutoExceptionsPlugin +import com.pluto.plugins.layoutinspector.PlutoLayoutInspectorPlugin +import com.pluto.plugins.logger.PlutoLoggerPlugin +import com.pluto.plugins.logger.PlutoTimberTree +import com.pluto.plugins.network.PlutoNetworkPlugin +import com.pluto.plugins.rooms.db.PlutoRoomsDBWatcher +import com.sampleapp.functions.datastore.DemoDatastorePrefFragment.Companion.APP_STATE_PREF_NAME +import com.sampleapp.functions.datastore.DemoDatastorePrefFragment.Companion.USER_STATE_PREF_NAME +import com.sampleapp.functions.datastore.appStateDatastore +import com.sampleapp.functions.datastore.userStateDatastore +import com.sampleapp.functions.roomsdatabase.db.SampleDatabase +import com.sampleapp.functions.roomsdatabase.db2.Sample2Database +import com.sampleapp.pluto.DataSourcePluginGroup +import kotlin.system.exitProcess +import timber.log.Timber class SampleApp : Application() { override fun onCreate() { + initializeStrictMode() super.onCreate() - Pluto.initialize(this) - Pluto.setANRListener(object : ANRListener { - override fun onAppNotResponding(exception: ANRException) { - exception.printStackTrace() - PlutoLog.e("anr-exception", exception.threadStateMap) - } - }) - Pluto.setExceptionHandler { thread, tr -> - Log.d("exception", "uncaught exception handled on thread: " + thread.name, tr) + Pluto.Installer(this) + .addPlugin(PlutoExceptionsPlugin()) +// .addPlugin(PlutoDatastorePreferencesPlugin()) + .addPlugin(PlutoNetworkPlugin()) + .addPlugin(PlutoLoggerPlugin()) +// .addPlugin(PlutoSharePreferencesPlugin()) +// .addPlugin(PlutoRoomsDatabasePlugin()) + .addPlugin(PlutoLayoutInspectorPlugin()) + .addPluginGroup(DataSourcePluginGroup()) + .install() + Pluto.showNotch(true) + + plantPlutoTimber() + setExceptionListener() + watchRoomsDatabase() + watchDatastorePreferences() + } + + private fun initializeStrictMode() { + StrictMode.setThreadPolicy( + ThreadPolicy.Builder() + .permitDiskReads() + .detectDiskWrites() + .detectNetwork() // or .detectAll() for all detectable problems + .penaltyLog() + .build() + ) + StrictMode.setVmPolicy( + VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .penaltyLog() + .build() + ) + } + + /** + * Datastore Preferences handler + */ + private fun watchDatastorePreferences() { + PlutoDatastoreWatcher.watch(APP_STATE_PREF_NAME, appStateDatastore) + PlutoDatastoreWatcher.watch(USER_STATE_PREF_NAME, userStateDatastore) + } + + /** + * Rooms database handler + */ + private fun watchRoomsDatabase() { + PlutoRoomsDBWatcher.watch(SampleDatabase.DB_NAME, SampleDatabase::class.java) + PlutoRoomsDBWatcher.watch(Sample2Database.DB_NAME, Sample2Database::class.java) + } + + /** + * Logger Timber handler + */ + private fun plantPlutoTimber() { + Timber.plant(PlutoTimberTree()) + } + + /** + * Exception handler + */ + private fun setExceptionListener() { + PlutoExceptions.setExceptionHandler { thread, throwable -> + Log.e("exception_demo", "uncaught exception handled on thread: " + thread.name, throwable) + exitProcess(0) + } + + PlutoExceptions.setANRHandler { thread, exception -> + Log.e("anr_demo", "unhandled ANR handled on thread: " + thread.name, exception) } } } diff --git a/sample/src/main/java/com/sampleapp/SecondActivity.kt b/sample/src/main/java/com/sampleapp/SecondActivity.kt deleted file mode 100644 index aa11ac7db..000000000 --- a/sample/src/main/java/com/sampleapp/SecondActivity.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.sampleapp - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity - -class SecondActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_second) - } -} diff --git a/sample/src/main/java/com/sampleapp/SupportedFunctions.kt b/sample/src/main/java/com/sampleapp/SupportedFunctions.kt new file mode 100644 index 000000000..754893266 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/SupportedFunctions.kt @@ -0,0 +1,48 @@ +package com.sampleapp + +import androidx.annotation.Keep +import androidx.fragment.app.Fragment +import com.pluto.plugins.datastore.pref.PlutoDatastorePreferencesPlugin +import com.pluto.plugins.exceptions.PlutoExceptionsPlugin +import com.pluto.plugins.layoutinspector.PlutoLayoutInspectorPlugin +import com.pluto.plugins.logger.PlutoLoggerPlugin +import com.pluto.plugins.network.PlutoNetworkPlugin +import com.pluto.plugins.preferences.PlutoSharePreferencesPlugin +import com.pluto.plugins.rooms.db.PlutoRoomsDatabasePlugin +import com.sampleapp.functions.datastore.DemoDatastorePrefFragment +import com.sampleapp.functions.exceptions.DemoExceptionFragment +import com.sampleapp.functions.logger.DemoLoggerFragment +import com.sampleapp.functions.network.DemoNetworkFragment +import com.sampleapp.functions.roomsdatabase.DemoRoomsDatabaseFragment +import com.sampleapp.functions.sharedpreferences.DemoSharedPrefFragment + +class SupportedPlugins private constructor() { + companion object { + internal fun get(): ArrayList { + return arrayListOf( + FunctionsModel(PlutoNetworkPlugin.ID, "Network & API calls"), + FunctionsModel(PlutoExceptionsPlugin.ID, "Crashes & ANRs"), + FunctionsModel(PlutoLoggerPlugin.ID, "Logger"), + FunctionsModel(PlutoSharePreferencesPlugin.ID, "Shared Preferences"), + FunctionsModel(PlutoRoomsDatabasePlugin.ID, "Rooms Database"), + FunctionsModel(PlutoDatastorePreferencesPlugin.ID, "Datastore Preferences"), + FunctionsModel(PlutoLayoutInspectorPlugin.ID, "Layout Inspector") + ) + } + + fun getDemoFragment(id: String): Fragment { + return when (id) { + PlutoNetworkPlugin.ID -> DemoNetworkFragment() + PlutoExceptionsPlugin.ID -> DemoExceptionFragment() + PlutoSharePreferencesPlugin.ID -> DemoSharedPrefFragment() + PlutoDatastorePreferencesPlugin.ID -> DemoDatastorePrefFragment() + PlutoLoggerPlugin.ID -> DemoLoggerFragment() + PlutoRoomsDatabasePlugin.ID -> DemoRoomsDatabaseFragment() + else -> DemoNetworkFragment() + } + } + } +} + +@Keep +internal data class FunctionsModel(val id: String, val label: String) diff --git a/sample/src/main/java/com/sampleapp/Test.java b/sample/src/main/java/com/sampleapp/Test.java deleted file mode 100644 index 327435c35..000000000 --- a/sample/src/main/java/com/sampleapp/Test.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.sampleapp; - -import com.mocklets.pluto.PlutoLog; - -public class Test { - - public void javaTest() { - PlutoLog.v("tag", "message", null); - } -} diff --git a/sample/src/main/java/com/sampleapp/Utils.kt b/sample/src/main/java/com/sampleapp/Utils.kt index c9e550fd9..0b41d1541 100644 --- a/sample/src/main/java/com/sampleapp/Utils.kt +++ b/sample/src/main/java/com/sampleapp/Utils.kt @@ -1,35 +1,47 @@ package com.sampleapp +import android.content.ActivityNotFoundException import android.content.Context -import android.preference.PreferenceManager -import com.mocklets.pluto.Pluto +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast -@Suppress("MagicNumber") -fun resetSharedPreferences(context: Context) { - val defaultPref = PreferenceManager.getDefaultSharedPreferences(context) - val profileDataPref = context.getSharedPreferences("profile_data", Context.MODE_PRIVATE) - val settingsDataPref = context.getSharedPreferences("settings_data", Context.MODE_PRIVATE) +fun ViewGroup.inflate(layoutResId: Int, attachToRoot: Boolean = false): View = + LayoutInflater.from(context).inflate(layoutResId, this, attachToRoot) - defaultPref.edit().putInt("feed_sync_delay", 4000).apply() +@SuppressWarnings("PrintStackTrace") +fun Context.openBrowser(url: String) { + try { + val myIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(myIntent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(this, "No application can handle this request. Please install a browser", Toast.LENGTH_SHORT).show() + e.printStackTrace() + } +} - profileDataPref.edit().putString("user_name", "John Smith").apply() - profileDataPref.edit().putString("user_email", "john.smith@gmail.com").apply() +fun Context.getScreen(): Pair { + val dm = resources.displayMetrics + return Pair(dm.widthPixels, dm.heightPixels) +} - settingsDataPref.edit().putInt("some-int", 123).apply() - settingsDataPref.edit().putLong("points_earned", 34L).apply() - settingsDataPref.edit().putFloat("float", 34.45f).apply() - settingsDataPref.edit().putBoolean("is_email_verified", true).apply() - settingsDataPref.edit().putStringSet("preference_list", hashSetOf("science", "business")).apply() +fun Context.canDrawOverlays(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.canDrawOverlays(this) +} else { + true } -fun saveAppProperties() { - Pluto.setAppProperties( - hashMapOf( - "user_name" to "John Smith", - "user_id" to "8060823b-ab8f-4f9b-bc4d-ec1acd290f23", - "user_location" to "Bangalore, India", - "user_email" to "john.smith@gmail.com", - "device_imei" to "49015420323751" +fun Context.openOverlaySettings() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:$packageName") ) - ) + startActivity(intent) + } } diff --git a/sample/src/main/java/com/sampleapp/functions/datastore/DemoDatastorePrefFragment.kt b/sample/src/main/java/com/sampleapp/functions/datastore/DemoDatastorePrefFragment.kt new file mode 100644 index 000000000..4e5341778 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/datastore/DemoDatastorePrefFragment.kt @@ -0,0 +1,73 @@ +package com.sampleapp.functions.datastore + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.sampleapp.R +import com.sampleapp.databinding.FragmentDemoDatastorePrefBinding +import com.sampleapp.functions.datastore.DemoDatastorePrefFragment.Companion.APP_STATE_PREF_NAME +import com.sampleapp.functions.datastore.DemoDatastorePrefFragment.Companion.USER_STATE_PREF_NAME +import kotlinx.coroutines.launch + +class DemoDatastorePrefFragment : Fragment(R.layout.fragment_demo_datastore_pref) { + private var _binding: FragmentDemoDatastorePrefBinding? = null + private val binding + get() = _binding!! + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentDemoDatastorePrefBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.button.setOnClickListener { initDataForDataStoreSample(requireContext()) } + } + + private fun initDataForDataStoreSample(context: Context) { + lifecycleScope.launch { + context.userStateDatastore.edit { + it[booleanPreferencesKey("is_logged_in")] = true + it[stringPreferencesKey("auth_token")] = "asljknva38uv972gv" + it[stringPreferencesKey("refresh_token")] = "iuch21d2c1acbkufh2918hcb1837bc1a" + } + context.appStateDatastore.edit { + it[booleanPreferencesKey("is_latest_version")] = false + it[stringPreferencesKey("session_uuid")] = "9522b353-e3a9-428c-9af6-338fd5e9f9d6" + it[longPreferencesKey("session_duration")] = RANDOM_LONG + it[floatPreferencesKey("pi_value")] = PI_VALUE + it[doublePreferencesKey("double_value")] = RANDOM_DOUBLE + it[intPreferencesKey("int_value")] = RANDOM_INT + } + } + } + + companion object { + const val RANDOM_LONG = 13_101_993L + const val PI_VALUE = 3.141592653589793238462643383279502884197f + const val RANDOM_INT = 3 + const val RANDOM_DOUBLE = 3.14 + const val APP_STATE_PREF_NAME = "app states" + const val USER_STATE_PREF_NAME = "user states" + } +} + +val Context.appStateDatastore by preferencesDataStore(name = APP_STATE_PREF_NAME) +val Context.userStateDatastore by preferencesDataStore(name = USER_STATE_PREF_NAME) diff --git a/sample/src/main/java/com/sampleapp/functions/exceptions/DemoExceptionFragment.kt b/sample/src/main/java/com/sampleapp/functions/exceptions/DemoExceptionFragment.kt new file mode 100644 index 000000000..e8524e40b --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/exceptions/DemoExceptionFragment.kt @@ -0,0 +1,35 @@ +package com.sampleapp.functions.exceptions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.sampleapp.R +import com.sampleapp.databinding.FragmentDemoExceptionBinding + +class DemoExceptionFragment : Fragment(R.layout.fragment_demo_exception) { + private var _binding: FragmentDemoExceptionBinding? = null + private val binding + get() = _binding!! + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentDemoExceptionBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.nullPointer.setOnClickListener { throw NullPointerException("test exception") } + binding.illegalState.setOnClickListener { throw IllegalStateException("test exception") } + + binding.deadlockCta.setOnClickListener { TestingThreadANR.testDeadLock() } + binding.sleepCta.setOnClickListener { TestingThreadANR.testSleep() } + binding.infiniteLoopCta.setOnClickListener { TestingThreadANR.testInfiniteLoop() } + } +} diff --git a/sample/src/main/java/com/sampleapp/TestingThreadANR.kt b/sample/src/main/java/com/sampleapp/functions/exceptions/TestingThreadANR.kt similarity index 86% rename from sample/src/main/java/com/sampleapp/TestingThreadANR.kt rename to sample/src/main/java/com/sampleapp/functions/exceptions/TestingThreadANR.kt index 1b72cf13b..a7396a84c 100644 --- a/sample/src/main/java/com/sampleapp/TestingThreadANR.kt +++ b/sample/src/main/java/com/sampleapp/functions/exceptions/TestingThreadANR.kt @@ -1,8 +1,7 @@ -package com.sampleapp +package com.sampleapp.functions.exceptions import android.os.Handler -import android.util.Log -import com.mocklets.pluto.PlutoLog +import com.pluto.plugins.logger.PlutoLog class TestingThreadANR : Thread() { @@ -22,7 +21,7 @@ class TestingThreadANR : Thread() { try { sleep(SLEEP_DURATION) } catch (e: InterruptedException) { - Log.e("ANR-Sleep", "interrupted while sleep", e) + PlutoLog.e("ANR-Sleep", "interrupted while sleep", e) } } diff --git a/sample/src/main/java/com/sampleapp/functions/layoutinspector/DemoLayoutInspectorActivity.kt b/sample/src/main/java/com/sampleapp/functions/layoutinspector/DemoLayoutInspectorActivity.kt new file mode 100644 index 000000000..f478172e8 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/layoutinspector/DemoLayoutInspectorActivity.kt @@ -0,0 +1,23 @@ +package com.sampleapp.functions.layoutinspector + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.sampleapp.R +import com.sampleapp.databinding.ActivityDemoLayoutInspectorBinding + +class DemoLayoutInspectorActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityDemoLayoutInspectorBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.close.setOnClickListener { + finish() + } + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .replace(R.id.container, DemoLayoutInspectorFragment()) + .commitNow() + } + } +} diff --git a/sample/src/main/java/com/sampleapp/functions/layoutinspector/DemoLayoutInspectorFragment.kt b/sample/src/main/java/com/sampleapp/functions/layoutinspector/DemoLayoutInspectorFragment.kt new file mode 100644 index 000000000..194aa2c84 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/layoutinspector/DemoLayoutInspectorFragment.kt @@ -0,0 +1,6 @@ +package com.sampleapp.functions.layoutinspector + +import androidx.fragment.app.Fragment +import com.sampleapp.R + +class DemoLayoutInspectorFragment : Fragment(R.layout.fragment_demo_layout_inspector) diff --git a/sample/src/main/java/com/sampleapp/functions/logger/DemoLoggerFragment.kt b/sample/src/main/java/com/sampleapp/functions/logger/DemoLoggerFragment.kt new file mode 100644 index 000000000..3e5481101 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/logger/DemoLoggerFragment.kt @@ -0,0 +1,70 @@ +package com.sampleapp.functions.logger + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.pluto.plugins.logger.PlutoLog +import com.pluto.plugins.logger.event +import com.sampleapp.R +import com.sampleapp.databinding.FragmentDemoLoggerBinding +import java.lang.NullPointerException +import timber.log.Timber + +class DemoLoggerFragment : Fragment(R.layout.fragment_demo_logger) { + private var _binding: FragmentDemoLoggerBinding? = null + private val binding + get() = _binding!! + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentDemoLoggerBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + @SuppressWarnings("StringLiteralDuplication") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + /** + * Pluto Logs demo + */ + binding.logDebug.setOnClickListener { PlutoLog.d("debug", "debug cta clicked") } + binding.logError.setOnClickListener { PlutoLog.e("error", "error cta clicked", NullPointerException("demo")) } + binding.logVerbose.setOnClickListener { PlutoLog.v("verbose", "verbose cta clicked", NullPointerException("demo")) } + binding.logInfo.setOnClickListener { PlutoLog.i("info", "info cta clicked") } + binding.logEvent.setOnClickListener { + PlutoLog.event( + "event", "event cta clicked", + hashMapOf( + "attr_name" to "demo attr", + "timestamp" to System.currentTimeMillis() + ) + ) + PlutoLog.event("event", "event cta clicked without attrs") + } + + /** + * Timber Logs demo + */ + binding.timberDebug.setOnClickListener { Timber.d("timber_debug cta clicked") } + binding.timberError.setOnClickListener { Timber.tag("error").e(NullPointerException("demo"), "timber_error cta clicked") } + binding.timberVerbose.setOnClickListener { Timber.tag("error").v(NullPointerException("demo"), "timber_verbose cta clicked") } + binding.timberInfo.setOnClickListener { Timber.i("timber_info cta clicked") } + binding.timberEvent.setOnClickListener { + Timber.tag("event").event( + "timber_event cta clicked", + hashMapOf( + "attr_name" to "demo attr", + "timestamp" to System.currentTimeMillis() + ) + ) + Timber.tag("event").event("timber_event cta clicked") + } + } +} diff --git a/sample/src/main/java/com/sampleapp/functions/network/DemoNetworkFragment.kt b/sample/src/main/java/com/sampleapp/functions/network/DemoNetworkFragment.kt new file mode 100644 index 000000000..be36d2a6f --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/network/DemoNetworkFragment.kt @@ -0,0 +1,49 @@ +package com.sampleapp.functions.network + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.sampleapp.R +import com.sampleapp.databinding.FragmentDemoNetworkBinding +import com.sampleapp.functions.network.internal.custom.CustomViewModel +import com.sampleapp.functions.network.internal.ktor.KtorViewModel +import com.sampleapp.functions.network.internal.okhttp.OkhttpViewModel + +class DemoNetworkFragment : Fragment(R.layout.fragment_demo_network) { + + private var _binding: FragmentDemoNetworkBinding? = null + private val binding + get() = _binding!! + + private val okhttpViewModel: OkhttpViewModel by viewModels() + private val ktorViewModel: KtorViewModel by viewModels() + private val customViewModel: CustomViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentDemoNetworkBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.graphqlQuery.setOnClickListener { okhttpViewModel.graphqlQuery() } + binding.graphqlQueryError.setOnClickListener { okhttpViewModel.graphqlQueryError() } + binding.graphqlMutation.setOnClickListener { okhttpViewModel.graphqlMutation() } + binding.graphqlMutationError.setOnClickListener { okhttpViewModel.graphqlMutationError() } + binding.postCall.setOnClickListener { okhttpViewModel.post() } + binding.getCall.setOnClickListener { okhttpViewModel.get() } + binding.getCallKtor.setOnClickListener { ktorViewModel.get() } + binding.postCallKtor.setOnClickListener { ktorViewModel.post() } + binding.xmlCall.setOnClickListener { okhttpViewModel.xml() } + binding.formEncodedCall.setOnClickListener { okhttpViewModel.form() } + binding.customTrace.setOnClickListener { customViewModel.customTrace() } + } +} diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/custom/CustomViewModel.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/custom/CustomViewModel.kt new file mode 100644 index 000000000..24eee482a --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/custom/CustomViewModel.kt @@ -0,0 +1,44 @@ +package com.sampleapp.functions.network.internal.custom + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pluto.plugins.network.intercept.NetworkData +import com.pluto.plugins.network.intercept.NetworkInterceptor +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class CustomViewModel : ViewModel() { + + @SuppressWarnings("MagicNumber") + fun customTrace() { + viewModelScope.launch { + val networkInterceptor = NetworkInterceptor.intercept( + NetworkData.Request( + url = "https://google.com", + method = "GET", + body = NetworkData.Body( + body = "{\"message\": \"body\"}", + contentType = "application/json", + ), + headers = emptyMap(), + sentTimestamp = System.currentTimeMillis() + ) + ) + delay(5_000) + networkInterceptor.onResponse( + NetworkData.Response( + statusCode = 503, + body = NetworkData.Body( + body = "body", + contentType = "text/plain", + ), + headers = hashMapOf( + "custom_header" to "custom header value" + ), + sentTimestamp = System.currentTimeMillis(), + receiveTimestamp = System.currentTimeMillis() + 1000 + ) + ) + } + } +} diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/ktor/KtorNetwork.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/ktor/KtorNetwork.kt new file mode 100644 index 000000000..426c5e807 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/ktor/KtorNetwork.kt @@ -0,0 +1,21 @@ +package com.sampleapp.functions.network.internal.ktor + +import com.pluto.plugins.network.interceptors.ktor.PlutoKtorInterceptor +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.header +import io.ktor.http.URLProtocol +import io.ktor.serialization.kotlinx.json.json + +val Client = HttpClient { + defaultRequest { + header("network-type", "ktor") + url.protocol = URLProtocol.HTTPS + url.host = "api.mocklets.com/p68296" + } + install(ContentNegotiation) { + json() + } + install(PlutoKtorInterceptor) +} diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/ktor/KtorViewModel.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/ktor/KtorViewModel.kt new file mode 100644 index 000000000..c1c9788df --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/ktor/KtorViewModel.kt @@ -0,0 +1,34 @@ +package com.sampleapp.functions.network.internal.ktor + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.path +import kotlinx.coroutines.launch + +class KtorViewModel : ViewModel() { + + fun get() { + viewModelScope.launch { + Client.get { + url.path("get") + } + } + } + + fun post() { + viewModelScope.launch { + Client.post { + contentType(ContentType.Application.Json) + setBody( + PostRequestBody("John Smith", "john.smith@gmail.com") + ) + url.path("post", "new") + } + } + } +} diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/ktor/PostRequestBody.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/ktor/PostRequestBody.kt new file mode 100644 index 000000000..a8cc48e2e --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/ktor/PostRequestBody.kt @@ -0,0 +1,12 @@ +package com.sampleapp.functions.network.internal.ktor + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PostRequestBody( + @SerialName("user") + val user: String, + @SerialName("email") + val email: String, +) diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/ApiService.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/ApiService.kt new file mode 100644 index 000000000..556d070d5 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/ApiService.kt @@ -0,0 +1,37 @@ +package com.sampleapp.functions.network.internal.okhttp + +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST + +interface ApiService { + + @FormUrlEncoded + @Headers("Authorization: Bearer e42ed4a6-f346-4f0b-ad49-02c66dcca91a") + @POST("post?scope=points,preferences") + suspend fun form(@Field("title") title: String, @Field("diff") diff: String): Any + + @Headers("Authorization: Bearer e42ed4a6-f346-4f0b-ad49-02c66dcca91a") + @POST("post/new") + suspend fun post(@Body hashMapOf: Any): Any + + @Headers("Authorization: Bearer e42ed4a6-f346-4f0b-ad49-02c66dcca91a") + @GET("get") + suspend fun get(): Any + + @Headers( + "type: xml", + "x-app-theme: light", + "Authorization: Bearer e42ed4a6-f346-4f0b-ad49-02c66dcca91a" + ) + @POST("xml") + suspend fun xml(@Body hashMapOf: RequestBody): Any + + // https://studio.apollographql.com/public/SpaceX-pxxbxen/variant/current/home + @POST("https://spacex-production.up.railway.app/") + suspend fun graphql(@Body body: Any): Any +} diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/DataModels.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/DataModels.kt new file mode 100644 index 000000000..270bca209 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/DataModels.kt @@ -0,0 +1,14 @@ +package com.sampleapp.functions.network.internal.okhttp + +import com.squareup.moshi.JsonClass +import kotlinx.serialization.Serializable + +sealed class ResponseWrapper { + data class Success(val body: T) : ResponseWrapper() + data class Failure(val error: ErrorResponse, val errorString: String? = null) : + ResponseWrapper() +} + +@Serializable +@JsonClass(generateAdapter = true) +data class ErrorResponse(val reason: String?, val error: String) diff --git a/sample/src/main/java/com/sampleapp/network/GzipRequestInterceptor.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/GzipRequestInterceptor.kt similarity index 84% rename from sample/src/main/java/com/sampleapp/network/GzipRequestInterceptor.kt rename to sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/GzipRequestInterceptor.kt index 8b10643f6..1aba103ba 100644 --- a/sample/src/main/java/com/sampleapp/network/GzipRequestInterceptor.kt +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/GzipRequestInterceptor.kt @@ -1,4 +1,4 @@ -package com.sampleapp.network +package com.sampleapp.functions.network.internal.okhttp import okhttp3.Interceptor import okhttp3.MediaType @@ -13,12 +13,12 @@ import okio.buffer internal class GzipRequestInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val originalRequest: Request = chain.request() - if (originalRequest.body() == null || originalRequest.header("Content-Encoding") != null) { + if (originalRequest.body == null || originalRequest.header("Content-Encoding") != null) { return chain.proceed(originalRequest) } val compressedRequest: Request = originalRequest.newBuilder() .header("Content-Encoding", "gzip") - .method(originalRequest.method(), gzip(originalRequest.body()!!)) + .method(originalRequest.method, gzip(originalRequest.body!!)) .build() return chain.proceed(compressedRequest) } diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/Network.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/Network.kt new file mode 100644 index 000000000..a94d67dc8 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/Network.kt @@ -0,0 +1,38 @@ +package com.sampleapp.functions.network.internal.okhttp + +import com.pluto.plugins.network.interceptors.okhttp.PlutoOkhttpInterceptor +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +object Network { + + private const val READ_TIMEOUT = 30L + + private val retrofit: Retrofit by lazy { + Retrofit.Builder() + .baseUrl("https://api.mocklets.com/p68296/") + .addConverterFactory(MoshiConverterFactory.create()) + .client(okHttpClient) + .build() + } + + private val okHttpClient: OkHttpClient = OkHttpClient.Builder() + .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) +// .addInterceptor(GzipRequestInterceptor()) + .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + .addInterceptor(PlutoOkhttpInterceptor) + .build() + + fun getService(cls: Class): T { + return retrofit.create(cls) + } + + inline fun getService(): Lazy { + return lazy { + getService(T::class.java) + } + } +} diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/NetworkCalls.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/NetworkCalls.kt new file mode 100644 index 000000000..2d1ead813 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/NetworkCalls.kt @@ -0,0 +1,72 @@ +package com.sampleapp.functions.network.internal.okhttp + +import android.util.Log +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import java.io.IOException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.HttpException + +@Suppress("TooGenericExceptionCaught") +suspend fun enqueue( + dispatcher: CoroutineDispatcher = Dispatchers.IO, + apiCall: suspend () -> T +): ResponseWrapper { + + return withContext(dispatcher) { + try { + ResponseWrapper.Success(apiCall.invoke()) + } catch (throwable: Throwable) { + Log.e("network_error", "network failure", throwable) + when (throwable) { + is IOException -> ResponseWrapper.Failure( + ErrorResponse( + "IO_Exception", throwable.message ?: DEFAULT_ERROR_MESSAGE + ) + ) + is HttpException -> ResponseWrapper.Failure(convertErrorBody(throwable)) + else -> ResponseWrapper.Failure( + ErrorResponse(CONVERSION_FAILURE, DEFAULT_ERROR_MESSAGE) + ) + } + } + } +} + +@Suppress("TooGenericExceptionCaught") +private fun convertErrorBody(throwable: HttpException): ErrorResponse { + val moshiAdapter: JsonAdapter = Moshi.Builder().build().adapter(ErrorResponse::class.java) + val errorString = throwable.response()?.errorBody()?.string() + return if (!errorString.isNullOrEmpty()) { + try { + run { + val error = moshiAdapter.fromJson(errorString) + validateError(error) + error ?: ErrorResponse(VALIDATION_ERROR_MESSAGE, DEFAULT_ERROR_MESSAGE) + } + } catch (exception: Exception) { + Log.e( + "network_error", + exception.message.toString(), + exception + ) + ErrorResponse(CONVERSION_FAILURE, DEFAULT_ERROR_MESSAGE) + } + } else { + ErrorResponse(UPSTREAM_FAILURE, EMPTY_ERROR_MESSAGE) + } +} + +private fun validateError(error: ErrorResponse?) { + if (error?.error == null) { // TODO handle deserialization issue + throw KotlinNullPointerException("response.error value null") + } +} + +private const val DEFAULT_ERROR_MESSAGE = "Something went wrong!" +private const val EMPTY_ERROR_MESSAGE = "empty error response" +private const val VALIDATION_ERROR_MESSAGE = "validation_error_message" +private const val UPSTREAM_FAILURE = "upstream_failure" +private const val CONVERSION_FAILURE = "response_conversion_failure" diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/OkhttpViewModel.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/OkhttpViewModel.kt new file mode 100644 index 000000000..b03e58af3 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/OkhttpViewModel.kt @@ -0,0 +1,141 @@ +package com.sampleapp.functions.network.internal.okhttp + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody + +class OkhttpViewModel : ViewModel() { + + private val apiService: ApiService by Network.getService() + private val responseData = MutableLiveData>() + + fun get() { + val label = "GET call" + viewModelScope.launch { + val auth = enqueue { apiService.get() } + responseData.postValue( + when (auth) { + is ResponseWrapper.Success -> Pair(label, auth.body) + is ResponseWrapper.Failure -> Pair(label, auth.error) + } + ) + } + } + + fun graphqlQuery() { + viewModelScope.launch { + enqueue { + apiService.graphql( + mapOf( + GQL_QUERY to "query Launches(\$limit: Int){launches(limit: \$limit){mission_name}}", + GQL_VARIABLES to mapOf("limit" to GQL_LIMIT_VALID), + ) + ) + } + } + } + + fun graphqlQueryError() { + viewModelScope.launch { + enqueue { + apiService.graphql( + mapOf( + GQL_QUERY to "query Launches(\$limit: Int){launches(limit: \$limit){mission_name}}", + GQL_VARIABLES to mapOf("limit" to GQL_LIMIT_INVALID), + ) + ) + } + } + } + + fun graphqlMutation() { + viewModelScope.launch { + enqueue { + apiService.graphql( + mapOf( + GQL_QUERY to "mutation Insert_users(\$objects: [users_insert_input!]!) {insert_users(objects: \$objects) {affected_rows}}", + GQL_VARIABLES to mapOf("objects" to emptyList()), + ) + ) + } + } + } + + fun graphqlMutationError() { + viewModelScope.launch { + enqueue { + apiService.graphql( + mapOf( + GQL_QUERY to "mutation Insert_users(\$objects: [users_insert_input!]!) {insert_users112231321(objects: \$objects) {affected_rows}}", + GQL_VARIABLES to mapOf("objects" to emptyList()), + ) + ) + } + } + } + + fun post() { + val label = "POST call" + viewModelScope.launch { + val auth = enqueue { + apiService.post( + hashMapOf( + "user" to "John Smith", + "email" to "john.smith@gmail.com" + ) + ) + } + responseData.postValue( + when (auth) { + is ResponseWrapper.Success -> Pair(label, auth.body) + is ResponseWrapper.Failure -> Pair(label, auth.error) + } + ) + } + } + + fun xml() { + val label = "XML Response call" + val requestBodyText = + """byehello""".trimMargin() + val requestBody = requestBodyText.toRequestBody("text/xml".toMediaTypeOrNull()) + + viewModelScope.launch { + val auth = enqueue { + apiService.xml(requestBody) + } + + responseData.postValue( + when (auth) { + is ResponseWrapper.Success -> Pair(label, auth.body) + is ResponseWrapper.Failure -> Pair(label, auth.error) + } + ) + } + } + + fun form() { + val label = "Form URL Encoded call" + viewModelScope.launch { + val auth = enqueue { + apiService.form(title = "sample title", diff = "sample diff") + } + responseData.postValue( + when (auth) { + is ResponseWrapper.Success -> Pair(label, auth.body) + is ResponseWrapper.Failure -> Pair(label, auth.error) + } + ) + } + } + + companion object { + private const val GQL_QUERY = "query" + private const val GQL_LIMIT_VALID = 3 + private const val GQL_LIMIT_INVALID = -1111 + private const val GQL_VARIABLES = "variables" + } +} diff --git a/sample/src/main/java/com/sampleapp/functions/roomsdatabase/DemoRoomsDatabaseFragment.kt b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/DemoRoomsDatabaseFragment.kt new file mode 100644 index 000000000..dd5735790 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/DemoRoomsDatabaseFragment.kt @@ -0,0 +1,84 @@ +package com.sampleapp.functions.roomsdatabase + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.sampleapp.R +import com.sampleapp.databinding.FragmentDemoRoomsDatabaseBinding +import com.sampleapp.functions.roomsdatabase.db.SampleDatabase +import com.sampleapp.functions.roomsdatabase.db.entity.Admin +import com.sampleapp.functions.roomsdatabase.db.entity.User +import com.sampleapp.functions.roomsdatabase.db2.Sample2Database +import com.sampleapp.functions.roomsdatabase.db2.entity.UserV2 +import java.util.Random + +@SuppressWarnings("UnderscoresInNumericLiterals") +class DemoRoomsDatabaseFragment : Fragment(R.layout.fragment_demo_rooms_database) { + private var _binding: FragmentDemoRoomsDatabaseBinding? = null + private val binding + get() = _binding!! + + private val genders = arrayOf("Male", "Female") + private val phoneNumberRange = 9900000000..9999999999 + private val ageRange = 1..100 + private val range = 100 + private val db: SampleDatabase by lazy { SampleDatabase.getInstance(requireContext()) } + private val db2: Sample2Database by lazy { Sample2Database.getInstance(requireContext()) } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentDemoRoomsDatabaseBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.insertUser.setOnClickListener { + val name = "User${Random().nextInt(range)}" + db.userDao().insert( + User( + 0, + name, + genders.random(), + ageRange.random(), + phoneNumberRange.random().toString(), + "$name@gmail.com", + true + ) + ) + } + + binding.insertAdmin.setOnClickListener { + val name = "Admin${Random().nextInt(range)}" + db.adminDao().insert( + Admin( + 0, + name, + isAdmin = true, + canWrite = false + ) + ) + } + + binding.insertUser2.setOnClickListener { + val name = "User${Random().nextInt(range)}" + db2.userDao().insert( + UserV2( + 0, + "2-$name", + genders.random(), + ageRange.random(), + phoneNumberRange.random().toString(), + "$name@yahoo.com", + false + ) + ) + } + } +} diff --git a/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/SampleDatabase.kt b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/SampleDatabase.kt new file mode 100644 index 000000000..3842a642d --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/SampleDatabase.kt @@ -0,0 +1,42 @@ +package com.sampleapp.functions.roomsdatabase.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.sampleapp.functions.roomsdatabase.db.dao.AdminDAO +import com.sampleapp.functions.roomsdatabase.db.dao.UserDAO +import com.sampleapp.functions.roomsdatabase.db.entity.Admin +import com.sampleapp.functions.roomsdatabase.db.entity.User + +@Database(entities = [User::class, Admin::class], version = 5) +abstract class SampleDatabase : RoomDatabase() { + + internal abstract fun userDao(): UserDAO + internal abstract fun adminDao(): AdminDAO + + companion object { + const val DB_NAME = "sample_database" + private val lock = Any() + private var INSTANCE: SampleDatabase? = null + + fun getInstance(context: Context): SampleDatabase { + synchronized(lock) { + if (INSTANCE == null) { + INSTANCE = createDB(context) + } + return INSTANCE as SampleDatabase + } + } + + private fun createDB(context: Context): SampleDatabase { + val database: Builder = + Room.databaseBuilder(context, SampleDatabase::class.java, DB_NAME) + return database + .addMigrations() + .fallbackToDestructiveMigration() + .allowMainThreadQueries() + .build() + } + } +} diff --git a/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/dao/AdminDAO.kt b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/dao/AdminDAO.kt new file mode 100644 index 000000000..2187323e3 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/dao/AdminDAO.kt @@ -0,0 +1,16 @@ +package com.sampleapp.functions.roomsdatabase.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.sampleapp.functions.roomsdatabase.db.entity.Admin + +@Dao +internal interface AdminDAO { + + @Insert + fun insert(user: Admin) + + @Query("DELETE FROM Admin") + fun clear() +} diff --git a/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/dao/UserDAO.kt b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/dao/UserDAO.kt new file mode 100644 index 000000000..602e8a225 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/dao/UserDAO.kt @@ -0,0 +1,16 @@ +package com.sampleapp.functions.roomsdatabase.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.sampleapp.functions.roomsdatabase.db.entity.User + +@Dao +internal interface UserDAO { + + @Insert + fun insert(user: User) + + @Query("DELETE FROM User") + fun clear() +} diff --git a/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/entity/Admin.kt b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/entity/Admin.kt new file mode 100644 index 000000000..20dc74253 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/entity/Admin.kt @@ -0,0 +1,16 @@ +package com.sampleapp.functions.roomsdatabase.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +internal data class Admin( + @PrimaryKey(autoGenerate = true) + val id: Int, + val name: String? = null, + @ColumnInfo(name = "is_admin") + val isAdmin: Boolean, + @ColumnInfo(name = "can_write") + val canWrite: Boolean +) diff --git a/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/entity/User.kt b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/entity/User.kt new file mode 100644 index 000000000..d5973f711 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db/entity/User.kt @@ -0,0 +1,19 @@ +package com.sampleapp.functions.roomsdatabase.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +internal data class User( + @PrimaryKey(autoGenerate = true) + val id: Int, + val name: String? = null, + val gender: String, + @ColumnInfo(defaultValue = "21") + val age: Int, + val phoneNumber: String, + val email: String, + @ColumnInfo(name = "is_admin") + val isAdmin: Boolean +) diff --git a/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db2/Sample2Database.kt b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db2/Sample2Database.kt new file mode 100644 index 000000000..412ac0b4c --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db2/Sample2Database.kt @@ -0,0 +1,39 @@ +package com.sampleapp.functions.roomsdatabase.db2 + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.sampleapp.functions.roomsdatabase.db2.dao.UserV2DAO +import com.sampleapp.functions.roomsdatabase.db2.entity.UserV2 + +@Database(entities = [UserV2::class], version = 5) +abstract class Sample2Database : RoomDatabase() { + + internal abstract fun userDao(): UserV2DAO + + companion object { + const val DB_NAME = "sample_2_database" + private val lock = Any() + private var INSTANCE: Sample2Database? = null + + fun getInstance(context: Context): Sample2Database { + synchronized(lock) { + if (INSTANCE == null) { + INSTANCE = createDB(context) + } + return INSTANCE as Sample2Database + } + } + + private fun createDB(context: Context): Sample2Database { + val database: Builder = + Room.databaseBuilder(context, Sample2Database::class.java, DB_NAME) + return database + .addMigrations() + .fallbackToDestructiveMigration() + .allowMainThreadQueries() + .build() + } + } +} diff --git a/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db2/dao/UserV2DAO.kt b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db2/dao/UserV2DAO.kt new file mode 100644 index 000000000..4293b17f2 --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db2/dao/UserV2DAO.kt @@ -0,0 +1,16 @@ +package com.sampleapp.functions.roomsdatabase.db2.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.sampleapp.functions.roomsdatabase.db2.entity.UserV2 + +@Dao +internal interface UserV2DAO { + + @Insert + fun insert(user: UserV2) + + @Query("DELETE FROM UserV2") + fun clear() +} diff --git a/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db2/entity/UserV2.kt b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db2/entity/UserV2.kt new file mode 100644 index 000000000..cc7c5058a --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/roomsdatabase/db2/entity/UserV2.kt @@ -0,0 +1,19 @@ +package com.sampleapp.functions.roomsdatabase.db2.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +internal data class UserV2( + @PrimaryKey(autoGenerate = true) + val id: Int, + val name: String? = null, + val gender: String, + @ColumnInfo(defaultValue = "21") + val age: Int, + val phoneNumber: String, + val email: String, + @ColumnInfo(name = "is_admin") + val isAdmin: Boolean +) diff --git a/sample/src/main/java/com/sampleapp/functions/sharedpreferences/DemoSharedPrefFragment.kt b/sample/src/main/java/com/sampleapp/functions/sharedpreferences/DemoSharedPrefFragment.kt new file mode 100644 index 000000000..624dcb1de --- /dev/null +++ b/sample/src/main/java/com/sampleapp/functions/sharedpreferences/DemoSharedPrefFragment.kt @@ -0,0 +1,50 @@ +package com.sampleapp.functions.sharedpreferences + +import android.content.Context +import android.os.Bundle +import android.preference.PreferenceManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.sampleapp.R +import com.sampleapp.databinding.FragmentDemoSharedPrefBinding + +class DemoSharedPrefFragment : Fragment(R.layout.fragment_demo_shared_pref) { + private var _binding: FragmentDemoSharedPrefBinding? = null + private val binding + get() = _binding!! + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentDemoSharedPrefBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.button.setOnClickListener { resetSharedPreferences(requireContext()) } + } + + @SuppressWarnings("MagicNumber") + private fun resetSharedPreferences(context: Context) { + val defaultPref = PreferenceManager.getDefaultSharedPreferences(context) + val profileDataPref = context.getSharedPreferences("profile_data", Context.MODE_PRIVATE) + val settingsDataPref = context.getSharedPreferences("settings_data", Context.MODE_PRIVATE) + + defaultPref.edit().putInt("feed_sync_delay", 4000).apply() + + profileDataPref.edit().putString("user_name", "John Smith").apply() + profileDataPref.edit().putString("user_email", "john.smith@gmail.com").apply() + + settingsDataPref.edit().putInt("some-int", 123).apply() + settingsDataPref.edit().putLong("points_earned", 34L).apply() + settingsDataPref.edit().putFloat("float", 34.45f).apply() + settingsDataPref.edit().putBoolean("is_email_verified", true).apply() + settingsDataPref.edit().putStringSet("preference_list", hashSetOf("science", "business")).apply() + } +} diff --git a/sample/src/main/java/com/sampleapp/network/ApiService.kt b/sample/src/main/java/com/sampleapp/network/ApiService.kt deleted file mode 100644 index bc548bfdc..000000000 --- a/sample/src/main/java/com/sampleapp/network/ApiService.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.sampleapp.network - -import com.google.gson.JsonObject -import okhttp3.RequestBody -import retrofit2.http.Body -import retrofit2.http.Field -import retrofit2.http.FormUrlEncoded -import retrofit2.http.GET -import retrofit2.http.Headers -import retrofit2.http.POST - -interface ApiService { - - @FormUrlEncoded - @Headers("Authorization: Bearer e42ed4a6-f346-4f0b-ad49-02c66dcca91a") - @POST("post?scope=points,preferences") - suspend fun form(@Field("title") title: String, @Field("diff") diff: String): JsonObject - - @Headers("Authorization: Bearer e42ed4a6-f346-4f0b-ad49-02c66dcca91a") - @POST("post") - suspend fun post(@Body hashMapOf: Any): JsonObject - - @Headers("Authorization: Bearer e42ed4a6-f346-4f0b-ad49-02c66dcca91a") - @GET("get") - suspend fun get(): JsonObject - - @Headers( - "type: xml", - "x-app-theme: light", - "Authorization: Bearer e42ed4a6-f346-4f0b-ad49-02c66dcca91a" - ) - @POST("xml") - suspend fun xml(@Body hashMapOf: RequestBody): JsonObject -} diff --git a/sample/src/main/java/com/sampleapp/network/DataModels.kt b/sample/src/main/java/com/sampleapp/network/DataModels.kt deleted file mode 100644 index b4535de4b..000000000 --- a/sample/src/main/java/com/sampleapp/network/DataModels.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.sampleapp.network - -sealed class ResponseWrapper { - data class Success(val body: T) : ResponseWrapper() - data class Failure(val error: ErrorResponse, val errorString: String? = null) : - ResponseWrapper() -} - -data class ErrorResponse(val reason: String?, val error: String) - -data class StatusResponse(val status: Boolean) diff --git a/sample/src/main/java/com/sampleapp/network/Network.kt b/sample/src/main/java/com/sampleapp/network/Network.kt deleted file mode 100644 index 9250faf62..000000000 --- a/sample/src/main/java/com/sampleapp/network/Network.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.sampleapp.network - -import com.google.gson.GsonBuilder -import com.mocklets.pluto.PlutoInterceptor -import java.util.concurrent.TimeUnit -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory - -object Network { - - private const val READ_TIMEOUT = 30L - - private val retrofit: Retrofit by lazy { - Retrofit.Builder() - .baseUrl("https://api.mocklets.com/p68296/") - .addConverterFactory(GsonConverterFactory.create(GsonBuilder().create())) - .client(okHttpClient) - .build() - } - - private val okHttpClient: OkHttpClient = OkHttpClient.Builder() - .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) - .addInterceptors() - .build() - - fun getService(cls: Class): T { - return retrofit.create(cls) - } - - inline fun getService(): Lazy { - return lazy { - getService(T::class.java) - } - } -} - -private fun OkHttpClient.Builder.addInterceptors(): OkHttpClient.Builder { -// addInterceptor(GzipRequestInterceptor()) - addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) - addInterceptor(PlutoInterceptor()) - return this -} diff --git a/sample/src/main/java/com/sampleapp/network/NetworkCalls.kt b/sample/src/main/java/com/sampleapp/network/NetworkCalls.kt deleted file mode 100644 index 11163e57e..000000000 --- a/sample/src/main/java/com/sampleapp/network/NetworkCalls.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.sampleapp.network - -import com.google.gson.Gson -import com.mocklets.pluto.PlutoLog -import java.io.IOException -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import retrofit2.HttpException - -@Suppress("TooGenericExceptionCaught") -suspend fun enqueue( - dispatcher: CoroutineDispatcher = Dispatchers.IO, - apiCall: suspend () -> T -): ResponseWrapper { - - return withContext(dispatcher) { - try { - ResponseWrapper.Success(apiCall.invoke()) - } catch (throwable: Throwable) { - PlutoLog.e(tag = "network_error", message = "network failure", tr = throwable) - when (throwable) { - is IOException -> ResponseWrapper.Failure( - ErrorResponse( - "IO_Exception", throwable.message ?: DEFAULT_ERROR_MESSAGE - ) - ) - is HttpException -> ResponseWrapper.Failure(convertErrorBody(throwable)) - else -> ResponseWrapper.Failure( - ErrorResponse(CONVERSION_FAILURE, DEFAULT_ERROR_MESSAGE) - ) - } - } - } -} - -@Suppress("TooGenericExceptionCaught") -private fun convertErrorBody(throwable: HttpException): ErrorResponse { - val errorString = throwable.response()?.errorBody()?.string() - return if (!errorString.isNullOrEmpty()) { - try { - run { - val error = Gson().fromJson(errorString, ErrorResponse::class.java) - validateError(error) - error - } - } catch (exception: Exception) { - PlutoLog.e( - tag = "network_error", - message = exception.message.toString(), - tr = exception - ) - ErrorResponse(CONVERSION_FAILURE, DEFAULT_ERROR_MESSAGE) - } - } else { - ErrorResponse(UPSTREAM_FAILURE, EMPTY_ERROR_MESSAGE) - } -} - -private fun validateError(error: ErrorResponse) { - if (error.error == null) { // TODO handle deserialization issue - throw KotlinNullPointerException("response.error value null") - } -} - -private const val DEFAULT_ERROR_MESSAGE = "Something went wrong!" -private const val EMPTY_ERROR_MESSAGE = "empty error response" -private const val UPSTREAM_FAILURE = "upstream_failure" -private const val CONVERSION_FAILURE = "response_conversion_failure" diff --git a/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..afe614c64 --- /dev/null +++ b/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/drawable/ic_alert_bg.xml b/sample/src/main/res/drawable/ic_alert_bg.xml new file mode 100644 index 000000000..df2343999 --- /dev/null +++ b/sample/src/main/res/drawable/ic_alert_bg.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/drawable/ic_arrow.xml b/sample/src/main/res/drawable/ic_arrow.xml new file mode 100644 index 000000000..e5488961b --- /dev/null +++ b/sample/src/main/res/drawable/ic_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/sample/src/main/res/drawable/ic_arrow_white.xml b/sample/src/main/res/drawable/ic_arrow_white.xml new file mode 100644 index 000000000..87e0b04e9 --- /dev/null +++ b/sample/src/main/res/drawable/ic_arrow_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/sample/src/main/res/drawable/ic_chevron_right.xml b/sample/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 000000000..8814d77e6 --- /dev/null +++ b/sample/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/sample/src/main/res/drawable/ic_close.xml b/sample/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000..1ae1eb424 --- /dev/null +++ b/sample/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/sample/src/main/res/drawable/ic_launcher.xml b/sample/src/main/res/drawable/ic_launcher.xml deleted file mode 100644 index 07d5da9cb..000000000 --- a/sample/src/main/res/drawable/ic_launcher.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/sample/src/main/res/drawable/ic_launcher_background.xml b/sample/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..58bc635f4 --- /dev/null +++ b/sample/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/sample/src/main/res/layout/activity_demo_layout_inspector.xml b/sample/src/main/res/layout/activity_demo_layout_inspector.xml new file mode 100644 index 000000000..68266a85a --- /dev/null +++ b/sample/src/main/res/layout/activity_demo_layout_inspector.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml index e5fe910dc..e6f68b1cd 100644 --- a/sample/src/main/res/layout/activity_main.xml +++ b/sample/src/main/res/layout/activity_main.xml @@ -1,159 +1,253 @@ - - - - -