diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 2dbdf8ad3..fad819202 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,26 +4,26 @@ about: Create a report to help us improve --- -## :loudspeaker: Describe the bug -*A clear and concise description of what the bug is.* +## :writing_hand: Describe the bug + -## :bomb: To Reproduce -*Steps to reproduce the behavior:* +## :bomb: Steps to reproduce + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error -### :wrench: Expected behavior -*A clear and concise description of what you expected to happen.* +## :wrench: Expected behavior + ## :camera: Screenshots -*If applicable, add screenshots to help explain your problem.* + -## :iphone: Smartphone - - Device: *e.g. Nexus 6P* - - OS: *e.g. 7.1.1* - - Lib version: *e.g. 2.0.2* +## :iphone: Tech info + - Device: + - OS: + - Chucker version: ## :page_facing_up: Additional context -*Add any other context about the problem here.* + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 2d5890fec..54c1c0977 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -4,18 +4,19 @@ about: Suggest an idea for this project --- -## :warning: Is your feature request related to a problem? Please describe. -*A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]* +## :warning: Is your feature request related to a problem? Please describe + -## :dart: Describe the solution you'd like -*A clear and concise description of what you want to happen.* +## :bulb: Describe the solution you'd like + ## :bar_chart: Describe alternatives you've considered -*A clear and concise description of any alternative solutions or features you've considered.* + ## :page_facing_up: Additional context -*Add any other context or screenshots about the feature request here.* + -## :pencil: Do you want to develop this feature yourself? +## :raising_hand: Do you want to develop this feature yourself? + - [ ] Yes -- [X] No \ No newline at end of file +- [ ] No diff --git a/.github/PULL_REQUEST_TEMPLATE b/.github/PULL_REQUEST_TEMPLATE index d20dc6ac8..e7998a629 100644 --- a/.github/PULL_REQUEST_TEMPLATE +++ b/.github/PULL_REQUEST_TEMPLATE @@ -1,20 +1,20 @@ ## :camera: Screenshots -*Show us what you've changed, we love images.* + ## :page_facing_up: Context -*Why did you change something? Is there an [issue](https://github.com/ChuckerTeam/chucker/issues) to link here? Or an extarnal link?* + ## :pencil: Changes -*Which code did you change? How?* + ## :paperclip: Related PR -*PR that blocks this one, or the ones blocked by this PR* + -## :warning: Breaking -*Is there something breaking in the API? Any class or method signature changed?* +## :no_entry_sign: Breaking + -## :pencil: How to test -*Is there a special case to test your changes?* +## :hammer_and_wrench: How to test + -## :crystal_ball: Next steps -*Do we have to plan something else after the merge?* +## :stopwatch: Next steps + diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml new file mode 100644 index 000000000..bf3a2ba50 --- /dev/null +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -0,0 +1,20 @@ +name: Validate Gradle Wrapper +on: + push: + branches: + - develop + - release + pull_request: + branches: + - '*' + +jobs: + validation: + name: Validation + runs-on: ubuntu-latest + steps: + - name: Checkout latest code + uses: actions/checkout@v2 + - name: Validate Gradle Wrapper + uses: gradle/wrapper-validation-action@v1 + diff --git a/.github/workflows/pre-merge.yaml b/.github/workflows/pre-merge.yaml new file mode 100644 index 000000000..70d5159ab --- /dev/null +++ b/.github/workflows/pre-merge.yaml @@ -0,0 +1,18 @@ +name: Pre Merge Checks +on: + push: + branches: + - develop + - release + pull_request: + branches: + - '*' + +jobs: + gradle: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v2 + - name: Run Gradle tasks + run: ./gradlew clean ktlintCheck lint detekt build test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7224dde1a..000000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -language: android -sudo: required -dist: precise -jdk: oraclejdk8 -os: - - linux -env: - global: - - ANDROID_API_LEVEL=28 - - ANDROID_BUILD_TOOLS_VERSION=28.0.3 - - ANDROID_ABI=armeabi-v7a - - ANDROID_TAG=google_apis - -android: - components: - - tools - - platform-tools - - build-tools-$ANDROID_BUILD_TOOLS_VERSION - - android-$ANDROID_API_LEVEL - - extra-android-support - - extra-android-m2repository - - extra-google-m2repository - -script: - - ./gradlew clean ktlintCheck lint detekt build test diff --git a/CHANGELOG.md b/CHANGELOG.md index 55eb83a0e..e67abd05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Change Log +## Version 3.2.0 *(2020-04-04)* + +This is a new minor release with numerous internal changes. + +### Summary of changes + +* Chucker won't load the whole response into memory anymore, but will mutlicast it with the help of temporary files. It allows to avoid issues with OOM, like in reported in [#218]. +This change also allows to avoid problems with Chucker consuming responses, like reported in [#242]. +* Added a red open padlock icon to clearly indicate HTTP requests in transactions list. +* Added TLS info (version and cipher suite) into `Overview` tab. +* Added ability to encode/decode URLs. +* Added RTL support. +* Switched from AsyncTasks to Kotlin coroutines. +* Switched to [ViewBinding](https://developer.android.com/topic/libraries/view-binding). +* Bumped targetSDK to 29. +* Greatly increased test coverage (we will add exact numbers and reports pretty soon). + +### Bugfixes + +* Fix for [#218] with OOM exceptions on big responses. +* Fix for [#242] with Chucker throwing exceptions when used as `networkInterceptor()`. +* Fix for [#240] with HttpHeader serialisation exceptions when obfuscation is used. +* Fix for [#254] with response body search being case-sensitive. +* Fix for [#255] with missing search icon on Response tab. +* Fix for [#241] with overlapping texts. + +### Dependency updates + +* Added kotlinx-coroutines-core 1.3.5 +* Added kotlinx-coroutines-android 1.3.5 +* Updated Kotlin to 1.3.71 +* Updated Android Gradle plugin to 3.6.1 +* Updated Room to 2.2.5 +* Updated OkHttp to 3.12.10 +* Updated Detekt to 1.7.3 +* Updated Dokka to 0.10.1 +* Updated KtLint plugin to 9.2.1 +* Updated MaterialComponents to 1.1.0 +* Updated Gradle to 6.3 + +### Credits + +This release was possible thanks to the contribution of: + +@adammasyk +@cortinico +@CuriousNikhil +@hitanshu-dhawan +@MiSikora +@technoir42 +@vbuberen + ## Version 3.1.2 *(2020-02-09)* This is hot-fix release to fix multiple issues introduced in `3.1.0`. @@ -16,6 +68,13 @@ This is hot-fix release to fix multiple issues introduced in `3.1.0`. * Fixed an [issue](https://github.com/ChuckerTeam/chucker/pull/222) with crash when user taps Clear from notification shade while the original app is already dead. * Fixed an [issue](https://github.com/ChuckerTeam/chucker/pull/223) with possible NPEs. +### Credits + +This release was possible thanks to the contribution of: + +@MiSikora +@vbuberen + ## Version 3.1.1 *(2020-01-25)* This is hot-fix release to fix issue introduced in `3.1.0`. @@ -24,6 +83,12 @@ This is hot-fix release to fix issue introduced in `3.1.0`. - Fixed an [issue](https://github.com/ChuckerTeam/chucker/issues/203) introduced in 3.1.0 where some of response bodies were shown as `null` and their sizes were 0 bytes. +### Credits + +This release was possible thanks to the contribution of: + +@cortinico + ## Version 3.1.0 *(2020-01-24)* ### This version shouldn't be used as dependency due to [#203](https://github.com/ChuckerTeam/chucker/issues/203). Use 3.1.1 instead. @@ -297,3 +362,9 @@ Initial release. [#196]: https://github.com/ChuckerTeam/chucker/pull/196 [#198]: https://github.com/ChuckerTeam/chucker/pull/198 [#201]: https://github.com/ChuckerTeam/chucker/pull/201 +[#218]: https://github.com/ChuckerTeam/chucker/issues/218 +[#242]: https://github.com/ChuckerTeam/chucker/issues/242 +[#240]: https://github.com/ChuckerTeam/chucker/pull/240 +[#254]: https://github.com/ChuckerTeam/chucker/issues/254 +[#255]: https://github.com/ChuckerTeam/chucker/issues/255 +[#241]: https://github.com/ChuckerTeam/chucker/issues/241 diff --git a/README.md b/README.md index 170e1e350..3e7d9a709 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Chucker -[![JitPack](https://jitpack.io/v/ChuckerTeam/Chucker.svg)](https://jitpack.io/#ChuckerTeam/Chucker) [![Build Status](https://travis-ci.org/ChuckerTeam/chucker.svg?branch=master)](https://travis-ci.org/ChuckerTeam/chucker) ![License](https://img.shields.io/github/license/ChuckerTeam/Chucker.svg) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-orange.svg)](http://makeapullrequest.com) [![Join the chat at https://kotlinlang.slack.com](https://img.shields.io/badge/slack-@kotlinlang/chucker-yellow.svg?logo=slack)](https://kotlinlang.slack.com/archives/CRWD6370R) [![Android Weekly](https://img.shields.io/badge/Android%20Weekly-%23375-blue.svg)](https://androidweekly.net/issues/issue-375) +[![JitPack](https://jitpack.io/v/ChuckerTeam/Chucker.svg)](https://jitpack.io/#ChuckerTeam/Chucker) ![Pre Merge Checks](https://github.com/ChuckerTeam/chucker/workflows/Pre%20Merge%20Checks/badge.svg?branch=develop) ![License](https://img.shields.io/github/license/ChuckerTeam/Chucker.svg) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-orange.svg)](http://makeapullrequest.com) [![Join the chat at https://kotlinlang.slack.com](https://img.shields.io/badge/slack-@kotlinlang/chucker-yellow.svg?logo=slack)](https://kotlinlang.slack.com/archives/CRWD6370R) [![Android Weekly](https://img.shields.io/badge/Android%20Weekly-%23375-blue.svg)](https://androidweekly.net/issues/issue-375) _A fork of [Chuck](https://github.com/jgilfelt/chuck)_ @@ -20,7 +20,7 @@ _A fork of [Chuck](https://github.com/jgilfelt/chuck)_ * [Acknowledgments](#acknowledgments-) * [License](#license-) -Chucker simplifies the inspection of **HTTP(S) requests/responses**, and **Throwables** fired by your Android App. Chucker works as a **OkHttp Interceptor** persisting all those events inside your application, and providing a UI for inspecting and sharing their content. +Chucker simplifies the inspection of **HTTP(S) requests/responses**, and **Throwables** fired by your Android App. Chucker works as an **OkHttp Interceptor** persisting all those events inside your application, and providing a UI for inspecting and sharing their content. Apps using Chucker will display a **push notification** showing a summary of ongoing HTTP activity and Throwables. Tapping on the notification launches the full Chucker UI. Apps can optionally suppress the notification, and launch the Chucker UI directly from within their own interface. @@ -42,8 +42,8 @@ repositories { ```groovy dependencies { - debugImplementation "com.github.ChuckerTeam.Chucker:library:3.1.2" - releaseImplementation "com.github.ChuckerTeam.Chucker:library-no-op:3.1.2" + debugImplementation "com.github.ChuckerTeam.Chucker:library:3.2.0" + releaseImplementation "com.github.ChuckerTeam.Chucker:library-no-op:3.2.0" } ``` @@ -119,7 +119,7 @@ try { ### Redact-Header 👮‍♂️ -**Warning** The data generated and stored when using Chucker may contain sensitive information such as Authorization or Cookie headers, and the contents of request and response bodies. +**Warning** The data generated and stored when using Chucker may contain sensitive information such as Authorization or Cookie headers, and the contents of request and response bodies. It is intended for **use during development**, and not in release builds or other production deployments. @@ -163,13 +163,13 @@ If you're looking for the **latest stable version**, you can always find it on t * Why are retries and redirects not being captured discretely? * Why are my encoded request/response bodies not appearing as plain text? -Please refer to [this section of the OkHttp wiki](https://github.com/square/okhttp/wiki/Interceptors#choosing-between-application-and-network-interceptors). You can choose to use Chucker as either an application or network interceptor, depending on your requirements. +Please refer to [this section of the OkHttp documentation](https://square.github.io/okhttp/interceptors/). You can choose to use Chucker as either an application or network interceptor, depending on your requirements. ## Contributing 🤝 -We're offering support for Chucker on the [#chucker](https://kotlinlang.slack.com/archives/CRWD6370R) channel on [kotlinlang.slack.com](https://kotlinlang.slack.com/). Come and joing the conversation over there. +We're offering support for Chucker on the [#chucker](https://kotlinlang.slack.com/archives/CRWD6370R) channel on [kotlinlang.slack.com](https://kotlinlang.slack.com/). Come and join the conversation over there. -**We're looking for contributors! Don't be shy.** 😁 Feel free to open issues/pull requests to help me improve this project. +**We're looking for contributors! Don't be shy.** 😁 Feel free to open issues/pull requests to help us improve this project. * When reporting a new Issue, make sure to attach **Screenshots**, **Videos** or **GIFs** of the problem you are reporting. * When submitting a new PR, make sure tests are all green. Write new tests if necessary. diff --git a/build.gradle b/build.gradle index c23cc5415..d82936f79 100644 --- a/build.gradle +++ b/build.gradle @@ -1,33 +1,37 @@ buildscript { ext { - kotlinVersion = '1.3.61' - androidGradleVersion = '3.5.3' + kotlinVersion = '1.3.71' + androidGradleVersion = '3.6.1' + coroutineVersion = '1.3.5' // Google libraries appCompatVersion = '1.1.0' constraintLayoutVersion = '1.1.3' - materialComponentsVersion = '1.1.0-rc02' - roomVersion = '2.2.3' + materialComponentsVersion = '1.1.0' + roomVersion = '2.2.5' lifecycleVersion = '2.2.0' + androidXCoreVersion = '2.1.0' // Publishing androidMavenGradleVersion = '2.1' // Networking gsonVersion = '2.8.6' - okhttp3Version = '3.12.6' + okhttp3Version = '3.12.10' retrofitVersion = '2.6.4' // Debug and quality control - detektVersion = '1.4.0' - dokkaVersion = '0.10.0' - ktLintVersion = '9.1.1' - leakcanaryVersion = '2.1' + detektVersion = '1.7.4' + dokkaVersion = '0.10.1' + ktLintVersion = '0.36.0' + ktLintGradleVersion = '9.2.1' + leakcanaryVersion = '2.2' // Testing - junitGradlePluignVersion = '1.3.1.1' - junitVersion = '5.4.2' + junitGradlePluignVersion = '1.6.0.0' + junitVersion = '5.6.0' mockkVersion = '1.9.3' + truthVersion = '1.0.1' } repositories { @@ -43,7 +47,7 @@ buildscript { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath "org.jetbrains.dokka:dokka-gradle-plugin:$dokkaVersion" classpath "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:$detektVersion" - classpath "org.jlleitschuh.gradle:ktlint-gradle:$ktLintVersion" + classpath "org.jlleitschuh.gradle:ktlint-gradle:$ktLintGradleVersion" } } @@ -63,6 +67,6 @@ task clean(type: Delete) { ext { minSdkVersion = 16 - targetSdkVersion = 28 - compileSdkVersion = 28 + targetSdkVersion = 29 + compileSdkVersion = 29 } diff --git a/gradle.properties b/gradle.properties index b5e75b03b..e0cd781b9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,9 +16,11 @@ org.gradle.jvmargs=-Xmx1536m # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects org.gradle.parallel=true -VERSION_NAME=3.1.2 -# 3*100*100 + 1*100 + 2 => 30102 -VERSION_CODE=30102 +android.useAndroidX=true + +VERSION_NAME=3.2.0 +# 3*100*100 + 2*100 + 0 => 30200 +VERSION_CODE=30200 GROUP=com.github.chuckerteam.chucker POM_REPO_NAME=Chucker diff --git a/gradle/kotlin-static-analysis.gradle b/gradle/kotlin-static-analysis.gradle index 18b70fb3a..f75383206 100644 --- a/gradle/kotlin-static-analysis.gradle +++ b/gradle/kotlin-static-analysis.gradle @@ -2,7 +2,7 @@ apply plugin: 'io.gitlab.arturbosch.detekt' apply plugin: 'org.jlleitschuh.gradle.ktlint' ktlint { - version = "0.36.0" + version = rootProject.ext.ktLintVersion debug = false verbose = true android = false @@ -19,7 +19,6 @@ ktlint { } detekt { - toolVersion = "1.4.0" config = files("../detekt-config.yml") buildUponDefaultConfig = true } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 87b738cbd..f3d88b1c2 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 31a0802f3..6623300be 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index af6708ff2..2fe81a7d9 100755 --- a/gradlew +++ b/gradlew @@ -1,5 +1,21 @@ #!/usr/bin/env sh +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + ############################################################################## ## ## Gradle start up script for UN*X @@ -28,7 +44,7 @@ APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m"' +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" @@ -109,8 +125,8 @@ if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` @@ -138,19 +154,19 @@ if $cygwin ; then else eval `echo args$i`="\"$arg\"" fi - i=$((i+1)) + i=`expr $i + 1` done case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi @@ -159,14 +175,9 @@ save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } -APP_ARGS=$(save "$@") +APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 0f8d5937c..24467a141 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,3 +1,19 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @@ -14,7 +30,7 @@ set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome diff --git a/library/build.gradle b/library/build.gradle index 67854cc8c..2911f7875 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -21,6 +21,10 @@ android { kotlinOptions { jvmTarget = "1.8" } + + viewBinding { + enabled = true + } } dokka { @@ -64,9 +68,13 @@ dependencies { implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" + implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-runtime:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion" + implementation "com.google.code.gson:gson:$gsonVersion" implementation "com.squareup.okhttp3:okhttp:$okhttp3Version" @@ -75,6 +83,8 @@ dependencies { testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion" testImplementation "io.mockk:mockk:$mockkVersion" testImplementation "com.squareup.okhttp3:mockwebserver:$okhttp3Version" + testImplementation "androidx.arch.core:core-testing:$androidXCoreVersion" + testImplementation "com.google.truth:truth:$truthVersion" } apply from: rootProject.file('gradle/gradle-mvn-push.gradle') diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml index c37e83aa2..b7c72c9b0 100644 --- a/library/src/main/AndroidManifest.xml +++ b/library/src/main/AndroidManifest.xml @@ -8,10 +8,12 @@ android:launchMode="singleTask" android:taskAffinity="com.chuckerteam.chucker.task" android:theme="@style/Chucker.Theme" /> + - diff --git a/library/src/main/java/com/chuckerteam/chucker/api/ChuckerCollector.kt b/library/src/main/java/com/chuckerteam/chucker/api/ChuckerCollector.kt index 293802010..1c304d63b 100644 --- a/library/src/main/java/com/chuckerteam/chucker/api/ChuckerCollector.kt +++ b/library/src/main/java/com/chuckerteam/chucker/api/ChuckerCollector.kt @@ -5,6 +5,9 @@ import com.chuckerteam.chucker.internal.data.entity.HttpTransaction import com.chuckerteam.chucker.internal.data.entity.RecordedThrowable import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider import com.chuckerteam.chucker.internal.support.NotificationHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * The collector responsible of collecting data from a [ChuckerInterceptor] and @@ -14,7 +17,7 @@ import com.chuckerteam.chucker.internal.support.NotificationHelper * @param context An Android Context * @param showNotification Control whether a notification is shown while HTTP activity * is recorded. - * @param retentionManager Set the retention period for HTTP transaction data captured + * @param retentionPeriod Set the retention period for HTTP transaction data captured * by this collector. The default is one week. */ class ChuckerCollector @JvmOverloads constructor( @@ -36,7 +39,9 @@ class ChuckerCollector @JvmOverloads constructor( */ fun onError(tag: String, throwable: Throwable) { val recordedThrowable = RecordedThrowable(tag, throwable) - RepositoryProvider.throwable().saveThrowable(recordedThrowable) + CoroutineScope(Dispatchers.IO).launch { + RepositoryProvider.throwable().saveThrowable(recordedThrowable) + } if (showNotification) { notificationHelper.show(recordedThrowable) } @@ -48,7 +53,9 @@ class ChuckerCollector @JvmOverloads constructor( * @param transaction The HTTP transaction sent */ internal fun onRequestSent(transaction: HttpTransaction) { - RepositoryProvider.transaction().insertTransaction(transaction) + CoroutineScope(Dispatchers.IO).launch { + RepositoryProvider.transaction().insertTransaction(transaction) + } if (showNotification) { notificationHelper.show(transaction) } diff --git a/library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt b/library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt index a466e43b3..24f41b441 100755 --- a/library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt +++ b/library/src/main/java/com/chuckerteam/chucker/api/ChuckerInterceptor.kt @@ -2,10 +2,15 @@ package com.chuckerteam.chucker.api import android.content.Context import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.support.AndroidCacheFileFactory +import com.chuckerteam.chucker.internal.support.FileFactory import com.chuckerteam.chucker.internal.support.IOUtils -import com.chuckerteam.chucker.internal.support.contentLenght +import com.chuckerteam.chucker.internal.support.TeeSource +import com.chuckerteam.chucker.internal.support.contentLength import com.chuckerteam.chucker.internal.support.contentType +import com.chuckerteam.chucker.internal.support.hasBody import com.chuckerteam.chucker.internal.support.isGzipped +import java.io.File import java.io.IOException import java.nio.charset.Charset import okhttp3.Headers @@ -15,8 +20,7 @@ import okhttp3.Response import okhttp3.ResponseBody import okio.Buffer import okio.GzipSource - -private const val MAX_BLOB_SIZE = 1000_000L +import okio.Okio /** * An OkHttp Interceptor which persists and displays HTTP activity @@ -27,16 +31,39 @@ private const val MAX_BLOB_SIZE = 1000_000L * @param maxContentLength The maximum length for request and response content * before their truncation. Warning: setting this value too high may cause unexpected * results. + * @param fileFactory Provider for [File]s where Chucker will save temporary responses before + * processing them. * @param headersToRedact a [Set] of headers you want to redact. They will be replaced * with a `**` in the Chucker UI. */ -class ChuckerInterceptor @JvmOverloads constructor( +class ChuckerInterceptor internal constructor( private val context: Context, private val collector: ChuckerCollector = ChuckerCollector(context), private val maxContentLength: Long = 250000L, + private val fileFactory: FileFactory, headersToRedact: Set = emptySet() ) : Interceptor { + /** + * An OkHttp Interceptor which persists and displays HTTP activity + * in your application for later inspection. + * + * @param context An Android [Context] + * @param collector A [ChuckerCollector] to customize data retention + * @param maxContentLength The maximum length for request and response content + * before their truncation. Warning: setting this value too high may cause unexpected + * results. + * @param headersToRedact a [Set] of headers you want to redact. They will be replaced + * with a `**` in the Chucker UI. + */ + @JvmOverloads + constructor( + context: Context, + collector: ChuckerCollector = ChuckerCollector(context), + maxContentLength: Long = 250000L, + headersToRedact: Set = emptySet() + ) : this(context, collector, maxContentLength, AndroidCacheFileFactory(context), headersToRedact) + private val io: IOUtils = IOUtils(context) private val headersToRedact: MutableSet = headersToRedact.toMutableSet() @@ -62,10 +89,8 @@ class ChuckerInterceptor @JvmOverloads constructor( throw e } - val processedResponse = processResponse(response, transaction) - collector.onResponseReceived(transaction) - - return processedResponse + processResponseMetadata(response, transaction) + return multiCastResponseBody(response, transaction) } /** @@ -106,9 +131,12 @@ class ChuckerInterceptor @JvmOverloads constructor( } /** - * Processes a [Response] and populates corresponding fields of a [HttpTransaction]. + * Processes [Response] metadata and populates corresponding fields of a [HttpTransaction]. */ - private fun processResponse(response: Response, transaction: HttpTransaction): Response { + private fun processResponseMetadata( + response: Response, + transaction: HttpTransaction + ) { val responseEncodingIsSupported = io.bodyHasSupportedEncoding(response.headers().get(CONTENT_ENCODING)) transaction.apply { @@ -123,40 +151,62 @@ class ChuckerInterceptor @JvmOverloads constructor( responseCode = response.code() responseMessage = response.message() + response.handshake()?.let { handshake -> + responseTlsVersion = handshake.tlsVersion().javaName() + responseCipherSuite = handshake.cipherSuite().javaName() + } + responseContentType = response.contentType - responseContentLength = response.contentLenght + responseContentLength = response.contentLength tookMs = (response.receivedResponseAtMillis() - response.sentRequestAtMillis()) } - - return if (responseEncodingIsSupported) { - processResponseBody(response, transaction) - } else { - response - } } /** - * Processes a [ResponseBody] and populates corresponding fields of a [HttpTransaction]. + * Multi casts a [Response] body if it is available and downstreams it to a file which will + * be available for Chucker to consume and save in the [transaction] at some point in the future + * when the end user reads bytes form the [response]. */ - private fun processResponseBody(response: Response, transaction: HttpTransaction): Response { - val responseBody = response.body() ?: return response + private fun multiCastResponseBody( + response: Response, + transaction: HttpTransaction + ): Response { + val responseBody = response.body() + if (!response.hasBody() || responseBody == null) { + collector.onResponseReceived(transaction) + return response + } val contentType = responseBody.contentType() - val charset = contentType?.charset(UTF8) ?: UTF8 val contentLength = responseBody.contentLength() - val responseSource = if (response.isGzipped) { - GzipSource(responseBody.source()) - } else { - responseBody.source() - } - val buffer = Buffer().apply { responseSource.use { writeAll(it) } } + val teeSource = TeeSource( + responseBody.source(), + fileFactory.create(), + ChuckerTransactionTeeCallback(response, transaction), + maxContentLength + ) + + return response.newBuilder() + .body(ResponseBody.create(contentType, contentLength, Okio.buffer(teeSource))) + .build() + } - if (io.isPlaintext(buffer)) { + private fun processResponseBody( + response: Response, + responseBodyBuffer: Buffer, + transaction: HttpTransaction + ) { + val responseBody = response.body() ?: return + + val contentType = responseBody.contentType() + val charset = contentType?.charset(UTF8) ?: UTF8 + + if (io.isPlaintext(responseBodyBuffer)) { transaction.isResponseBodyPlainText = true - if (contentLength != 0L) { - transaction.responseBody = buffer.clone().readString(charset) + if (responseBodyBuffer.size() != 0L) { + transaction.responseBody = responseBodyBuffer.readString(charset) } } else { transaction.isResponseBodyPlainText = false @@ -164,14 +214,10 @@ class ChuckerInterceptor @JvmOverloads constructor( val isImageContentType = (contentType?.toString()?.contains(CONTENT_TYPE_IMAGE, ignoreCase = true) == true) - if (isImageContentType && buffer.size() < MAX_BLOB_SIZE) { - transaction.responseImageData = buffer.clone().readByteArray() + if (isImageContentType && (responseBodyBuffer.size() < MAX_BLOB_SIZE)) { + transaction.responseImageData = responseBodyBuffer.readByteArray() } } - - return response.newBuilder() - .body(ResponseBody.create(contentType, contentLength, buffer)) - .build() } /** Overrides all headers from [headersToRedact] with `**` */ @@ -185,6 +231,37 @@ class ChuckerInterceptor @JvmOverloads constructor( return builder.build() } + private inner class ChuckerTransactionTeeCallback( + private val response: Response, + private val transaction: HttpTransaction + ) : TeeSource.Callback { + override fun onSuccess(file: File) { + val buffer = readResponseBuffer(file, response.isGzipped) + file.delete() + if (buffer != null) processResponseBody(response, buffer, transaction) + collector.onResponseReceived(transaction) + } + + override fun onFailure(exception: IOException, file: File) { + file.delete() + collector.onResponseReceived(transaction) + } + + private fun readResponseBuffer(responseBody: File, isGzipped: Boolean): Buffer? { + val bufferedSource = Okio.buffer(Okio.source(responseBody)) + val source = if (isGzipped) { + GzipSource(bufferedSource) + } else { + bufferedSource + } + return try { + Buffer().apply { writeAll(source) } + } catch (_: IOException) { + null + } + } + } + companion object { private val UTF8 = Charset.forName("UTF-8") diff --git a/library/src/main/java/com/chuckerteam/chucker/api/RetentionManager.kt b/library/src/main/java/com/chuckerteam/chucker/api/RetentionManager.kt index ef0436de8..c3e26503e 100644 --- a/library/src/main/java/com/chuckerteam/chucker/api/RetentionManager.kt +++ b/library/src/main/java/com/chuckerteam/chucker/api/RetentionManager.kt @@ -6,6 +6,9 @@ import android.util.Log import com.chuckerteam.chucker.api.Chucker.LOG_TAG import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * Class responsible of holding the logic for the retention of your HTTP transactions @@ -62,8 +65,10 @@ class RetentionManager @JvmOverloads constructor( } private fun deleteSince(threshold: Long) { - RepositoryProvider.transaction().deleteOldTransactions(threshold) - RepositoryProvider.throwable().deleteOldThrowables(threshold) + CoroutineScope(Dispatchers.IO).launch { + RepositoryProvider.transaction().deleteOldTransactions(threshold) + RepositoryProvider.throwable().deleteOldThrowables(threshold) + } } private fun isCleanupDue(now: Long) = now - getLastCleanup(now) > cleanupFrequency diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpHeader.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpHeader.kt index eb4258388..bbd041b17 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpHeader.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpHeader.kt @@ -1,3 +1,8 @@ package com.chuckerteam.chucker.internal.data.entity -internal data class HttpHeader(val name: String, val value: String) +import com.google.gson.annotations.SerializedName + +internal data class HttpHeader( + @SerializedName("name") val name: String, + @SerializedName("value") val value: String +) diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransaction.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransaction.kt index b263af001..49c848605 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransaction.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransaction.kt @@ -9,6 +9,7 @@ import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey import com.chuckerteam.chucker.internal.support.FormatUtils +import com.chuckerteam.chucker.internal.support.FormattedUrl import com.chuckerteam.chucker.internal.support.JsonConverter import com.google.gson.reflect.TypeToken import java.util.Date @@ -19,6 +20,7 @@ import okhttp3.HttpUrl * Represent a full HTTP transaction (with Request and Response). Instances of this classes * should be populated as soon as the library receives data from OkHttp. */ +@Suppress("LongParameterList") @Entity(tableName = "transactions") internal class HttpTransaction( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") @@ -32,6 +34,8 @@ internal class HttpTransaction( @ColumnInfo(name = "host") var host: String?, @ColumnInfo(name = "path") var path: String?, @ColumnInfo(name = "scheme") var scheme: String?, + @ColumnInfo(name = "responseTlsVersion") var responseTlsVersion: String?, + @ColumnInfo(name = "responseCipherSuite") var responseCipherSuite: String?, @ColumnInfo(name = "requestContentLength") var requestContentLength: Long?, @ColumnInfo(name = "requestContentType") var requestContentType: String?, @ColumnInfo(name = "requestHeaders") var requestHeaders: String?, @@ -46,7 +50,6 @@ internal class HttpTransaction( @ColumnInfo(name = "responseBody") var responseBody: String?, @ColumnInfo(name = "isResponseBodyPlainText") var isResponseBodyPlainText: Boolean = true, @ColumnInfo(name = "responseImageData") var responseImageData: ByteArray? - ) { @Ignore @@ -60,6 +63,8 @@ internal class HttpTransaction( host = null, path = null, scheme = null, + responseTlsVersion = null, + responseCipherSuite = null, requestContentLength = null, requestContentType = null, requestHeaders = null, @@ -207,11 +212,57 @@ internal class HttpTransaction( return responseBody?.let { formatBody(it, responseContentType) } ?: "" } - fun populateUrl(url: HttpUrl): HttpTransaction { - this.url = url.toString() - host = url.host() - path = ("/${url.pathSegments().joinToString("/")}${url.query()?.let { "?$it" } ?: ""}") - scheme = url.scheme() + fun populateUrl(httpUrl: HttpUrl): HttpTransaction { + val formattedUrl = FormattedUrl.fromHttpUrl(httpUrl, encoded = false) + url = formattedUrl.url + host = formattedUrl.host + path = formattedUrl.pathWithQuery + scheme = formattedUrl.scheme return this } + + fun getFormattedUrl(encode: Boolean): String { + val httpUrl = url?.let(HttpUrl::get) ?: return "" + return FormattedUrl.fromHttpUrl(httpUrl, encode).url + } + + fun getFormattedPath(encode: Boolean): String { + val httpUrl = url?.let(HttpUrl::get) ?: return "" + return FormattedUrl.fromHttpUrl(httpUrl, encode).pathWithQuery + } + + // Not relying on 'equals' because comparison be long due to request and response sizes + // and it would be unwise to do this every time 'equals' is called. + @Suppress("ComplexMethod") + fun hasTheSameContent(other: HttpTransaction?): Boolean { + if (this === other) return true + if (other == null) return false + + return (id == other.id) && + (requestDate == other.requestDate) && + (responseDate == other.responseDate) && + (tookMs == other.tookMs) && + (protocol == other.protocol) && + (method == other.method) && + (url == other.url) && + (host == other.host) && + (path == other.path) && + (scheme == other.scheme) && + (responseTlsVersion == other.responseTlsVersion) && + (responseCipherSuite == other.responseCipherSuite) && + (requestContentLength == other.requestContentLength) && + (requestContentType == other.requestContentType) && + (requestHeaders == other.requestHeaders) && + (requestBody == other.requestBody) && + (isRequestBodyPlainText == other.isRequestBodyPlainText) && + (responseCode == other.responseCode) && + (responseMessage == other.responseMessage) && + (error == other.error) && + (responseContentLength == other.responseContentLength) && + (responseContentType == other.responseContentType) && + (responseHeaders == other.responseHeaders) && + (responseBody == other.responseBody) && + (isResponseBodyPlainText == other.isResponseBodyPlainText) && + (responseImageData?.contentEquals(other.responseImageData ?: byteArrayOf()) != false) + } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTuple.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTuple.kt index a48be2c95..cc4471dee 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTuple.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/entity/HttpTransactionTuple.kt @@ -2,11 +2,14 @@ package com.chuckerteam.chucker.internal.data.entity import androidx.room.ColumnInfo import com.chuckerteam.chucker.internal.support.FormatUtils +import com.chuckerteam.chucker.internal.support.FormattedUrl +import okhttp3.HttpUrl /** * A subset of [HttpTransaction] to perform faster Read operations on the Repository. * This Tuple is good to be used on List or Preview interfaces. */ +@Suppress("LongParameterList") internal class HttpTransactionTuple( @ColumnInfo(name = "id") var id: Long, @ColumnInfo(name = "requestDate") var requestDate: Long?, @@ -42,4 +45,15 @@ internal class HttpTransactionTuple( private fun formatBytes(bytes: Long): String { return FormatUtils.formatByteCount(bytes, true) } + + fun getFormattedPath(encode: Boolean): String { + val path = this.path ?: return "" + + // Create dummy URL since there is no data in this class to get it from + // and we are only interested in a formatted path with query. + val dummyUrl = "https://www.example.com$path" + + val httpUrl = HttpUrl.parse(dummyUrl) ?: return "" + return FormattedUrl.fromHttpUrl(httpUrl, encode).pathWithQuery + } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepository.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepository.kt index 8577a8151..a6388bda6 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepository.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/HttpTransactionDatabaseRepository.kt @@ -4,44 +4,40 @@ import androidx.lifecycle.LiveData import com.chuckerteam.chucker.internal.data.entity.HttpTransaction import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple import com.chuckerteam.chucker.internal.data.room.ChuckerDatabase -import java.util.concurrent.Executor -import java.util.concurrent.Executors +import com.chuckerteam.chucker.internal.support.distinctUntilChanged internal class HttpTransactionDatabaseRepository(private val database: ChuckerDatabase) : HttpTransactionRepository { - private val executor: Executor = Executors.newSingleThreadExecutor() - - private val transcationDao get() = database.transactionDao() + private val transactionDao get() = database.transactionDao() override fun getFilteredTransactionTuples(code: String, path: String): LiveData> { val pathQuery = if (path.isNotEmpty()) "%$path%" else "%" - return transcationDao.getFilteredTuples("$code%", pathQuery) + return transactionDao.getFilteredTuples("$code%", pathQuery) } - override fun getTransaction(transactionId: Long): LiveData { - return transcationDao.getById(transactionId) + override fun getTransaction(transactionId: Long): LiveData { + return transactionDao.getById(transactionId) + .distinctUntilChanged { old, new -> old?.hasTheSameContent(new) != false } } override fun getSortedTransactionTuples(): LiveData> { - return transcationDao.getSortedTuples() + return transactionDao.getSortedTuples() } - override fun deleteAllTransactions() { - executor.execute { transcationDao.deleteAll() } + override suspend fun deleteAllTransactions() { + transactionDao.deleteAll() } - override fun insertTransaction(transaction: HttpTransaction) { - executor.execute { - val id = transcationDao.insert(transaction) - transaction.id = id ?: 0 - } + override suspend fun insertTransaction(transaction: HttpTransaction) { + val id = transactionDao.insert(transaction) + transaction.id = id ?: 0 } override fun updateTransaction(transaction: HttpTransaction): Int { - return transcationDao.update(transaction) + return transactionDao.update(transaction) } - override fun deleteOldTransactions(threshold: Long) { - executor.execute { transcationDao.deleteBefore(threshold) } + override suspend fun deleteOldTransactions(threshold: Long) { + transactionDao.deleteBefore(threshold) } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/HttpTransactionRepository.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/HttpTransactionRepository.kt index 89b40189c..69bd90fe1 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/HttpTransactionRepository.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/HttpTransactionRepository.kt @@ -11,17 +11,17 @@ import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple */ internal interface HttpTransactionRepository { - fun insertTransaction(transaction: HttpTransaction) + suspend fun insertTransaction(transaction: HttpTransaction) fun updateTransaction(transaction: HttpTransaction): Int - fun deleteOldTransactions(threshold: Long) + suspend fun deleteOldTransactions(threshold: Long) - fun deleteAllTransactions() + suspend fun deleteAllTransactions() fun getSortedTransactionTuples(): LiveData> fun getFilteredTransactionTuples(code: String, path: String): LiveData> - fun getTransaction(transactionId: Long): LiveData + fun getTransaction(transactionId: Long): LiveData } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RecordedThrowableDatabaseRepository.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RecordedThrowableDatabaseRepository.kt index ce9674108..7f8b5817a 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RecordedThrowableDatabaseRepository.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RecordedThrowableDatabaseRepository.kt @@ -4,32 +4,29 @@ import androidx.lifecycle.LiveData import com.chuckerteam.chucker.internal.data.entity.RecordedThrowable import com.chuckerteam.chucker.internal.data.entity.RecordedThrowableTuple import com.chuckerteam.chucker.internal.data.room.ChuckerDatabase -import java.util.concurrent.Executor -import java.util.concurrent.Executors +import com.chuckerteam.chucker.internal.support.distinctUntilChanged internal class RecordedThrowableDatabaseRepository( private val database: ChuckerDatabase ) : RecordedThrowableRepository { - private val executor: Executor = Executors.newSingleThreadExecutor() - override fun getRecordedThrowable(id: Long): LiveData { - return database.throwableDao().getById(id) + return database.throwableDao().getById(id).distinctUntilChanged() } - override fun deleteAllThrowables() { - executor.execute { database.throwableDao().deleteAll() } + override suspend fun deleteAllThrowables() { + database.throwableDao().deleteAll() } override fun getSortedThrowablesTuples(): LiveData> { return database.throwableDao().getTuples() } - override fun saveThrowable(throwable: RecordedThrowable) { - executor.execute { database.throwableDao().insert(throwable) } + override suspend fun saveThrowable(throwable: RecordedThrowable) { + database.throwableDao().insert(throwable) } - override fun deleteOldThrowables(threshold: Long) { - executor.execute { database.throwableDao().deleteBefore(threshold) } + override suspend fun deleteOldThrowables(threshold: Long) { + database.throwableDao().deleteBefore(threshold) } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RecordedThrowableRepository.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RecordedThrowableRepository.kt index bea37112d..925c74586 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RecordedThrowableRepository.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RecordedThrowableRepository.kt @@ -6,16 +6,16 @@ import com.chuckerteam.chucker.internal.data.entity.RecordedThrowableTuple /** * Repository Interface representing all the operations that are needed to let Chucker work - * with [RecordedThrowable] and [RecordedThrowableTuple]. Please use [ChuckerDatabaseRepository] + * with [RecordedThrowable] and [RecordedThrowableTuple]. Please use [RecordedThrowableDatabaseRepository] * that uses Room and SqLite to run those operations. */ internal interface RecordedThrowableRepository { - fun saveThrowable(throwable: RecordedThrowable) + suspend fun saveThrowable(throwable: RecordedThrowable) - fun deleteOldThrowables(threshold: Long) + suspend fun deleteOldThrowables(threshold: Long) - fun deleteAllThrowables() + suspend fun deleteAllThrowables() fun getSortedThrowablesTuples(): LiveData> diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RepositoryProvider.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RepositoryProvider.kt index 59228db55..62c8eeca9 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RepositoryProvider.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/repository/RepositoryProvider.kt @@ -5,8 +5,8 @@ import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider.initi import com.chuckerteam.chucker.internal.data.room.ChuckerDatabase /** - * A singleton to hold the [ChuckerRepository] instance. Make sure you call [initialize] before - * accessing the stored instance. + * A singleton to hold the [HttpTransactionRepository] and [RecordedThrowableRepository] instances. + * Make sure you call [initialize] before accessing the stored instance. */ internal object RepositoryProvider { diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/room/ChuckerDatabase.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/room/ChuckerDatabase.kt index 527cce661..3bf54e52d 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/room/ChuckerDatabase.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/room/ChuckerDatabase.kt @@ -7,7 +7,7 @@ import androidx.room.RoomDatabase import com.chuckerteam.chucker.internal.data.entity.HttpTransaction import com.chuckerteam.chucker.internal.data.entity.RecordedThrowable -@Database(entities = [RecordedThrowable::class, HttpTransaction::class], version = 2, exportSchema = false) +@Database(entities = [RecordedThrowable::class, HttpTransaction::class], version = 3, exportSchema = false) internal abstract class ChuckerDatabase : RoomDatabase() { abstract fun throwableDao(): RecordedThrowableDao diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/room/HttpTransactionDao.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/room/HttpTransactionDao.kt index 8b7bcaee9..87f739f99 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/room/HttpTransactionDao.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/room/HttpTransactionDao.kt @@ -28,17 +28,17 @@ internal interface HttpTransactionDao { fun getFilteredTuples(codeQuery: String, pathQuery: String): LiveData> @Insert - fun insert(transaction: HttpTransaction): Long? + suspend fun insert(transaction: HttpTransaction): Long? @Update(onConflict = OnConflictStrategy.REPLACE) fun update(transaction: HttpTransaction): Int @Query("DELETE FROM transactions") - fun deleteAll() + suspend fun deleteAll() @Query("SELECT * FROM transactions WHERE id = :id") - fun getById(id: Long): LiveData + fun getById(id: Long): LiveData @Query("DELETE FROM transactions WHERE requestDate <= :threshold") - fun deleteBefore(threshold: Long) + suspend fun deleteBefore(threshold: Long) } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/data/room/RecordedThrowableDao.kt b/library/src/main/java/com/chuckerteam/chucker/internal/data/room/RecordedThrowableDao.kt index f78f541fc..78fcac664 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/data/room/RecordedThrowableDao.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/data/room/RecordedThrowableDao.kt @@ -13,15 +13,15 @@ internal interface RecordedThrowableDao { @Query("SELECT id,tag,date,clazz,message FROM throwables ORDER BY date DESC") fun getTuples(): LiveData> - @Insert() - fun insert(throwable: RecordedThrowable): Long? + @Insert + suspend fun insert(throwable: RecordedThrowable): Long? @Query("DELETE FROM throwables") - fun deleteAll() + suspend fun deleteAll() @Query("SELECT * FROM throwables WHERE id = :id") fun getById(id: Long): LiveData @Query("DELETE FROM throwables WHERE date <= :threshold") - fun deleteBefore(threshold: Long) + suspend fun deleteBefore(threshold: Long) } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/AndroidCacheFileFactory.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/AndroidCacheFileFactory.kt new file mode 100644 index 000000000..196369b82 --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/AndroidCacheFileFactory.kt @@ -0,0 +1,16 @@ +package com.chuckerteam.chucker.internal.support + +import android.content.Context +import java.io.File +import java.util.concurrent.atomic.AtomicLong + +internal class AndroidCacheFileFactory( + context: Context +) : FileFactory { + private val fileDir = context.cacheDir + private val uniqueIdGenerator = AtomicLong() + + override fun create(): File { + return File(fileDir, "chucker-${uniqueIdGenerator.getAndIncrement()}") + } +} diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/ClearDatabaseService.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/ClearDatabaseService.kt index 9da1a71d5..117628f25 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/support/ClearDatabaseService.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/ClearDatabaseService.kt @@ -4,6 +4,9 @@ import android.app.IntentService import android.content.Intent import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider import java.io.Serializable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch internal class ClearDatabaseService : IntentService(CLEAN_DATABASE_SERVICE_NAME) { @@ -11,13 +14,17 @@ internal class ClearDatabaseService : IntentService(CLEAN_DATABASE_SERVICE_NAME) when (intent?.getSerializableExtra(EXTRA_ITEM_TO_CLEAR)) { is ClearAction.Transaction -> { RepositoryProvider.initialize(applicationContext) - RepositoryProvider.transaction().deleteAllTransactions() + CoroutineScope(Dispatchers.IO).launch { + RepositoryProvider.transaction().deleteAllTransactions() + } NotificationHelper.clearBuffer() NotificationHelper(this).dismissTransactionsNotification() } is ClearAction.Error -> { RepositoryProvider.initialize(applicationContext) - RepositoryProvider.throwable().deleteAllThrowables() + CoroutineScope(Dispatchers.IO).launch { + RepositoryProvider.throwable().deleteAllThrowables() + } NotificationHelper(this).dismissErrorsNotification() } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/FileFactory.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/FileFactory.kt new file mode 100644 index 000000000..4eac2269c --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/FileFactory.kt @@ -0,0 +1,7 @@ +package com.chuckerteam.chucker.internal.support + +import java.io.File + +internal interface FileFactory { + fun create(): File +} diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/FormatUtils.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/FormatUtils.kt index e18d39ee0..c0c4184a4 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/support/FormatUtils.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/FormatUtils.kt @@ -94,8 +94,8 @@ internal object FormatUtils { } } - fun getShareText(context: Context, transaction: HttpTransaction): String { - var text = "${context.getString(R.string.chucker_url)}: ${transaction.url}\n" + fun getShareText(context: Context, transaction: HttpTransaction, encodeUrls: Boolean): String { + var text = "${context.getString(R.string.chucker_url)}: ${transaction.getFormattedUrl(encodeUrls)}\n" text += "${context.getString(R.string.chucker_method)}: ${transaction.method}\n" text += "${context.getString(R.string.chucker_protocol)}: ${transaction.protocol}\n" text += "${context.getString(R.string.chucker_status)}: ${transaction.status}\n" diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/FormattedUrl.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/FormattedUrl.kt new file mode 100644 index 000000000..3e133f933 --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/FormattedUrl.kt @@ -0,0 +1,49 @@ +package com.chuckerteam.chucker.internal.support + +import okhttp3.HttpUrl + +internal class FormattedUrl private constructor( + val scheme: String, + val host: String, + val path: String, + val query: String +) { + val pathWithQuery: String + get() = if (query.isBlank()) { + path + } else { + "$path?$query" + } + + val url get() = "$scheme://$host$pathWithQuery" + + companion object { + fun fromHttpUrl(httpUrl: HttpUrl, encoded: Boolean): FormattedUrl { + return if (encoded) { + encodedUrl(httpUrl) + } else { + decodedUrl(httpUrl) + } + } + + private fun encodedUrl(httpUrl: HttpUrl): FormattedUrl { + val path = httpUrl.encodedPathSegments().joinToString("/") + return FormattedUrl( + httpUrl.scheme(), + httpUrl.host(), + if (path.isNotBlank()) "/$path" else "", + httpUrl.encodedQuery().orEmpty() + ) + } + + private fun decodedUrl(httpUrl: HttpUrl): FormattedUrl { + val path = httpUrl.pathSegments().joinToString("/") + return FormattedUrl( + httpUrl.scheme(), + httpUrl.host(), + if (path.isNotBlank()) "/$path" else "", + httpUrl.query().orEmpty() + ) + } + } +} diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/JsonConverter.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/JsonConverter.kt index f1ebab918..31daea5c1 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/support/JsonConverter.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/JsonConverter.kt @@ -1,10 +1,7 @@ package com.chuckerteam.chucker.internal.support -import com.google.gson.FieldNamingPolicy import com.google.gson.Gson import com.google.gson.GsonBuilder -import com.google.gson.internal.bind.DateTypeAdapter -import java.util.Date internal object JsonConverter { @@ -12,8 +9,6 @@ internal object JsonConverter { GsonBuilder() .serializeNulls() .setPrettyPrinting() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .registerTypeAdapter(Date::class.java, DateTypeAdapter()) .create() } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/LiveDataUtils.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/LiveDataUtils.kt new file mode 100644 index 000000000..129ab5c61 --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/LiveDataUtils.kt @@ -0,0 +1,68 @@ +package com.chuckerteam.chucker.internal.support + +import android.annotation.SuppressLint +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import java.util.concurrent.Executor + +internal fun LiveData.combineLatest( + other: LiveData, + func: (T1, T2) -> R +): LiveData { + return MediatorLiveData().apply { + var lastA: T1? = null + var lastB: T2? = null + + addSource(this@combineLatest) { + lastA = it + val observedB = lastB + if (it == null && value != null) { + value = null + } else if (it != null && observedB != null) { + value = func(it, observedB) + } + } + + addSource(other) { + lastB = it + val observedA = lastA + if (it == null && value != null) { + value = null + } else if (observedA != null && it != null) { + value = func(observedA, it) + } + } + } +} + +internal fun LiveData.combineLatest(other: LiveData): LiveData> { + return combineLatest(other) { a, b -> a to b } +} + +// Unlike built-in extension operation is performed on a provided thread pool. +// This is needed in our case since we compare requests and responses which can be big +// and result in frame drops. +internal fun LiveData.distinctUntilChanged( + executor: Executor = ioExecutor(), + areEqual: (old: T, new: T) -> Boolean = { old, new -> old == new } +): LiveData { + val distinctMediator = MediatorLiveData() + var old = uninitializedToken + distinctMediator.addSource(this) { new -> + executor.execute { + @Suppress("UNCHECKED_CAST") + if (old === uninitializedToken || !areEqual(old as T, new)) { + old = new + distinctMediator.postValue(new) + } + } + } + return distinctMediator +} + +private val uninitializedToken: Any? = Any() + +// It is lesser evil than providing a custom executor. +@SuppressLint("RestrictedApi") +private fun ioExecutor() = ArchTaskExecutor.getIOThreadExecutor() diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/NotificationHelper.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/NotificationHelper.kt index 7327c1f14..e1e772da3 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/support/NotificationHelper.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/NotificationHelper.kt @@ -63,12 +63,12 @@ internal class NotificationHelper(val context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val transactionsChannel = NotificationChannel( TRANSACTIONS_CHANNEL_ID, - context.getString(R.string.chucker_networks_notification_category), + context.getString(R.string.chucker_network_notification_category), NotificationManager.IMPORTANCE_LOW ) val errorsChannel = NotificationChannel( ERRORS_CHANNEL_ID, - context.getString(R.string.chucker_errors_notification_category), + context.getString(R.string.chucker_throwable_notification_category), NotificationManager.IMPORTANCE_LOW ) notificationManager.createNotificationChannels(listOf(transactionsChannel, errorsChannel)) diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/OkHttpUtils.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/OkHttpUtils.kt index f410d1d09..5b6fcd3a3 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/support/OkHttpUtils.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/OkHttpUtils.kt @@ -1,14 +1,37 @@ -@file:JvmName("OkHttpUtils") - package com.chuckerteam.chucker.internal.support +import java.net.HttpURLConnection.HTTP_NOT_MODIFIED +import java.net.HttpURLConnection.HTTP_NO_CONTENT +import java.net.HttpURLConnection.HTTP_OK import okhttp3.Headers import okhttp3.Request import okhttp3.Response -internal val Response.contentLenght: Long +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() + if ((responseCode < HTTP_CONTINUE || responseCode >= HTTP_OK) && + (responseCode != HTTP_NO_CONTENT) && + (responseCode != 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() ?: -1 + return this.header("Content-Length")?.toLongOrNull() ?: -1L } internal val Response.isChunked: Boolean diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/SearchHighlightUtil.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/SearchHighlightUtil.kt index 61a0b69c7..64f4cd605 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/support/SearchHighlightUtil.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/SearchHighlightUtil.kt @@ -7,7 +7,7 @@ import android.text.style.ForegroundColorSpan import android.text.style.UnderlineSpan /** - * Hightlight parts of the String when it matches the search. + * Highlight parts of the String when it matches the search. * * @param search the text to highlight */ diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/support/TeeSource.kt b/library/src/main/java/com/chuckerteam/chucker/internal/support/TeeSource.kt new file mode 100644 index 000000000..89855bcec --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/support/TeeSource.kt @@ -0,0 +1,101 @@ +package com.chuckerteam.chucker.internal.support + +import java.io.File +import java.io.IOException +import okio.Buffer +import okio.Okio +import okio.Source +import okio.Timeout + +/** + * A source that acts as a tee operator - https://en.wikipedia.org/wiki/Tee_(command). + * + * It takes the input [upstream] and reads from it serving the bytes to the end consumer + * like a regular [Source]. While bytes are read from the [upstream] the are also copied + * to a [sideChannel] file. After the [upstream] is depleted or when a failure occurs + * an appropriate [callback] method is called. + * + * Failure is considered any [IOException] during reading the bytes, + * exceeding [readBytesLimit] length or not reading the whole upstream. + */ +internal class TeeSource( + private val upstream: Source, + private val sideChannel: File, + private val callback: Callback, + private val readBytesLimit: Long = Long.MAX_VALUE +) : Source { + private val sideStream = Okio.buffer(Okio.sink(sideChannel)) + private var totalBytesRead = 0L + private var isReadLimitExceeded = false + private var isUpstreamExhausted = false + private var isFailure = false + + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = try { + upstream.read(sink, byteCount) + } catch (e: IOException) { + callSideChannelFailure(e) + throw e + } + + if (bytesRead == -1L) { + isUpstreamExhausted = true + sideStream.close() + return -1L + } + + totalBytesRead += bytesRead + if (!isReadLimitExceeded && (totalBytesRead <= readBytesLimit)) { + val offset = sink.size() - bytesRead + sink.copyTo(sideStream.buffer(), offset, bytesRead) + sideStream.emitCompleteSegments() + return bytesRead + } + if (!isReadLimitExceeded) { + isReadLimitExceeded = true + sideStream.close() + callSideChannelFailure(IOException("Capacity of $readBytesLimit bytes exceeded")) + } + + return bytesRead + } + + override fun close() { + sideStream.close() + upstream.close() + if (isUpstreamExhausted) { + // Failure might have occurred due to exceeding limit. + if (!isFailure) { + callback.onSuccess(sideChannel) + } + } else { + callSideChannelFailure(IOException("Upstream was not fully consumed")) + } + } + + override fun timeout(): Timeout = upstream.timeout() + + private fun callSideChannelFailure(exception: IOException) { + if (!isFailure) { + isFailure = true + callback.onFailure(exception, sideChannel) + } + } + + interface Callback { + /** + * Called when the upstream was successfully copied to the [file]. + */ + fun onSuccess(file: File) + + /** + * Called when there was an issue while copying bytes to the [file]. + * + * It might occur due to one of the following reasons: + * - an exception was thrown while reading bytes + * - capacity limit was exceeded + * - upstream was not fully consumed + */ + fun onFailure(exception: IOException, file: File) + } +} diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/HomePageAdapter.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/HomePageAdapter.kt index e03e1997d..e64a7f183 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/HomePageAdapter.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/HomePageAdapter.kt @@ -5,7 +5,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentStatePagerAdapter import com.chuckerteam.chucker.R -import com.chuckerteam.chucker.internal.ui.error.ErrorListFragment +import com.chuckerteam.chucker.internal.ui.throwable.ThrowableListFragment import com.chuckerteam.chucker.internal.ui.transaction.TransactionListFragment import java.lang.ref.WeakReference @@ -16,7 +16,7 @@ internal class HomePageAdapter(context: Context, fragmentManager: FragmentManage override fun getItem(position: Int): Fragment = if (position == SCREEN_HTTP_INDEX) { TransactionListFragment.newInstance() } else { - ErrorListFragment.newInstance() + ThrowableListFragment.newInstance() } override fun getCount(): Int = 2 @@ -32,6 +32,6 @@ internal class HomePageAdapter(context: Context, fragmentManager: FragmentManage companion object { const val SCREEN_HTTP_INDEX = 0 - const val SCREEN_ERROR_INDEX = 1 + const val SCREEN_THROWABLE_INDEX = 1 } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/MainActivity.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/MainActivity.kt index b85345213..8e740421d 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/MainActivity.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/MainActivity.kt @@ -2,13 +2,11 @@ package com.chuckerteam.chucker.internal.ui import android.content.Intent import android.os.Bundle -import androidx.appcompat.widget.Toolbar import androidx.lifecycle.ViewModelProvider -import androidx.viewpager.widget.ViewPager -import com.chuckerteam.chucker.R import com.chuckerteam.chucker.api.Chucker -import com.chuckerteam.chucker.internal.ui.error.ErrorActivity -import com.chuckerteam.chucker.internal.ui.error.ErrorAdapter +import com.chuckerteam.chucker.databinding.ChuckerActivityMainBinding +import com.chuckerteam.chucker.internal.ui.throwable.ThrowableActivity +import com.chuckerteam.chucker.internal.ui.throwable.ThrowableAdapter import com.chuckerteam.chucker.internal.ui.transaction.TransactionActivity import com.chuckerteam.chucker.internal.ui.transaction.TransactionAdapter import com.google.android.material.tabs.TabLayout @@ -16,40 +14,39 @@ import com.google.android.material.tabs.TabLayout internal class MainActivity : BaseChuckerActivity(), TransactionAdapter.TransactionClickListListener, - ErrorAdapter.ErrorClickListListener { + ThrowableAdapter.ThrowableClickListListener { private lateinit var viewModel: MainViewModel - private lateinit var viewPager: ViewPager + private lateinit var mainBinding: ChuckerActivityMainBinding private val applicationName: CharSequence get() = applicationInfo.loadLabel(packageManager) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.chucker_activity_main) - - val toolbar = findViewById(R.id.toolbar) - setSupportActionBar(toolbar) - toolbar.subtitle = applicationName - viewModel = ViewModelProvider(this).get(MainViewModel::class.java) + mainBinding = ChuckerActivityMainBinding.inflate(layoutInflater) - viewPager = findViewById(R.id.viewPager) - viewPager.adapter = HomePageAdapter(this, supportFragmentManager) - - val tabLayout = findViewById(R.id.tabLayout) - tabLayout.setupWithViewPager(viewPager) - - viewPager.addOnPageChangeListener(object : TabLayout.TabLayoutOnPageChangeListener(tabLayout) { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - if (position == 0) { - Chucker.dismissTransactionsNotification(this@MainActivity) - } else { - Chucker.dismissErrorsNotification(this@MainActivity) + with(mainBinding) { + setContentView(root) + setSupportActionBar(toolbar) + toolbar.subtitle = applicationName + viewPager.adapter = HomePageAdapter(this@MainActivity, supportFragmentManager) + tabLayout.setupWithViewPager(viewPager) + viewPager.addOnPageChangeListener( + object : TabLayout.TabLayoutOnPageChangeListener(tabLayout) { + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + if (position == HomePageAdapter.SCREEN_HTTP_INDEX) { + Chucker.dismissTransactionsNotification(this@MainActivity) + } else { + Chucker.dismissErrorsNotification(this@MainActivity) + } + } } - } - }) + ) + } + consumeIntent(intent) } @@ -64,15 +61,15 @@ internal class MainActivity : private fun consumeIntent(intent: Intent) { // Get the screen to show, by default => HTTP val screenToShow = intent.getIntExtra(EXTRA_SCREEN, Chucker.SCREEN_HTTP) - if (screenToShow == Chucker.SCREEN_HTTP) { - viewPager.currentItem = HomePageAdapter.SCREEN_HTTP_INDEX + mainBinding.viewPager.currentItem = if (screenToShow == Chucker.SCREEN_HTTP) { + HomePageAdapter.SCREEN_HTTP_INDEX } else { - viewPager.currentItem = HomePageAdapter.SCREEN_ERROR_INDEX + HomePageAdapter.SCREEN_THROWABLE_INDEX } } - override fun onErrorClick(throwableId: Long, position: Int) { - ErrorActivity.start(this, throwableId) + override fun onThrowableClick(throwableId: Long, position: Int) { + ThrowableActivity.start(this, throwableId) } override fun onTransactionClick(transactionId: Long, position: Int) { diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/MainViewModel.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/MainViewModel.kt index 9fc4cec89..d58d7e4b2 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/MainViewModel.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/MainViewModel.kt @@ -3,18 +3,20 @@ package com.chuckerteam.chucker.internal.ui import android.text.TextUtils import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel +import androidx.lifecycle.switchMap +import androidx.lifecycle.viewModelScope import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple import com.chuckerteam.chucker.internal.data.entity.RecordedThrowableTuple import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider import com.chuckerteam.chucker.internal.support.NotificationHelper +import kotlinx.coroutines.launch internal class MainViewModel : ViewModel() { private val currentFilter = MutableLiveData("") - val transactions: LiveData> = Transformations.switchMap(currentFilter) { searchQuery -> + val transactions: LiveData> = currentFilter.switchMap { searchQuery -> with(RepositoryProvider.transaction()) { when { searchQuery.isNullOrBlank() -> { @@ -30,23 +32,23 @@ internal class MainViewModel : ViewModel() { } } - val errors: LiveData> = - Transformations.map( - RepositoryProvider.throwable().getSortedThrowablesTuples() - ) { - it - } + val throwables: LiveData> = RepositoryProvider.throwable() + .getSortedThrowablesTuples() fun updateItemsFilter(searchQuery: String) { currentFilter.value = searchQuery } fun clearTransactions() { - RepositoryProvider.transaction().deleteAllTransactions() + viewModelScope.launch { + RepositoryProvider.transaction().deleteAllTransactions() + } NotificationHelper.clearBuffer() } - fun clearErrors() { - RepositoryProvider.throwable().deleteAllThrowables() + fun clearThrowables() { + viewModelScope.launch { + RepositoryProvider.throwable().deleteAllThrowables() + } } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/error/ErrorAdapter.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/error/ErrorAdapter.kt deleted file mode 100644 index 4b4c1ed6a..000000000 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/error/ErrorAdapter.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.chuckerteam.chucker.internal.ui.error - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import com.chuckerteam.chucker.R -import com.chuckerteam.chucker.internal.data.entity.RecordedThrowableTuple -import java.text.DateFormat - -internal class ErrorAdapter( - val listener: ErrorClickListListener -) : RecyclerView.Adapter() { - - private var data: List = listOf() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ErrorViewHolder { - val view = - LayoutInflater.from(parent.context) - .inflate(R.layout.chucker_list_item_error, parent, false) - return ErrorViewHolder(view) - } - - override fun onBindViewHolder(holder: ErrorViewHolder, position: Int) { - val throwable = data[position] - holder.bind(throwable) - } - - override fun getItemCount(): Int { - return data.size - } - - fun setData(data: List) { - this.data = data - notifyDataSetChanged() - } - - inner class ErrorViewHolder( - itemView: View - ) : RecyclerView.ViewHolder(itemView), View.OnClickListener { - - private val tagView: TextView = itemView.findViewById(R.id.tag) - private val clazzView: TextView = itemView.findViewById(R.id.clazz) - private val messageView: TextView = itemView.findViewById(R.id.message) - private val dateView: TextView = itemView.findViewById(R.id.date) - private var throwableId: Long? = null - - init { - itemView.setOnClickListener(this) - } - - internal fun bind(throwable: RecordedThrowableTuple) = with(throwable) { - throwableId = id - tagView.text = tag - clazzView.text = clazz - messageView.text = message - dateView.text = formattedDate - } - - override fun onClick(v: View) { - throwableId?.let { - listener.onErrorClick(it, adapterPosition) - } - } - } - - private val RecordedThrowableTuple.formattedDate: String - get() { - return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) - .format(this.date) - } - - interface ErrorClickListListener { - fun onErrorClick(throwableId: Long, position: Int) - } -} diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/error/ErrorActivity.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableActivity.kt similarity index 54% rename from library/src/main/java/com/chuckerteam/chucker/internal/ui/error/ErrorActivity.kt rename to library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableActivity.kt index 0f87836e2..316f2bd63 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/error/ErrorActivity.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableActivity.kt @@ -1,4 +1,4 @@ -package com.chuckerteam.chucker.internal.ui.error +package com.chuckerteam.chucker.internal.ui.throwable import android.content.Context import android.content.Intent @@ -6,68 +6,55 @@ import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View -import android.widget.TextView import androidx.core.app.ShareCompat import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider import com.chuckerteam.chucker.R +import com.chuckerteam.chucker.databinding.ChuckerActivityThrowableBinding import com.chuckerteam.chucker.internal.data.entity.RecordedThrowable -import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider import com.chuckerteam.chucker.internal.ui.BaseChuckerActivity import java.text.DateFormat -internal class ErrorActivity : BaseChuckerActivity() { +internal class ThrowableActivity : BaseChuckerActivity() { - private var throwableId: Long = 0 - private var throwable: RecordedThrowable? = null + private lateinit var viewModel: ThrowableViewModel + private lateinit var errorBinding: ChuckerActivityThrowableBinding - private lateinit var title: TextView - private lateinit var tag: TextView - private lateinit var clazz: TextView - private lateinit var message: TextView - private lateinit var date: TextView - private lateinit var stacktrace: TextView + private var throwableId: Long = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.chucker_activity_error) - setSupportActionBar(findViewById(R.id.toolbar)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - title = findViewById(R.id.toolbar_title) - tag = findViewById(R.id.tag) - clazz = findViewById(R.id.clazz) - message = findViewById(R.id.message) - date = findViewById(R.id.date) - stacktrace = findViewById(R.id.stacktrace) - date.visibility = View.GONE + errorBinding = ChuckerActivityThrowableBinding.inflate(layoutInflater) throwableId = intent.getLongExtra(EXTRA_THROWABLE_ID, 0) - } - override fun onResume() { - super.onResume() - RepositoryProvider.throwable() - .getRecordedThrowable(throwableId) - .observe( - this, - Observer { recordedThrowable -> - recordedThrowable?.let { - throwable = it - populateUI(it) - } - } - ) + with(errorBinding) { + setContentView(root) + setSupportActionBar(toolbar) + throwableItem.date.visibility = View.GONE + } + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + viewModel = ViewModelProvider(this, ThrowableViewModelFactory(throwableId)) + .get(ThrowableViewModel::class.java) + + viewModel.throwable.observe( + this, + Observer { + populateUI(it) + } + ) } override fun onCreateOptionsMenu(menu: Menu): Boolean { val inflater = menuInflater - inflater.inflate(R.menu.chucker_error, menu) + inflater.inflate(R.menu.chucker_throwable, menu) return super.onCreateOptionsMenu(menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean { return if (item.itemId == R.id.share_text) { - throwable?.let { share(it) } + viewModel.throwable.value?.let { share(it) } true } else { super.onOptionsItemSelected(item) @@ -76,7 +63,7 @@ internal class ErrorActivity : BaseChuckerActivity() { private fun share(throwable: RecordedThrowable) { val throwableDetailsText = getString( - R.string.chucker_share_error_content, + R.string.chucker_share_throwable_content, throwable.formattedDate, throwable.clazz, throwable.tag, @@ -86,35 +73,37 @@ internal class ErrorActivity : BaseChuckerActivity() { startActivity( ShareCompat.IntentBuilder.from(this) .setType(MIME_TYPE) - .setChooserTitle(getString(R.string.chucker_share_error_title)) - .setSubject(getString(R.string.chucker_share_error_subject)) + .setChooserTitle(getString(R.string.chucker_share_throwable_title)) + .setSubject(getString(R.string.chucker_share_throwable_subject)) .setText(throwableDetailsText) .createChooserIntent() ) } private fun populateUI(throwable: RecordedThrowable) { - title.text = throwable.formattedDate - tag.text = throwable.tag - clazz.text = throwable.clazz - message.text = throwable.message - stacktrace.text = throwable.content + errorBinding.apply { + toolbarTitle.text = throwable.formattedDate + throwableItem.tag.text = throwable.tag + throwableItem.clazz.text = throwable.clazz + throwableItem.message.text = throwable.message + throwableStacktrace.text = throwable.content + } } + private val RecordedThrowable.formattedDate: String + get() { + return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + .format(this.date) + } + companion object { private const val MIME_TYPE = "text/plain" private const val EXTRA_THROWABLE_ID = "transaction_id" fun start(context: Context, throwableId: Long) { - val intent = Intent(context, ErrorActivity::class.java) + val intent = Intent(context, ThrowableActivity::class.java) intent.putExtra(EXTRA_THROWABLE_ID, throwableId) context.startActivity(intent) } } - - private val RecordedThrowable.formattedDate: String - get() { - return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) - .format(this.date) - } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableAdapter.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableAdapter.kt new file mode 100644 index 000000000..cd57f1ec4 --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableAdapter.kt @@ -0,0 +1,66 @@ +package com.chuckerteam.chucker.internal.ui.throwable + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.chuckerteam.chucker.databinding.ChuckerListItemThrowableBinding +import com.chuckerteam.chucker.internal.data.entity.RecordedThrowableTuple +import java.text.DateFormat + +internal class ThrowableAdapter( + val listener: ThrowableClickListListener +) : RecyclerView.Adapter() { + + private var data: List = listOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThrowableViewHolder { + val viewBinding = ChuckerListItemThrowableBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ThrowableViewHolder(viewBinding) + } + + override fun onBindViewHolder(holder: ThrowableViewHolder, position: Int) { + val throwable = data[position] + holder.bind(throwable) + } + + override fun getItemCount(): Int { + return data.size + } + + fun setData(data: List) { + this.data = data + notifyDataSetChanged() + } + + inner class ThrowableViewHolder( + private val itemBinding: ChuckerListItemThrowableBinding + ) : RecyclerView.ViewHolder(itemBinding.root), View.OnClickListener { + + private var throwableId: Long? = null + + init { + itemView.setOnClickListener(this) + } + + internal fun bind(throwable: RecordedThrowableTuple) = with(itemBinding) { + throwableId = throwable.id + + tag.text = throwable.tag + clazz.text = throwable.clazz + message.text = throwable.message + date.text = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + .format(throwable.date) + } + + override fun onClick(v: View) { + throwableId?.let { + listener.onThrowableClick(it, adapterPosition) + } + } + } + + interface ThrowableClickListListener { + fun onThrowableClick(throwableId: Long, position: Int) + } +} diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/error/ErrorListFragment.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableListFragment.kt similarity index 56% rename from library/src/main/java/com/chuckerteam/chucker/internal/ui/error/ErrorListFragment.kt rename to library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableListFragment.kt index a7fd06e42..6d39be006 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/error/ErrorListFragment.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableListFragment.kt @@ -1,6 +1,5 @@ -package com.chuckerteam.chucker.internal.ui.error +package com.chuckerteam.chucker.internal.ui.throwable -import android.content.Context import android.os.Bundle import android.text.method.LinkMovementMethod import android.view.LayoutInflater @@ -9,23 +8,20 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL -import androidx.recyclerview.widget.RecyclerView import com.chuckerteam.chucker.R +import com.chuckerteam.chucker.databinding.ChuckerFragmentThrowableListBinding import com.chuckerteam.chucker.internal.ui.MainViewModel -internal class ErrorListFragment : Fragment() { +internal class ThrowableListFragment : Fragment(), ThrowableAdapter.ThrowableClickListListener { private lateinit var viewModel: MainViewModel - private lateinit var adapter: ErrorAdapter - private lateinit var listener: ErrorAdapter.ErrorClickListListener - private lateinit var tutorialView: View + private lateinit var errorsBinding: ChuckerFragmentThrowableListBinding + private lateinit var errorsAdapter: ThrowableAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -34,24 +30,28 @@ internal class ErrorListFragment : Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.chucker_fragment_error_list, container, false).apply { - tutorialView = findViewById(R.id.tutorial) - findViewById(R.id.link).movementMethod = LinkMovementMethod.getInstance() + errorsBinding = ChuckerFragmentThrowableListBinding.inflate(inflater, container, false) + errorsAdapter = ThrowableAdapter(this) - val recyclerView = findViewById(R.id.list) - recyclerView.addItemDecoration(DividerItemDecoration(context, VERTICAL)) - adapter = ErrorAdapter(listener) - recyclerView.adapter = adapter + with(errorsBinding) { + tutorialLink.movementMethod = LinkMovementMethod.getInstance() + errorsRecyclerView.apply { + setHasFixedSize(true) + addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + adapter = errorsAdapter + } } + + return errorsBinding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.errors.observe( + viewModel.throwables.observe( viewLifecycleOwner, - Observer { errors -> - adapter.setData(errors) - tutorialView.visibility = if (errors.isNullOrEmpty()) { + Observer { throwables -> + errorsAdapter.setData(throwables) + errorsBinding.tutorialView.visibility = if (throwables.isNullOrEmpty()) { View.VISIBLE } else { View.GONE @@ -60,17 +60,8 @@ internal class ErrorListFragment : Fragment() { ) } - override fun onAttach(context: Context) { - super.onAttach(context) - - require(context is ErrorAdapter.ErrorClickListListener) { - "Context must implement the listener." - } - listener = context - } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.chucker_errors_list, menu) + inflater.inflate(R.menu.chucker_throwables_list, menu) super.onCreateOptionsMenu(menu, inflater) } @@ -86,15 +77,19 @@ internal class ErrorListFragment : Fragment() { private fun askForConfirmation() { AlertDialog.Builder(requireContext()) .setTitle(R.string.chucker_clear) - .setMessage(R.string.chucker_clear_error_confirmation) + .setMessage(R.string.chucker_clear_throwable_confirmation) .setPositiveButton(R.string.chucker_clear) { _, _ -> - viewModel.clearErrors() + viewModel.clearThrowables() } .setNegativeButton(R.string.chucker_cancel, null) .show() } + override fun onThrowableClick(throwableId: Long, position: Int) { + ThrowableActivity.start(requireActivity(), throwableId) + } + companion object { - fun newInstance() = ErrorListFragment() + fun newInstance() = ThrowableListFragment() } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableViewModel.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableViewModel.kt new file mode 100644 index 000000000..70ea892b8 --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableViewModel.kt @@ -0,0 +1,25 @@ +package com.chuckerteam.chucker.internal.ui.throwable + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.chuckerteam.chucker.internal.data.entity.RecordedThrowable +import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider + +internal class ThrowableViewModel( + throwableId: Long +) : ViewModel() { + + val throwable: LiveData = RepositoryProvider.throwable().getRecordedThrowable(throwableId) +} + +internal class ThrowableViewModelFactory( + private val throwableId: Long = 0L +) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + require(modelClass == ThrowableViewModel::class.java) { "Cannot create $modelClass" } + @Suppress("UNCHECKED_CAST") + return ThrowableViewModel(throwableId) as T + } +} diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/ProtocolResources.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/ProtocolResources.kt new file mode 100644 index 000000000..552d17dc8 --- /dev/null +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/ProtocolResources.kt @@ -0,0 +1,10 @@ +package com.chuckerteam.chucker.internal.ui.transaction + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import com.chuckerteam.chucker.R + +sealed class ProtocolResources(@DrawableRes val icon: Int, @ColorRes val color: Int) { + class Http : ProtocolResources(R.drawable.chucker_ic_http, R.color.chucker_color_error) + class Https : ProtocolResources(R.drawable.chucker_ic_https, R.color.chucker_color_primary) +} diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionActivity.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionActivity.kt index a8cb99c45..a5368fd1d 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionActivity.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionActivity.kt @@ -5,26 +5,24 @@ import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem -import android.widget.TextView -import androidx.appcompat.widget.Toolbar import androidx.core.app.ShareCompat import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.viewpager.widget.ViewPager import com.chuckerteam.chucker.R +import com.chuckerteam.chucker.databinding.ChuckerActivityTransactionBinding import com.chuckerteam.chucker.internal.support.FormatUtils.getShareCurlCommand import com.chuckerteam.chucker.internal.support.FormatUtils.getShareText import com.chuckerteam.chucker.internal.ui.BaseChuckerActivity -import com.google.android.material.tabs.TabLayout internal class TransactionActivity : BaseChuckerActivity() { - private lateinit var title: TextView private lateinit var viewModel: TransactionViewModel + private lateinit var transactionBinding: ChuckerActivityTransactionBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.chucker_activity_transaction) + transactionBinding = ChuckerActivityTransactionBinding.inflate(layoutInflater) val transactionId = intent.getLongExtra(EXTRA_TRANSACTION_ID, 0) @@ -33,36 +31,51 @@ internal class TransactionActivity : BaseChuckerActivity() { viewModel = ViewModelProvider(this, TransactionViewModelFactory(transactionId)) .get(TransactionViewModel::class.java) - val toolbar = findViewById(R.id.toolbar) - setSupportActionBar(toolbar) - title = findViewById(R.id.toolbar_title) - - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - findViewById(R.id.viewpager)?.let { viewPager -> + with(transactionBinding) { + setContentView(root) + setSupportActionBar(toolbar) setupViewPager(viewPager) - findViewById(R.id.tabs).setupWithViewPager(viewPager) + tabLayout.setupWithViewPager(viewPager) } - } - override fun onResume() { - super.onResume() + supportActionBar?.setDisplayHomeAsUpEnabled(true) + viewModel.transactionTitle.observe( this, - Observer { title.text = it } + Observer { transactionBinding.toolbarTitle.text = it } ) } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.chucker_transaction, menu) + setUpUrlEncoding(menu) return super.onCreateOptionsMenu(menu) } + private fun setUpUrlEncoding(menu: Menu) { + val encodeUrlMenuItem = menu.findItem(R.id.encode_url) + encodeUrlMenuItem.setOnMenuItemClickListener { + viewModel.switchUrlEncoding() + return@setOnMenuItemClickListener true + } + viewModel.encodeUrl.observe( + this, + Observer { encode -> + val icon = if (encode) { + R.drawable.chucker_ic_encoded_url_white + } else { + R.drawable.chucker_ic_decoded_url_white + } + encodeUrlMenuItem.setIcon(icon) + } + ) + } + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { R.id.share_text -> { viewModel.transaction.value?.let { - share(getShareText(this, it)) + share(getShareText(this, it, viewModel.encodeUrl.value!!)) } ?: showToast(getString(R.string.chucker_request_not_ready)) true } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionAdapter.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionAdapter.kt index 1abadefa0..951a93dde 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionAdapter.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionAdapter.kt @@ -2,22 +2,25 @@ package com.chuckerteam.chucker.internal.ui.transaction import android.annotation.SuppressLint import android.content.Context +import android.content.res.ColorStateList import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat +import androidx.core.widget.ImageViewCompat import androidx.recyclerview.widget.RecyclerView import com.chuckerteam.chucker.R +import com.chuckerteam.chucker.databinding.ChuckerListItemTransactionBinding import com.chuckerteam.chucker.internal.data.entity.HttpTransaction import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple import java.text.DateFormat +import javax.net.ssl.HttpsURLConnection internal class TransactionAdapter internal constructor( context: Context, private val listener: TransactionClickListListener? -) : RecyclerView.Adapter() { +) : RecyclerView.Adapter() { private var transactions: List = arrayListOf() private val colorDefault: Int = ContextCompat.getColor(context, R.color.chucker_status_default) @@ -29,14 +32,12 @@ internal class TransactionAdapter internal constructor( override fun getItemCount(): Int = transactions.size - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val itemView = - LayoutInflater.from(parent.context) - .inflate(R.layout.chucker_list_item_transaction, parent, false) - return ViewHolder(itemView) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TransactionViewHolder { + val viewBinding = ChuckerListItemTransactionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return TransactionViewHolder(viewBinding) } - override fun onBindViewHolder(holder: ViewHolder, position: Int) = + override fun onBindViewHolder(holder: TransactionViewHolder, position: Int) = holder.bind(transactions[position]) fun setData(httpTransactions: List) { @@ -44,51 +45,70 @@ internal class TransactionAdapter internal constructor( notifyDataSetChanged() } - inner class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) { - private val code: TextView = view.findViewById(R.id.code) - private val path: TextView = view.findViewById(R.id.path) - private val host: TextView = view.findViewById(R.id.host) - private val start: TextView = view.findViewById(R.id.time_start) - private val duration: TextView = view.findViewById(R.id.duration) - private val size: TextView = view.findViewById(R.id.size) - private val ssl: ImageView = view.findViewById(R.id.ssl) + inner class TransactionViewHolder( + private val itemBinding: ChuckerListItemTransactionBinding + ) : RecyclerView.ViewHolder(itemBinding.root), View.OnClickListener { + + private var transactionId: Long? = null + + init { + itemView.setOnClickListener(this) + } + + override fun onClick(v: View?) { + transactionId?.let { + listener?.onTransactionClick(it, adapterPosition) + } + } @SuppressLint("SetTextI18n") fun bind(transaction: HttpTransactionTuple) { - path.text = "${transaction.method} ${transaction.path}" - host.text = transaction.host - start.text = DateFormat.getTimeInstance().format(transaction.requestDate) - ssl.visibility = if (transaction.isSsl) View.VISIBLE else View.GONE - if (transaction.status === HttpTransaction.Status.Complete) { - code.text = transaction.responseCode.toString() - duration.text = transaction.durationString - size.text = transaction.totalSizeString - } else { - code.text = "" - duration.text = "" - size.text = "" - } - if (transaction.status === HttpTransaction.Status.Failed) { - code.text = "!!!" - } - setStatusColor(this, transaction) - view.setOnClickListener { - listener?.onTransactionClick(transaction.id, adapterPosition) + transactionId = transaction.id + + itemBinding.apply { + path.text = "${transaction.method} ${transaction.getFormattedPath(encode = false)}" + host.text = transaction.host + timeStart.text = DateFormat.getTimeInstance().format(transaction.requestDate) + + setProtocolImage(if (transaction.isSsl) ProtocolResources.Https() else ProtocolResources.Http()) + + if (transaction.status === HttpTransaction.Status.Complete) { + code.text = transaction.responseCode.toString() + duration.text = transaction.durationString + size.text = transaction.totalSizeString + } else { + code.text = "" + duration.text = "" + size.text = "" + } + if (transaction.status === HttpTransaction.Status.Failed) { + code.text = "!!!" + } } + + setStatusColor(transaction) } - private fun setStatusColor(holder: ViewHolder, transaction: HttpTransactionTuple) { + private fun setProtocolImage(resources: ProtocolResources) { + itemBinding.ssl.setImageDrawable(AppCompatResources.getDrawable(itemView.context, resources.icon)) + ImageViewCompat.setImageTintList( + itemBinding.ssl, + ColorStateList.valueOf(ContextCompat.getColor(itemView.context, resources.color)) + ) + } + + private fun setStatusColor(transaction: HttpTransactionTuple) { val color: Int = when { - transaction.status === HttpTransaction.Status.Failed -> colorError - transaction.status === HttpTransaction.Status.Requested -> colorRequested - transaction.responseCode == null -> colorDefault - transaction.responseCode!! >= SERVER_ERRORS -> color500 - transaction.responseCode!! >= CLIENT_ERRORS -> color400 - transaction.responseCode!! >= REDIRECTS -> color300 + (transaction.status === HttpTransaction.Status.Failed) -> colorError + (transaction.status === HttpTransaction.Status.Requested) -> colorRequested + (transaction.responseCode == null) -> colorDefault + (transaction.responseCode!! >= HttpsURLConnection.HTTP_INTERNAL_ERROR) -> color500 + (transaction.responseCode!! >= HttpsURLConnection.HTTP_BAD_REQUEST) -> color400 + (transaction.responseCode!! >= HttpsURLConnection.HTTP_MULT_CHOICE) -> color300 else -> colorDefault } - holder.code.setTextColor(color) - holder.path.setTextColor(color) + itemBinding.code.setTextColor(color) + itemBinding.path.setTextColor(color) } } @@ -96,7 +116,3 @@ internal class TransactionAdapter internal constructor( fun onTransactionClick(transactionId: Long, position: Int) } } - -private const val SERVER_ERRORS = 500 -private const val CLIENT_ERRORS = 400 -private const val REDIRECTS = 300 diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionListFragment.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionListFragment.kt index 5d455cd47..dc918f024 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionListFragment.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionListFragment.kt @@ -8,15 +8,14 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.RecyclerView import com.chuckerteam.chucker.R +import com.chuckerteam.chucker.databinding.ChuckerFragmentTransactionListBinding import com.chuckerteam.chucker.internal.ui.MainViewModel internal class TransactionListFragment : @@ -25,8 +24,8 @@ internal class TransactionListFragment : TransactionAdapter.TransactionClickListListener { private lateinit var viewModel: MainViewModel - private lateinit var adapter: TransactionAdapter - private lateinit var tutorialView: View + private lateinit var transactionsBinding: ChuckerFragmentTransactionListBinding + private lateinit var transactionsAdapter: TransactionAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -39,19 +38,19 @@ internal class TransactionListFragment : container: ViewGroup?, savedInstanceState: Bundle? ): View? { - val view = inflater.inflate(R.layout.chucker_fragment_transaction_list, container, false) - tutorialView = view.findViewById(R.id.tutorial) - view.findViewById(R.id.link).movementMethod = LinkMovementMethod.getInstance() + transactionsBinding = ChuckerFragmentTransactionListBinding.inflate(inflater, container, false) - val recyclerView = view.findViewById(R.id.list) - val context = view.context - recyclerView.addItemDecoration( - DividerItemDecoration(context, DividerItemDecoration.VERTICAL) - ) - adapter = TransactionAdapter(context, this) - recyclerView.adapter = adapter + transactionsAdapter = TransactionAdapter(requireContext(), this) + with(transactionsBinding) { + tutorialLink.movementMethod = LinkMovementMethod.getInstance() + transactionsRecyclerView.apply { + setHasFixedSize(true) + addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) + adapter = transactionsAdapter + } + } - return view + return transactionsBinding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -59,19 +58,24 @@ internal class TransactionListFragment : viewModel.transactions.observe( viewLifecycleOwner, Observer { transactionTuples -> - adapter.setData(transactionTuples) - tutorialView.visibility = if (transactionTuples.isEmpty()) View.VISIBLE else View.GONE + transactionsAdapter.setData(transactionTuples) + transactionsBinding.tutorialView.visibility = + if (transactionTuples.isEmpty()) View.VISIBLE else View.GONE } ) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.chucker_transactions_list, menu) + setUpSearch(menu) + super.onCreateOptionsMenu(menu, inflater) + } + + private fun setUpSearch(menu: Menu) { val searchMenuItem = menu.findItem(R.id.search) val searchView = searchMenuItem.actionView as SearchView searchView.setOnQueryTextListener(this) searchView.setIconifiedByDefault(true) - super.onCreateOptionsMenu(menu, inflater) } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -99,8 +103,9 @@ internal class TransactionListFragment : return true } - override fun onTransactionClick(transactionId: Long, position: Int) = + override fun onTransactionClick(transactionId: Long, position: Int) { TransactionActivity.start(requireActivity(), transactionId) + } companion object { fun newInstance(): TransactionListFragment { diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionOverviewFragment.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionOverviewFragment.kt index 1fc6039f9..31acbf64e 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionOverviewFragment.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionOverviewFragment.kt @@ -6,26 +6,17 @@ import android.view.Menu import android.view.MenuInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import com.chuckerteam.chucker.R +import com.chuckerteam.chucker.databinding.ChuckerFragmentTransactionOverviewBinding +import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.support.combineLatest internal class TransactionOverviewFragment : Fragment() { - private lateinit var url: TextView - private lateinit var method: TextView - private lateinit var protocol: TextView - private lateinit var status: TextView - private lateinit var response: TextView - private lateinit var ssl: TextView - private lateinit var requestTime: TextView - private lateinit var responseTime: TextView - private lateinit var duration: TextView - private lateinit var requestSize: TextView - private lateinit var responseSize: TextView - private lateinit var totalSize: TextView + private lateinit var overviewBinding: ChuckerFragmentTransactionOverviewBinding private lateinit var viewModel: TransactionViewModel override fun onCreate(savedInstanceState: Bundle?) { @@ -38,48 +29,68 @@ internal class TransactionOverviewFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? = - inflater.inflate(R.layout.chucker_fragment_transaction_overview, container, false) - .also { - url = it.findViewById(R.id.url) - method = it.findViewById(R.id.method) - protocol = it.findViewById(R.id.protocol) - status = it.findViewById(R.id.status) - response = it.findViewById(R.id.response) - ssl = it.findViewById(R.id.ssl) - requestTime = it.findViewById(R.id.request_time) - responseTime = it.findViewById(R.id.response_time) - duration = it.findViewById(R.id.duration) - requestSize = it.findViewById(R.id.request_size) - responseSize = it.findViewById(R.id.response_size) - totalSize = it.findViewById(R.id.total_size) - } + ): View? { + overviewBinding = ChuckerFragmentTransactionOverviewBinding.inflate(inflater, container, false) + return overviewBinding.root + } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - val saveMenuItem = menu.findItem(R.id.save_body) - saveMenuItem.isVisible = false + menu.findItem(R.id.save_body).isVisible = false + viewModel.doesUrlRequireEncoding.observe( + viewLifecycleOwner, + Observer { menu.findItem(R.id.encode_url).isVisible = it } + ) super.onCreateOptionsMenu(menu, inflater) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.transaction.observe( - viewLifecycleOwner, - Observer { transaction -> - url.text = transaction.url - method.text = transaction.method - protocol.text = transaction.protocol - status.text = transaction.status.toString() - response.text = transaction.responseSummaryText - ssl.setText(if (transaction.isSsl) R.string.chucker_yes else R.string.chucker_no) - requestTime.text = transaction.requestDateString - responseTime.text = transaction.responseDateString - duration.text = transaction.durationString - requestSize.text = transaction.requestSizeString - responseSize.text = transaction.responseSizeString - totalSize.text = transaction.totalSizeString + + viewModel.transaction + .combineLatest(viewModel.encodeUrl) + .observe( + viewLifecycleOwner, + Observer { (transaction, encodeUrl) -> + populateUI(transaction, encodeUrl) + } + ) + } + + private fun populateUI(transaction: HttpTransaction?, encodeUrl: Boolean) { + with(overviewBinding) { + url.text = transaction?.getFormattedUrl(encodeUrl) + method.text = transaction?.method + protocol.text = transaction?.protocol + status.text = transaction?.status.toString() + response.text = transaction?.responseSummaryText + when (transaction?.isSsl) { + null -> { + sslGroup.visibility = View.GONE + } + true -> { + sslGroup.visibility = View.VISIBLE + sslValue.setText(R.string.chucker_yes) + } + else -> { + sslGroup.visibility = View.VISIBLE + sslValue.setText(R.string.chucker_no) + } } - ) + if (transaction?.responseTlsVersion != null) { + tlsVersionValue.text = transaction.responseTlsVersion + tlsGroup.visibility = View.VISIBLE + } + if (transaction?.responseCipherSuite != null) { + cipherSuiteValue.text = transaction.responseCipherSuite + cipherSuiteGroup.visibility = View.VISIBLE + } + requestTime.text = transaction?.requestDateString + responseTime.text = transaction?.responseDateString + duration.text = transaction?.durationString + requestSize.text = transaction?.requestSizeString + responseSize.text = transaction?.responseSizeString + totalSize.text = transaction?.totalSizeString + } } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt index bfbefe095..7089f59e5 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt @@ -6,10 +6,10 @@ import android.text.Spanned import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView import androidx.recyclerview.widget.RecyclerView -import com.chuckerteam.chucker.R +import com.chuckerteam.chucker.databinding.ChuckerTransactionItemBodyLineBinding +import com.chuckerteam.chucker.databinding.ChuckerTransactionItemHeadersBinding +import com.chuckerteam.chucker.databinding.ChuckerTransactionItemImageBinding import com.chuckerteam.chucker.internal.support.highlightWithDefinedColors /** @@ -29,16 +29,16 @@ internal class TransactionBodyAdapter( val inflater = LayoutInflater.from(parent.context) return when (viewType) { TYPE_HEADERS -> { - val view = inflater.inflate(R.layout.chucker_transaction_item_headers, parent, false) - TransactionPayloadViewHolder.HeaderViewHolder(view) + val headersItemBinding = ChuckerTransactionItemHeadersBinding.inflate(inflater, parent, false) + TransactionPayloadViewHolder.HeaderViewHolder(headersItemBinding) } TYPE_BODY_LINE -> { - val view = inflater.inflate(R.layout.chucker_transaction_item_body_line, parent, false) - TransactionPayloadViewHolder.BodyLineViewHolder(view) + val bodyItemBinding = ChuckerTransactionItemBodyLineBinding.inflate(inflater, parent, false) + TransactionPayloadViewHolder.BodyLineViewHolder(bodyItemBinding) } else -> { - val view = inflater.inflate(R.layout.chucker_transaction_item_image, parent, false) - TransactionPayloadViewHolder.ImageViewHolder(view) + val imageItemBinding = ChuckerTransactionItemImageBinding.inflate(inflater, parent, false) + TransactionPayloadViewHolder.ImageViewHolder(imageItemBinding) } } } @@ -57,7 +57,7 @@ internal class TransactionBodyAdapter( bodyItems.filterIsInstance() .withIndex() .forEach { (index, item) -> - if (newText in item.line) { + if (item.line.contains(newText, ignoreCase = true)) { item.line.clearSpans() item.line = item.line.toString() .highlightWithDefinedColors(newText, backgroundColor, foregroundColor) @@ -95,29 +95,32 @@ internal class TransactionBodyAdapter( internal sealed class TransactionPayloadViewHolder(view: View) : RecyclerView.ViewHolder(view) { abstract fun bind(item: TransactionPayloadItem) - internal class HeaderViewHolder(view: View) : TransactionPayloadViewHolder(view) { - private val headersView: TextView = view.findViewById(R.id.headers) + internal class HeaderViewHolder( + private val headerBinding: ChuckerTransactionItemHeadersBinding + ) : TransactionPayloadViewHolder(headerBinding.root) { override fun bind(item: TransactionPayloadItem) { if (item is TransactionPayloadItem.HeaderItem) { - headersView.text = item.headers + headerBinding.responseHeaders.text = item.headers } } } - internal class BodyLineViewHolder(view: View) : TransactionPayloadViewHolder(view) { - private val bodyLineView: TextView = view.findViewById(R.id.body_line) + internal class BodyLineViewHolder( + private val bodyBinding: ChuckerTransactionItemBodyLineBinding + ) : TransactionPayloadViewHolder(bodyBinding.root) { override fun bind(item: TransactionPayloadItem) { if (item is TransactionPayloadItem.BodyLineItem) { - bodyLineView.text = item.line + bodyBinding.bodyLine.text = item.line } } } - internal class ImageViewHolder(view: View) : TransactionPayloadViewHolder(view) { - private val binaryDataView: ImageView = view.findViewById(R.id.binary_data) + internal class ImageViewHolder( + private val imageBinding: ChuckerTransactionItemImageBinding + ) : TransactionPayloadViewHolder(imageBinding.root) { override fun bind(item: TransactionPayloadItem) { if (item is TransactionPayloadItem.ImageItem) { - binaryDataView.setImageBitmap(item.image) + imageBinding.binaryData.setImageBitmap(item.image) } } } diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadFragment.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadFragment.kt index 93312550e..66f28140c 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadFragment.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadFragment.kt @@ -6,7 +6,6 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.net.Uri -import android.os.AsyncTask import android.os.Build import android.os.Bundle import android.text.SpannableStringBuilder @@ -15,7 +14,6 @@ import android.view.Menu import android.view.MenuInflater import android.view.View import android.view.ViewGroup -import android.widget.ProgressBar import android.widget.Toast import androidx.annotation.RequiresApi import androidx.appcompat.widget.SearchView @@ -24,20 +22,24 @@ import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.RecyclerView import com.chuckerteam.chucker.R +import com.chuckerteam.chucker.databinding.ChuckerFragmentTransactionPayloadBinding import com.chuckerteam.chucker.internal.data.entity.HttpTransaction import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext private const val GET_FILE_FOR_SAVING_REQUEST_CODE: Int = 43 internal class TransactionPayloadFragment : Fragment(), SearchView.OnQueryTextListener { - private lateinit var progressLoading: ProgressBar - private lateinit var transactionContentList: RecyclerView + private lateinit var payloadBinding: ChuckerFragmentTransactionPayloadBinding private var backgroundSpanColor: Int = Color.YELLOW private var foregroundSpanColor: Int = Color.RED @@ -45,8 +47,8 @@ internal class TransactionPayloadFragment : private var type: Int = 0 private lateinit var viewModel: TransactionViewModel - private var payloadLoaderTask: PayloadLoaderTask? = null - private var fileSaverTask: FileSaverTask? = null + + private val uiScope = MainScope() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -59,27 +61,34 @@ internal class TransactionPayloadFragment : inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? = - inflater.inflate(R.layout.chucker_fragment_transaction_payload, container, false).apply { - transactionContentList = findViewById(R.id.transaction_content) - transactionContentList.isNestedScrollingEnabled = false - progressLoading = findViewById(R.id.progress_loading_transaction) - } + ): View? { + payloadBinding = ChuckerFragmentTransactionPayloadBinding.inflate( + inflater, container, + false + ) + return payloadBinding.root + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.transaction.observe( viewLifecycleOwner, Observer { transaction -> - PayloadLoaderTask(this).execute(Pair(type, transaction)) + if (transaction == null) return@Observer + uiScope.launch { + showProgress() + val result = processPayload(type, transaction) + payloadBinding.responseRecyclerView.adapter = TransactionBodyAdapter(result) + payloadBinding.responseRecyclerView.setHasFixedSize(true) + hideProgress() + } } ) } - override fun onDestroyView() { - super.onDestroyView() - payloadLoaderTask?.cancel(true) - fileSaverTask?.cancel(true) + override fun onDestroy() { + super.onDestroy() + uiScope.cancel() } @SuppressLint("NewApi") @@ -104,6 +113,8 @@ internal class TransactionPayloadFragment : } } + menu.findItem(R.id.encode_url).isVisible = false + super.onCreateOptionsMenu(menu, inflater) } @@ -156,8 +167,14 @@ internal class TransactionPayloadFragment : val uri = resultData?.data val transaction = viewModel.transaction.value if (uri != null && transaction != null) { - fileSaverTask = FileSaverTask(this).apply { - execute(Triple(type, uri, transaction)) + uiScope.launch { + val result = saveToFile(type, uri, transaction) + val toastMessageId = if (result) { + R.string.chucker_file_saved + } else { + R.string.chucker_file_not_saved + } + Toast.makeText(context, toastMessageId, Toast.LENGTH_SHORT).show() } } } @@ -166,8 +183,8 @@ internal class TransactionPayloadFragment : override fun onQueryTextSubmit(query: String): Boolean = false override fun onQueryTextChange(newText: String): Boolean { - val adapter = (transactionContentList.adapter as TransactionBodyAdapter) - if (newText.isNotBlank()) { + val adapter = (payloadBinding.responseRecyclerView.adapter as TransactionBodyAdapter) + if (newText.isNotBlank() && newText.length > NUMBER_OF_IGNORED_SYMBOLS) { adapter.highlightQueryWithColors(newText, backgroundSpanColor, foregroundSpanColor) } else { adapter.resetHighlight() @@ -175,22 +192,23 @@ internal class TransactionPayloadFragment : return true } - /** - * Async task responsible of loading in the background the content of the HTTP request/response. - */ - class PayloadLoaderTask(private val fragment: TransactionPayloadFragment) : - AsyncTask, Unit, List>() { - - override fun onPreExecute() { - val progressBar: ProgressBar? = fragment.view?.findViewById(R.id.progress_loading_transaction) - val recyclerView: RecyclerView? = fragment.view?.findViewById(R.id.transaction_content) - progressBar?.visibility = View.VISIBLE - recyclerView?.visibility = View.INVISIBLE + private fun showProgress() { + payloadBinding.apply { + loadingProgress.visibility = View.VISIBLE + responseRecyclerView.visibility = View.INVISIBLE + } + } + + private fun hideProgress() { + payloadBinding.apply { + loadingProgress.visibility = View.INVISIBLE + responseRecyclerView.visibility = View.VISIBLE + requireActivity().invalidateOptionsMenu() } + } - @Suppress("ComplexMethod") - override fun doInBackground(vararg params: Pair): List { - val (type, transaction) = params[0] + private suspend fun processPayload(type: Int, transaction: HttpTransaction): MutableList { + return withContext(Dispatchers.Default) { val result = mutableListOf() val headersString: String @@ -210,7 +228,9 @@ internal class TransactionPayloadFragment : if (headersString.isNotBlank()) { result.add( TransactionPayloadItem.HeaderItem( - HtmlCompat.fromHtml(headersString, HtmlCompat.FROM_HTML_MODE_LEGACY) + HtmlCompat.fromHtml( + headersString, HtmlCompat.FROM_HTML_MODE_LEGACY + ) ) ) } @@ -220,7 +240,7 @@ internal class TransactionPayloadFragment : if (type == TYPE_RESPONSE && responseBitmap != null) { result.add(TransactionPayloadItem.ImageItem(responseBitmap)) } else if (!isBodyPlainText) { - fragment.context?.getString(R.string.chucker_body_omitted)?.let { + requireContext().getString(R.string.chucker_body_omitted)?.let { result.add(TransactionPayloadItem.BodyLineItem(SpannableStringBuilder.valueOf(it))) } } else { @@ -228,28 +248,15 @@ internal class TransactionPayloadFragment : result.add(TransactionPayloadItem.BodyLineItem(SpannableStringBuilder.valueOf(it))) } } - - return result - } - - override fun onPostExecute(result: List) { - val progressBar: ProgressBar? = fragment.view?.findViewById(R.id.progress_loading_transaction) - val recyclerView: RecyclerView? = fragment.view?.findViewById(R.id.transaction_content) - progressBar?.visibility = View.INVISIBLE - recyclerView?.visibility = View.VISIBLE - recyclerView?.adapter = TransactionBodyAdapter(result) + return@withContext result } } - class FileSaverTask(private val fragment: TransactionPayloadFragment) : - AsyncTask, Unit, Boolean>() { - - @Suppress("NestedBlockDepth") - override fun doInBackground(vararg params: Triple): Boolean { - val (type, uri, transaction) = params[0] + @Suppress("ThrowsCount") + private suspend fun saveToFile(type: Int, uri: Uri, transaction: HttpTransaction): Boolean { + return withContext(Dispatchers.IO) { try { - val context = fragment.context ?: return false - context.contentResolver.openFileDescriptor(uri, "w")?.use { + requireContext().contentResolver.openFileDescriptor(uri, "w")?.use { FileOutputStream(it.fileDescriptor).use { fos -> when (type) { TYPE_REQUEST -> { @@ -272,22 +279,12 @@ internal class TransactionPayloadFragment : } } catch (e: FileNotFoundException) { e.printStackTrace() - return false + return@withContext false } catch (e: IOException) { e.printStackTrace() - return false + return@withContext false } - return true - } - - override fun onPostExecute(isSuccessful: Boolean) { - fragment.fileSaverTask = null - val toastMessageId = if (isSuccessful) { - R.string.chucker_file_saved - } else { - R.string.chucker_file_not_saved - } - Toast.makeText(fragment.context, toastMessageId, Toast.LENGTH_SHORT).show() + return@withContext true } } @@ -295,6 +292,8 @@ internal class TransactionPayloadFragment : private const val ARG_TYPE = "type" private const val TRANSACTION_EXCEPTION = "Transaction not ready" + private const val NUMBER_OF_IGNORED_SYMBOLS = 1 + const val TYPE_REQUEST = 0 const val TYPE_RESPONSE = 1 diff --git a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionViewModel.kt b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionViewModel.kt index 732c6fd90..934502f8f 100644 --- a/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionViewModel.kt +++ b/library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/TransactionViewModel.kt @@ -1,28 +1,54 @@ package com.chuckerteam.chucker.internal.ui.transaction import androidx.lifecycle.LiveData -import androidx.lifecycle.Transformations +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.map import com.chuckerteam.chucker.internal.data.entity.HttpTransaction import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider +import com.chuckerteam.chucker.internal.support.combineLatest -internal class TransactionViewModel(transactionId: Long) : ViewModel() { +internal class TransactionViewModel( + transactionId: Long +) : ViewModel() { - val transactionTitle: LiveData = - Transformations.map(RepositoryProvider.transaction().getTransaction(transactionId)) { - if (it != null) "${it.method} ${it.path}" else "" + private val mutableEncodeUrl = MutableLiveData(false) + + val encodeUrl: LiveData = mutableEncodeUrl + + val transactionTitle: LiveData = RepositoryProvider.transaction() + .getTransaction(transactionId) + .combineLatest(encodeUrl) { transaction, encodeUrl -> + if (transaction != null) "${transaction.method} ${transaction.getFormattedPath(encode = encodeUrl)}" else "" } - val transaction: LiveData = - Transformations.map(RepositoryProvider.transaction().getTransaction(transactionId)) { - it + + val doesUrlRequireEncoding: LiveData = RepositoryProvider.transaction() + .getTransaction(transactionId) + .map { transaction -> + if (transaction == null) { + false + } else { + transaction.getFormattedPath(encode = true) != transaction.getFormattedPath(encode = false) + } } + + val transaction: LiveData = RepositoryProvider.transaction().getTransaction(transactionId) + + fun switchUrlEncoding() = encodeUrl(!encodeUrl.value!!) + + fun encodeUrl(encode: Boolean) { + mutableEncodeUrl.value = encode + } } -internal class TransactionViewModelFactory(private val transactionId: Long = 0L) : +internal class TransactionViewModelFactory( + private val transactionId: Long = 0L +) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { require(modelClass == TransactionViewModel::class.java) { "Cannot create $modelClass" } + @Suppress("UNCHECKED_CAST") return TransactionViewModel(transactionId) as T } } diff --git a/library/src/main/res/drawable/chucker_ic_decoded_url_white.xml b/library/src/main/res/drawable/chucker_ic_decoded_url_white.xml new file mode 100644 index 000000000..043a11f63 --- /dev/null +++ b/library/src/main/res/drawable/chucker_ic_decoded_url_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/library/src/main/res/drawable/chucker_ic_encoded_url_white.xml b/library/src/main/res/drawable/chucker_ic_encoded_url_white.xml new file mode 100644 index 000000000..1da285872 --- /dev/null +++ b/library/src/main/res/drawable/chucker_ic_encoded_url_white.xml @@ -0,0 +1,11 @@ + + + + diff --git a/library/src/main/res/drawable/chucker_ic_http.xml b/library/src/main/res/drawable/chucker_ic_http.xml new file mode 100644 index 000000000..53f13d93b --- /dev/null +++ b/library/src/main/res/drawable/chucker_ic_http.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/library/src/main/res/drawable/chucker_ic_https_grey.xml b/library/src/main/res/drawable/chucker_ic_https.xml similarity index 93% rename from library/src/main/res/drawable/chucker_ic_https_grey.xml rename to library/src/main/res/drawable/chucker_ic_https.xml index 8ffd1fdd8..84a041788 100644 --- a/library/src/main/res/drawable/chucker_ic_https_grey.xml +++ b/library/src/main/res/drawable/chucker_ic_https.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/library/src/main/res/drawable/chucker_ic_save_white.xml b/library/src/main/res/drawable/chucker_ic_save_white.xml index 56e36736f..b728b571b 100644 --- a/library/src/main/res/drawable/chucker_ic_save_white.xml +++ b/library/src/main/res/drawable/chucker_ic_save_white.xml @@ -6,4 +6,4 @@ - \ No newline at end of file + diff --git a/library/src/main/res/layout/chucker_activity_main.xml b/library/src/main/res/layout/chucker_activity_main.xml index aaac8d24f..7c8ab6600 100644 --- a/library/src/main/res/layout/chucker_activity_main.xml +++ b/library/src/main/res/layout/chucker_activity_main.xml @@ -3,16 +3,14 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:layoutDirection="ltr" android:orientation="vertical"> - + tools:context="com.chuckerteam.chucker.internal.ui.throwable.ThrowableActivity"> - - + - + @@ -40,7 +39,7 @@ app:layout_behavior="@string/appbar_scrolling_view_behavior"> - - + @@ -39,7 +36,7 @@ diff --git a/library/src/main/res/layout/chucker_fragment_error_list.xml b/library/src/main/res/layout/chucker_fragment_throwable_list.xml similarity index 86% rename from library/src/main/res/layout/chucker_fragment_error_list.xml rename to library/src/main/res/layout/chucker_fragment_throwable_list.xml index c0dad621e..7f05ad12f 100644 --- a/library/src/main/res/layout/chucker_fragment_error_list.xml +++ b/library/src/main/res/layout/chucker_fragment_throwable_list.xml @@ -3,19 +3,18 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layoutDirection="ltr" android:layout_height="match_parent"> + tools:listitem="@layout/chucker_list_item_throwable" /> + android:text="@string/chucker_throwable_tutorial" /> - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content" + android:padding="@dimen/chucker_doub_grid"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/library/src/main/res/layout/chucker_fragment_transaction_payload.xml b/library/src/main/res/layout/chucker_fragment_transaction_payload.xml index e4d0a6e84..d0b055209 100755 --- a/library/src/main/res/layout/chucker_fragment_transaction_payload.xml +++ b/library/src/main/res/layout/chucker_fragment_transaction_payload.xml @@ -5,11 +5,10 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:animateLayoutChanges="true" - android:layoutDirection="ltr" tools:context="com.chuckerteam.chucker.internal.ui.transaction.TransactionPayloadFragment"> diff --git a/library/src/main/res/layout/chucker_list_item_error.xml b/library/src/main/res/layout/chucker_list_item_throwable.xml similarity index 97% rename from library/src/main/res/layout/chucker_list_item_error.xml rename to library/src/main/res/layout/chucker_list_item_throwable.xml index 5fd0a6d68..a0827cf87 100644 --- a/library/src/main/res/layout/chucker_list_item_error.xml +++ b/library/src/main/res/layout/chucker_list_item_throwable.xml @@ -5,7 +5,6 @@ android:layout_height="wrap_content" android:background="?android:attr/selectableItemBackground" android:orientation="vertical" - android:layoutDirection="ltr" android:padding="8dp"> @@ -54,15 +52,13 @@ android:layout_width="@dimen/chucker_doub_grid" android:layout_height="@dimen/chucker_doub_grid" android:contentDescription="@string/chucker_ssl" - android:src="@drawable/chucker_ic_https_grey" - android:tint="@color/chucker_color_primary" - android:visibility="gone" app:layout_constraintStart_toStartOf="@+id/path" - app:layout_constraintTop_toBottomOf="@+id/path" - tools:visibility="visible" /> + app:layout_constraintTop_toTopOf="@+id/host" + app:layout_constraintBottom_toBottomOf="@id/host" + tools:src="@drawable/chucker_ic_https" /> \ No newline at end of file + android:layout_marginHorizontal="@dimen/chucker_doub_grid" + android:textIsSelectable="true" + tools:text="Content-Type: application/json" /> \ No newline at end of file diff --git a/library/src/main/res/layout/chucker_transaction_item_image.xml b/library/src/main/res/layout/chucker_transaction_item_image.xml index e599b93fc..88d0bd252 100644 --- a/library/src/main/res/layout/chucker_transaction_item_image.xml +++ b/library/src/main/res/layout/chucker_transaction_item_image.xml @@ -1,6 +1,6 @@ + + - \ No newline at end of file + diff --git a/library/src/main/res/menu/chucker_transactions_list.xml b/library/src/main/res/menu/chucker_transactions_list.xml index fecb4a941..36f7203dd 100644 --- a/library/src/main/res/menu/chucker_transactions_list.xml +++ b/library/src/main/res/menu/chucker_transactions_list.xml @@ -10,4 +10,4 @@ android:id="@+id/clear" android:icon="@drawable/chucker_ic_delete_white" app:showAsAction="always" /> - \ No newline at end of file + diff --git a/library/src/main/res/values-night/colors.xml b/library/src/main/res/values-night/colors.xml index ccf4a2a14..e610df9ce 100644 --- a/library/src/main/res/values-night/colors.xml +++ b/library/src/main/res/values-night/colors.xml @@ -4,7 +4,7 @@ #121212 #121212 #121212 - #69f0ae + #59CC94 #cf6679 #000000 #000000 diff --git a/library/src/main/res/values-v21/styles.xml b/library/src/main/res/values-v21/styles.xml index b6a4b78ec..9f0086f22 100644 --- a/library/src/main/res/values-v21/styles.xml +++ b/library/src/main/res/values-v21/styles.xml @@ -1,12 +1,9 @@ - - + + \ No newline at end of file diff --git a/library/src/main/res/values/colors.xml b/library/src/main/res/values/colors.xml index 15b7137f8..1ca6a6fb8 100644 --- a/library/src/main/res/values/colors.xml +++ b/library/src/main/res/values/colors.xml @@ -4,7 +4,7 @@ #002f6c #ffffff #ffffff - #00e676 + #009E09 #F44336 #ffffff #000000 diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml index c81ab68a1..8da76a26d 100644 --- a/library/src/main/res/values/strings.xml +++ b/library/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ Chucker Recording HTTP activity Clear + Encode URL Cancel Overview Request @@ -12,6 +13,8 @@ Protocol Status SSL + TLS version + Cipher Suite Request time Response time Duration @@ -34,17 +37,17 @@ Network Errors This tab displays network requests recorded with the ChuckerInterceptor. Add the interceptor to your OkHttp client to reap the benefits. - This tab displays error recorded with the ChuckerCollector.onError method. Call this method when an error is thrown to check your stacktrace live. - Chucker network requests - Chucker throwables - Share error details + This tab displays throwables recorded with the ChuckerCollector.onError method. Call this method when an error is thrown to check your stacktrace live. + Chucker network requests + Chucker throwables + Share throwable details Share transaction details - Error details + Throwable details Transaction details - + Do you want to clear complete network calls history? - Do you want to clear complete errors history? - Check the setup instructions on GitHub + Do you want to clear complete throwables history? + Check the setup instructions on GitHub Setup binary data The request isn\'t ready for sharing or saving diff --git a/library/src/main/res/values/styles.xml b/library/src/main/res/values/styles.xml index 12d96bddf..60fcd0855 100644 --- a/library/src/main/res/values/styles.xml +++ b/library/src/main/res/values/styles.xml @@ -1,20 +1,22 @@ - + - + @@ -25,13 +27,11 @@ \ No newline at end of file diff --git a/library/src/test/java/com/chuckerteam/chucker/ChuckerInterceptorDelegate.kt b/library/src/test/java/com/chuckerteam/chucker/ChuckerInterceptorDelegate.kt new file mode 100644 index 000000000..080348e33 --- /dev/null +++ b/library/src/test/java/com/chuckerteam/chucker/ChuckerInterceptorDelegate.kt @@ -0,0 +1,59 @@ +package com.chuckerteam.chucker + +import android.content.Context +import com.chuckerteam.chucker.api.ChuckerCollector +import com.chuckerteam.chucker.api.ChuckerInterceptor +import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.support.FileFactory +import io.mockk.every +import io.mockk.mockk +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicLong +import okhttp3.Interceptor +import okhttp3.Response + +internal class ChuckerInterceptorDelegate( + fileFactory: FileFactory, + maxContentLength: Long = 250000L, + headersToRedact: Set = emptySet() +) : Interceptor { + private val idGenerator = AtomicLong() + private val transactions = CopyOnWriteArrayList() + + private val mockContext = mockk { + every { getString(any()) } returns "" + } + private val mockCollector = mockk { + every { onRequestSent(any()) } returns Unit + every { onResponseReceived(any()) } answers { + val transaction = (args[0] as HttpTransaction) + transaction.id = idGenerator.getAndIncrement() + transactions.add(transaction) + } + } + + private val chucker = ChuckerInterceptor( + context = mockContext, + collector = mockCollector, + maxContentLength = maxContentLength, + headersToRedact = headersToRedact, + fileFactory = fileFactory + ) + + internal fun expectTransaction(): HttpTransaction { + if (transactions.isEmpty()) { + throw AssertionError("Expected transaction but was empty.") + } + return transactions.removeAt(0) + } + + internal fun expectNoTransactions() { + if (transactions.isNotEmpty()) { + throw AssertionError("Expected no transactions but found ${transactions.size}") + } + } + + override fun intercept(chain: Interceptor.Chain): Response { + return chucker.intercept(chain) + } +} diff --git a/library/src/test/java/com/chuckerteam/chucker/TestTransactionFactory.kt b/library/src/test/java/com/chuckerteam/chucker/TestTransactionFactory.kt new file mode 100644 index 000000000..85682f272 --- /dev/null +++ b/library/src/test/java/com/chuckerteam/chucker/TestTransactionFactory.kt @@ -0,0 +1,95 @@ +package com.chuckerteam.chucker + +import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import java.util.Date + +object TestTransactionFactory { + + internal fun createTransaction(method: String): HttpTransaction { + return HttpTransaction( + id = 0, + requestDate = Date(1300000).time, + responseDate = Date(1300300).time, + tookMs = 1000L, + protocol = "HTTP", + method = method, + url = "http://localhost/getUsers", + host = "localhost", + path = "/getUsers", + scheme = "", + responseTlsVersion = "", + responseCipherSuite = "", + requestContentLength = 1000L, + requestContentType = "application/json", + requestHeaders = null, + requestBody = null, + isRequestBodyPlainText = true, + responseCode = 200, + responseMessage = "OK", + error = null, + responseContentLength = 1000L, + responseContentType = "application/json", + responseHeaders = null, + responseBody = + """{"field": "value"}""", + isResponseBodyPlainText = true, + responseImageData = null + ) + } + + val expectedGetHttpTransaction = + """ + URL: http://localhost/getUsers + Method: GET + Protocol: HTTP + Status: Complete + Response: 200 OK + SSL: No + + Request time: ${Date(1300000)} + Response time: ${Date(1300300)} + Duration: 1000 ms + + Request size: 1.0 kB + Response size: 1.0 kB + Total size: 2.0 kB + + ---------- Request ---------- + + + + ---------- Response ---------- + + { + "field": "value" + } + """.trimIndent() + + val expectedHttpPostTransaction = + """ + URL: http://localhost/getUsers + Method: POST + Protocol: HTTP + Status: Complete + Response: 200 OK + SSL: No + + Request time: ${Date(1300000)} + Response time: ${Date(1300300)} + Duration: 1000 ms + + Request size: 1.0 kB + Response size: 1.0 kB + Total size: 2.0 kB + + ---------- Request ---------- + + + + ---------- Response ---------- + + { + "field": "value" + } + """.trimIndent() +} diff --git a/library/src/test/java/com/chuckerteam/chucker/TestUtils.kt b/library/src/test/java/com/chuckerteam/chucker/TestUtils.kt index f0bfb7398..a7b84adff 100644 --- a/library/src/test/java/com/chuckerteam/chucker/TestUtils.kt +++ b/library/src/test/java/com/chuckerteam/chucker/TestUtils.kt @@ -1,7 +1,12 @@ package com.chuckerteam.chucker +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import com.chuckerteam.chucker.internal.support.hasBody import java.io.File +import okhttp3.Response import okio.Buffer +import okio.ByteString import okio.Okio fun getResourceFile(file: String): Buffer { @@ -9,3 +14,45 @@ fun getResourceFile(file: String): Buffer { writeAll(Okio.buffer(Okio.source(File("./src/test/resources/$file")))) } } + +fun Response.readByteStringBody(): ByteString? { + return if (hasBody()) { + body()?.source()?.use { it.readByteString() } + } else { + null + } +} + +fun LiveData.test(test: LiveDataRecord.() -> Unit) { + val observer = RecordingObserver() + observeForever(observer) + LiveDataRecord(observer).test() + removeObserver(observer) + observer.records.clear() +} + +class LiveDataRecord internal constructor( + private val observer: RecordingObserver +) { + fun expectData(): T { + if (observer.records.isEmpty()) { + throw AssertionError("Expected data but was empty.") + } + return observer.records.removeAt(0) + } + + fun expectNoData() { + if (observer.records.isNotEmpty()) { + val data = observer.records[0] + throw AssertionError("Expected no data but was $data.") + } + } +} + +internal class RecordingObserver : Observer { + val records = mutableListOf() + + override fun onChanged(data: T) { + records += data + } +} diff --git a/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt b/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt index 56eb02dec..648e47fbe 100644 --- a/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt +++ b/library/src/test/java/com/chuckerteam/chucker/api/ChuckerInterceptorTest.kt @@ -1,12 +1,13 @@ package com.chuckerteam.chucker.api -import android.content.Context +import com.chuckerteam.chucker.ChuckerInterceptorDelegate import com.chuckerteam.chucker.getResourceFile -import com.chuckerteam.chucker.internal.data.entity.HttpTransaction -import io.mockk.every -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertTrue +import com.chuckerteam.chucker.internal.support.FileFactory +import com.chuckerteam.chucker.readByteStringBody +import com.google.common.truth.Truth.assertThat +import java.io.File +import java.net.HttpURLConnection.HTTP_NO_CONTENT +import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.mockwebserver.MockResponse @@ -15,53 +16,77 @@ import okio.Buffer import okio.ByteString import okio.GzipSink import org.junit.Rule -import org.junit.Test +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource class ChuckerInterceptorTest { + enum class ClientFactory { + APPLICATION { + override fun create(interceptor: Interceptor): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + } + }, + NETWORK { + override fun create(interceptor: Interceptor): OkHttpClient { + return OkHttpClient.Builder() + .addNetworkInterceptor(interceptor) + .build() + } + }; + + abstract fun create(interceptor: Interceptor): OkHttpClient + } + @get:Rule val server = MockWebServer() private val serverUrl = server.url("/") // Starts server implicitly - - private var transaction: HttpTransaction? = null - private val mockContext = mockk { - every { getString(any()) } returns "" - } - private val mockCollector = mockk() { - every { onRequestSent(any()) } returns Unit - every { onResponseReceived(any()) } answers { - transaction = args[0] as HttpTransaction + private lateinit var chuckerInterceptor: ChuckerInterceptorDelegate + + @BeforeEach + fun setUp(@TempDir tempDir: File) { + val fileFactory = object : FileFactory { + override fun create(): File { + return File(tempDir, "testFile") + } } + chuckerInterceptor = ChuckerInterceptorDelegate(fileFactory) } - private val client = OkHttpClient.Builder() - .addInterceptor(ChuckerInterceptor(mockContext, mockCollector)) - .build() - - @Test - fun imageResponse_isAvailableToChucker() { + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun imageResponse_isAvailableToChucker(factory: ClientFactory) { val image = getResourceFile("sample_image.png") - server.enqueue(MockResponse().addHeader("Content-Type:image/jpeg").setBody(image)) + server.enqueue(MockResponse().addHeader("Content-Type: image/jpeg").setBody(image)) val request = Request.Builder().url(serverUrl).build() val expectedBody = image.snapshot() - client.newCall(request).execute() + val client = factory.create(chuckerInterceptor) + client.newCall(request).execute().readByteStringBody() + val responseBody = ByteString.of(*chuckerInterceptor.expectTransaction().responseImageData!!) - assertEquals(expectedBody, ByteString.of(*transaction!!.responseImageData!!)) + assertThat(responseBody).isEqualTo(expectedBody) } - @Test - fun imageResponse_isAvailableToTheEndConsumer() { + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun imageResponse_isAvailableToTheEndConsumer(factory: ClientFactory) { val image = getResourceFile("sample_image.png") - server.enqueue(MockResponse().addHeader("Content-Type:image/jpeg").setBody(image)) + server.enqueue(MockResponse().addHeader("Content-Type: image/jpeg").setBody(image)) val request = Request.Builder().url(serverUrl).build() val expectedBody = image.snapshot() + val client = factory.create(chuckerInterceptor) val responseBody = client.newCall(request).execute().body()!!.source().readByteString() - assertEquals(expectedBody, responseBody) + assertThat(responseBody).isEqualTo(expectedBody) } - @Test - fun gzippedBody_isGunzippedForChucker() { + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun gzippedBody_isGunzippedForChucker(factory: ClientFactory) { val bytes = Buffer().apply { writeUtf8("Hello, world!") } val gzippedBytes = Buffer().apply { GzipSink(this).use { sink -> sink.write(bytes, bytes.size()) } @@ -69,14 +94,17 @@ class ChuckerInterceptorTest { server.enqueue(MockResponse().addHeader("Content-Encoding: gzip").setBody(gzippedBytes)) val request = Request.Builder().url(serverUrl).build() - client.newCall(request).execute() + val client = factory.create(chuckerInterceptor) + client.newCall(request).execute().readByteStringBody() + val transaction = chuckerInterceptor.expectTransaction() - assertTrue(transaction!!.isResponseBodyPlainText) - assertEquals("Hello, world!", transaction!!.responseBody) + assertThat(transaction.isResponseBodyPlainText).isTrue() + assertThat(transaction.responseBody).isEqualTo("Hello, world!") } - @Test - fun gzippedBody_isGunzippedForTheEndConsumer() { + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun gzippedBody_isGunzippedForTheEndConsumer(factory: ClientFactory) { val bytes = Buffer().apply { writeUtf8("Hello, world!") } val gzippedBytes = Buffer().apply { GzipSink(this).use { sink -> sink.write(bytes, bytes.size()) } @@ -84,8 +112,88 @@ class ChuckerInterceptorTest { server.enqueue(MockResponse().addHeader("Content-Encoding: gzip").setBody(gzippedBytes)) val request = Request.Builder().url(serverUrl).build() + val client = factory.create(chuckerInterceptor) val responseBody = client.newCall(request).execute().body()!!.source().readByteString() - assertEquals("Hello, world!", responseBody.utf8()) + assertThat(responseBody.utf8()).isEqualTo("Hello, world!") + } + + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun gzippedBody_withNoContent_isTransparentForChucker(factory: ClientFactory) { + server.enqueue(MockResponse().addHeader("Content-Encoding: gzip").setResponseCode(HTTP_NO_CONTENT)) + val request = Request.Builder().url(serverUrl).build() + + val client = factory.create(chuckerInterceptor) + client.newCall(request).execute().readByteStringBody() + val transaction = chuckerInterceptor.expectTransaction() + + assertThat(transaction.responseBody).isNull() + } + + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun gzippedBody_withNoContent_isTransparentForEndConsumer(factory: ClientFactory) { + server.enqueue(MockResponse().addHeader("Content-Encoding: gzip").setResponseCode(HTTP_NO_CONTENT)) + val request = Request.Builder().url(serverUrl).build() + + val client = factory.create(chuckerInterceptor) + val responseBody = client.newCall(request).execute().readByteStringBody() + + assertThat(responseBody).isNull() + } + + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun regularBody_isAvailableForChucker(factory: ClientFactory) { + val body = Buffer().apply { writeUtf8("Hello, world!") } + server.enqueue(MockResponse().setBody(body)) + val request = Request.Builder().url(serverUrl).build() + + val client = factory.create(chuckerInterceptor) + client.newCall(request).execute().readByteStringBody() + val transaction = chuckerInterceptor.expectTransaction() + + assertThat(transaction.isResponseBodyPlainText).isTrue() + assertThat(transaction.responseBody).isEqualTo("Hello, world!") + } + + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun regularBody_isAvailableForTheEndConsumer(factory: ClientFactory) { + val body = Buffer().apply { writeUtf8("Hello, world!") } + server.enqueue(MockResponse().setBody(body)) + val request = Request.Builder().url(serverUrl).build() + + val client = factory.create(chuckerInterceptor) + val responseBody = client.newCall(request).execute().readByteStringBody()!! + + assertThat(responseBody.utf8()).isEqualTo("Hello, world!") + } + + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun regularBody_withNoContent_isAvailableForChucker(factory: ClientFactory) { + server.enqueue(MockResponse().setResponseCode(HTTP_NO_CONTENT)) + val request = Request.Builder().url(serverUrl).build() + + val client = factory.create(chuckerInterceptor) + client.newCall(request).execute().readByteStringBody() + val transaction = chuckerInterceptor.expectTransaction() + + assertThat(transaction.isResponseBodyPlainText).isTrue() + assertThat(transaction.responseBody).isNull() + } + + @ParameterizedTest + @EnumSource(value = ClientFactory::class) + fun regularBody_withNoContent_isAvailableForTheEndConsumer(factory: ClientFactory) { + server.enqueue(MockResponse().setResponseCode(HTTP_NO_CONTENT)) + val request = Request.Builder().url(serverUrl).build() + + val client = factory.create(chuckerInterceptor) + val responseBody = client.newCall(request).execute().readByteStringBody() + + assertThat(responseBody).isNull() } } diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/FormatUtilsSharedTextTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/FormatUtilsSharedTextTest.kt new file mode 100644 index 000000000..efb0c6f16 --- /dev/null +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/FormatUtilsSharedTextTest.kt @@ -0,0 +1,51 @@ +package com.chuckerteam.chucker.internal.support + +import android.content.Context +import com.chuckerteam.chucker.R +import com.chuckerteam.chucker.TestTransactionFactory +import com.google.common.truth.Truth +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test + +class FormatUtilsSharedTextTest { + + private val contextMock = mockk { + every { getString(R.string.chucker_url) } returns "URL" + every { getString(R.string.chucker_method) } returns "Method" + every { getString(R.string.chucker_protocol) } returns "Protocol" + every { getString(R.string.chucker_status) } returns "Status" + every { getString(R.string.chucker_response) } returns "Response" + every { getString(R.string.chucker_ssl) } returns "SSL" + every { getString(R.string.chucker_yes) } returns "Yes" + every { getString(R.string.chucker_no) } returns "No" + every { getString(R.string.chucker_request_time) } returns "Request time" + every { getString(R.string.chucker_response_time) } returns "Response time" + every { getString(R.string.chucker_duration) } returns "Duration" + every { getString(R.string.chucker_request_size) } returns "Request size" + every { getString(R.string.chucker_response_size) } returns "Response size" + every { getString(R.string.chucker_total_size) } returns "Total size" + every { getString(R.string.chucker_request) } returns "Request" + every { getString(R.string.chucker_body_omitted) } returns "(encoded or binary body omitted)" + } + + @Test + fun getShareTextForGetTransaction() { + val shareText = getShareText("GET") + Truth.assertThat(shareText).isEqualTo(TestTransactionFactory.expectedGetHttpTransaction) + } + + @Test + fun getShareTextForPostTransaction() { + val shareText = getShareText("POST") + Truth.assertThat(shareText).isEqualTo(TestTransactionFactory.expectedHttpPostTransaction) + } + + private fun getShareText(method: String): String { + return FormatUtils.getShareText( + contextMock, + TestTransactionFactory.createTransaction(method), + false + ) + } +} diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/FormatUtilsTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/FormatUtilsTest.kt index 1ff4168aa..d89555810 100644 --- a/library/src/test/java/com/chuckerteam/chucker/internal/support/FormatUtilsTest.kt +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/FormatUtilsTest.kt @@ -1,47 +1,55 @@ package com.chuckerteam.chucker.internal.support -import org.junit.jupiter.api.Assertions.assertEquals +import com.chuckerteam.chucker.TestTransactionFactory +import com.chuckerteam.chucker.internal.data.entity.HttpHeader +import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test class FormatUtilsTest { + private val exampleHeadersList = listOf( + HttpHeader("Accept", "text/html"), + HttpHeader("Authorization", "exampleToken") + ) + @Test fun testFormatJson_withNullValues() { val parsedJson = FormatUtils.formatJson( - """ - { - "field": null - } - """.trimIndent() + """{ "field": null }""" ) - assertEquals( + assertThat(parsedJson).isEqualTo( """ { "field": null } - """.trimIndent(), - parsedJson + """.trimIndent() ) } @Test fun testFormatJson_withEmptyValues() { val parsedJson = FormatUtils.formatJson( + """{ "field": "" }""" + ) + + assertThat(parsedJson).isEqualTo( """ { "field": "" } """.trimIndent() ) + } - assertEquals( - """ - { - "field": "" - } - """.trimIndent(), - parsedJson + @Test + fun testFormatJson_withInvalidJson() { + val parsedJson = FormatUtils.formatJson( + """[{ "field": null }""" + ) + + assertThat(parsedJson).isEqualTo( + """[{ "field": null }""" ) } @@ -51,14 +59,158 @@ class FormatUtilsTest { """{ "field1": "something", "field2": "else" }""" ) - assertEquals( + assertThat(parsedJson).isEqualTo( """ { "field1": "something", "field2": "else" } - """.trimIndent(), - parsedJson + """.trimIndent() ) } + + @Test + fun testFormatJsonArray_willPrettyPrint() { + val parsedJson = FormatUtils.formatJson( + """[{ "field1": "something1", "field2": "else1" }, { "field1": "something2", "field2": "else2" }]""" + ) + + assertThat(parsedJson).isEqualTo( + """ + [ + { + "field1": "something1", + "field2": "else1" + }, + { + "field1": "something2", + "field2": "else2" + } + ] + """.trimIndent() + ) + } + + @Test + fun testFormatHeaders_withNullValues() { + val result = FormatUtils.formatHeaders(null, false) + assertThat(result).isEmpty() + } + + @Test + fun testFormatHeaders_withEmptyValues() { + val result = FormatUtils.formatHeaders(listOf(), false) + assertThat(result).isEmpty() + } + + @Test + fun testFormatHeaders_withoutMarkup() { + val result = FormatUtils.formatHeaders(exampleHeadersList, false) + val expected = "Accept: text/html\nAuthorization: exampleToken\n" + assertThat(result).isEqualTo(expected) + } + + @Test + fun testFormatHeaders_withMarkup() { + val result = FormatUtils.formatHeaders(exampleHeadersList, true) + val expected = " Accept: text/html
Authorization: exampleToken
" + assertThat(result).isEqualTo(expected) + } + + @Test + fun testFormatByteCount_zeroBytes() { + val resultNonSi = FormatUtils.formatByteCount(0, false) + val resultSi = FormatUtils.formatByteCount(0, true) + val expected = "0 B" + assertThat(resultNonSi).isEqualTo(expected) + assertThat(resultSi).isEqualTo(expected) + } + + @Test + fun testFormatByteCount_oneKiloByte() { + testFormatByteCount(1024L, "1.0 kB", "1.0 KiB") + } + + @Test + fun testFormatByteCount_oneKiloByteSi() { + testFormatByteCount(1023L, "1.0 kB", "1023 B") + } + + private fun testFormatByteCount( + byteCountToTest: Long, + expectedSi: String, + expectedNonSi: String + ) { + val resultNonSi = FormatUtils.formatByteCount(byteCountToTest, false) + val resultSi = FormatUtils.formatByteCount(byteCountToTest, true) + assertThat(resultNonSi).isEqualTo(expectedNonSi) + assertThat(resultSi).isEqualTo(expectedSi) + } + + @Test + fun testFormatXml_emptyString() { + assertThat(FormatUtils.formatXml("")).isEmpty() + } + + @Test + fun testFormatXml_properXml() { + val xml = + """ + value + """.trimIndent() + val expected = + """ + + value + + """.trimIndent() + assertThat(FormatUtils.formatXml(xml)).isEqualTo(expected) + } + + @Test + fun testCurlCommandWithoutHeaders() { + getRequestMethods().forEach { method -> + val transaction = TestTransactionFactory.createTransaction(method) + val curlСommand = FormatUtils.getShareCurlCommand(transaction) + val expectedCurlCommand = "curl -X $method http://localhost/getUsers" + assertThat(curlСommand).isEqualTo(expectedCurlCommand) + } + } + + @Test + fun testCurlCommandWithHeaders() { + val httpHeaders = ArrayList() + for (i in 0 until 5) { + httpHeaders.add(HttpHeader("name$i", "value$i")) + } + val dummyHeaders = JsonConverter.instance.toJson(httpHeaders) + + getRequestMethods().forEach { method -> + val transaction = TestTransactionFactory.createTransaction(method) + transaction.requestHeaders = dummyHeaders + val curlСommand = FormatUtils.getShareCurlCommand(transaction) + var expectedCurlCommand = "curl -X $method" + httpHeaders.forEach { header -> + expectedCurlCommand += " -H \"${header.name}: ${header.value}\"" + } + expectedCurlCommand += " http://localhost/getUsers" + assertThat(curlСommand).isEqualTo(expectedCurlCommand) + } + } + + @Test + fun testCurlPostAndPutCommandWithRequestBody() { + getRequestMethods().filter { method -> + method == "POST" || method == "PUT" + }.forEach { method -> + val dummyRequestBody = "{thing:put}" + val transaction = TestTransactionFactory.createTransaction(method) + transaction.requestBody = dummyRequestBody + val curlСommand = FormatUtils.getShareCurlCommand(transaction) + val expectedCurlCommand = "curl -X $method --data $'$dummyRequestBody' http://localhost/getUsers" + assertThat(curlСommand).isEqualTo(expectedCurlCommand) + } + } + + private fun getRequestMethods() = arrayOf("GET", "POST", "PUT", "DELETE") } diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/FormattedUrlTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/FormattedUrlTest.kt new file mode 100644 index 000000000..9a9b80cb3 --- /dev/null +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/FormattedUrlTest.kt @@ -0,0 +1,91 @@ +package com.chuckerteam.chucker.internal.support + +import com.google.common.truth.Truth.assertThat +import okhttp3.HttpUrl +import org.junit.Test + +class FormattedUrlTest { + @Test + fun encodedUrl_withAllParams_isFormattedProperly() { + val url = HttpUrl.get("https://www.example.com/path/to some/resource?q=\"Hello, world!\"") + + val formattedUrl = FormattedUrl.fromHttpUrl(url, encoded = true) + + assertThat(formattedUrl.scheme).isEqualTo("https") + assertThat(formattedUrl.host).isEqualTo("www.example.com") + assertThat(formattedUrl.path).isEqualTo("/path/to%20some/resource") + assertThat(formattedUrl.query).isEqualTo("q=%22Hello,%20world!%22") + assertThat(formattedUrl.pathWithQuery).isEqualTo("/path/to%20some/resource?q=%22Hello,%20world!%22") + assertThat(formattedUrl.url).isEqualTo("https://www.example.com/path/to%20some/resource?q=%22Hello,%20world!%22") + } + + @Test + fun encodedUrl_withoutPath_isFormattedProperly() { + val url = HttpUrl.get("https://www.example.com?q=\"Hello, world!\"") + + val formattedUrl = FormattedUrl.fromHttpUrl(url, encoded = true) + + assertThat(formattedUrl.scheme).isEqualTo("https") + assertThat(formattedUrl.host).isEqualTo("www.example.com") + assertThat(formattedUrl.path).isEmpty() + assertThat(formattedUrl.query).isEqualTo("q=%22Hello,%20world!%22") + assertThat(formattedUrl.pathWithQuery).isEqualTo("?q=%22Hello,%20world!%22") + assertThat(formattedUrl.url).isEqualTo("https://www.example.com?q=%22Hello,%20world!%22") + } + + @Test + fun encodedUrl_withoutQuery_isFormattedProperly() { + val url = HttpUrl.get("https://www.example.com/path/to some/resource") + + val formattedUrl = FormattedUrl.fromHttpUrl(url, encoded = true) + + assertThat(formattedUrl.scheme).isEqualTo("https") + assertThat(formattedUrl.host).isEqualTo("www.example.com") + assertThat(formattedUrl.path).isEqualTo("/path/to%20some/resource") + assertThat(formattedUrl.query).isEmpty() + assertThat(formattedUrl.pathWithQuery).isEqualTo("/path/to%20some/resource") + assertThat(formattedUrl.url).isEqualTo("https://www.example.com/path/to%20some/resource") + } + + @Test + fun decodedUrl_withAllParams_isFormattedProperly() { + val url = HttpUrl.get("https://www.example.com/path/to some/resource?q=\"Hello, world!\"") + + val formattedUrl = FormattedUrl.fromHttpUrl(url, encoded = false) + + assertThat(formattedUrl.scheme).isEqualTo("https") + assertThat(formattedUrl.host).isEqualTo("www.example.com") + assertThat(formattedUrl.path).isEqualTo("/path/to some/resource") + assertThat(formattedUrl.query).isEqualTo("q=\"Hello, world!\"") + assertThat(formattedUrl.pathWithQuery).isEqualTo("/path/to some/resource?q=\"Hello, world!\"") + assertThat(formattedUrl.url).isEqualTo("https://www.example.com/path/to some/resource?q=\"Hello, world!\"") + } + + @Test + fun decodedUrl_withoutPath_isFormattedProperly() { + val url = HttpUrl.get("https://www.example.com?q=\"Hello, world!\"") + + val formattedUrl = FormattedUrl.fromHttpUrl(url, encoded = false) + + assertThat(formattedUrl.scheme).isEqualTo("https") + assertThat(formattedUrl.host).isEqualTo("www.example.com") + assertThat(formattedUrl.path).isEmpty() + assertThat(formattedUrl.query).isEqualTo("q=\"Hello, world!\"") + assertThat(formattedUrl.pathWithQuery).isEqualTo("?q=\"Hello, world!\"") + assertThat(formattedUrl.url).isEqualTo("https://www.example.com?q=\"Hello, world!\"") + } + + @Test + fun decodedUrl_withoutQuery_isFormattedProperly() { + val url = HttpUrl.get("https://www.example.com/path/to some/resource") + + val formattedUrl = FormattedUrl.fromHttpUrl(url, encoded = false) + + assertThat(formattedUrl.scheme).isEqualTo("https") + assertThat(formattedUrl.host).isEqualTo("www.example.com") + assertThat(formattedUrl.path).isEqualTo("/path/to some/resource") + assertThat(formattedUrl.query).isEmpty() + assertThat(formattedUrl.pathWithQuery).isEqualTo("/path/to some/resource") + assertThat(formattedUrl.url).isEqualTo("https://www.example.com/path/to some/resource") + } +} diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/IOUtilsTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/IOUtilsTest.kt index b7f1f327b..1f7d38f66 100644 --- a/library/src/test/java/com/chuckerteam/chucker/internal/support/IOUtilsTest.kt +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/IOUtilsTest.kt @@ -2,6 +2,7 @@ package com.chuckerteam.chucker.internal.support import android.content.Context import com.chuckerteam.chucker.R +import com.google.common.truth.Truth.assertThat import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -9,10 +10,6 @@ import java.io.EOFException import java.nio.charset.Charset import java.util.stream.Stream import okio.Buffer -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNotEquals -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest @@ -28,7 +25,7 @@ class IOUtilsTest { fun isPlaintext_withEmptyBuffer_returnsTrue() { val buffer = Buffer() - assertTrue(ioUtils.isPlaintext(buffer)) + assertThat(ioUtils.isPlaintext(buffer)).isTrue() } @Test @@ -36,7 +33,7 @@ class IOUtilsTest { val buffer = Buffer() buffer.writeString(" ", Charset.defaultCharset()) - assertTrue(ioUtils.isPlaintext(buffer)) + assertThat(ioUtils.isPlaintext(buffer)).isTrue() } @Test @@ -44,7 +41,7 @@ class IOUtilsTest { val buffer = Buffer() buffer.writeString("just a string", Charset.defaultCharset()) - assertTrue(ioUtils.isPlaintext(buffer)) + assertThat(ioUtils.isPlaintext(buffer)).isTrue() } @Test @@ -52,7 +49,7 @@ class IOUtilsTest { val buffer = Buffer() buffer.writeByte(0x11000000) - assertFalse(ioUtils.isPlaintext(buffer)) + assertThat(ioUtils.isPlaintext(buffer)).isFalse() } @Test @@ -61,9 +58,10 @@ class IOUtilsTest { every { mockBuffer.size() } returns 100L every { mockBuffer.copyTo(any(), any(), any()) } throws EOFException() - assertFalse(ioUtils.isPlaintext(mockBuffer)) + assertThat(ioUtils.isPlaintext(mockBuffer)).isFalse() } + @Test fun readFromBuffer_contentNotTruncated() { val mockBuffer = mockk() every { mockBuffer.size() } returns 100L @@ -71,7 +69,7 @@ class IOUtilsTest { val result = ioUtils.readFromBuffer(mockBuffer, Charset.defaultCharset(), 200L) - assertEquals("{ \"message\": \"just a mock body\"}", result) + assertThat(result).isEqualTo("{ \"message\": \"just a mock body\"}") verify { mockBuffer.readString(100L, Charset.defaultCharset()) } } @@ -84,7 +82,7 @@ class IOUtilsTest { val result = ioUtils.readFromBuffer(mockBuffer, Charset.defaultCharset(), 50L) - assertEquals("{ \"message\": \"just a mock body\"}\\n\\n--- Content truncated ---", result) + assertThat(result).isEqualTo("{ \"message\": \"just a mock body\"}\\n\\n--- Content truncated ---") verify { mockBuffer.readString(50L, Charset.defaultCharset()) } } @@ -97,7 +95,7 @@ class IOUtilsTest { val result = ioUtils.readFromBuffer(mockBuffer, Charset.defaultCharset(), 200L) - assertEquals("\\n\\n--- Unexpected end of content ---", result) + assertThat(result).isEqualTo("\\n\\n--- Unexpected end of content ---") } @Test @@ -106,7 +104,7 @@ class IOUtilsTest { val nativeSource = ioUtils.getNativeSource(mockBuffer, false) - assertEquals(mockBuffer, nativeSource) + assertThat(nativeSource).isEqualTo(mockBuffer) } @Test @@ -114,17 +112,16 @@ class IOUtilsTest { val mockBuffer = mockk() val nativeSource = ioUtils.getNativeSource(mockBuffer, true) - - assertNotEquals(mockBuffer, nativeSource) + assertThat(nativeSource).isNotEqualTo(mockBuffer) } @ParameterizedTest(name = "{0} must be supported? {1}") @MethodSource("supportedEncodingSource") @DisplayName("Check if body encoding is supported") - fun bodyHasSupportedEncoding(encoding: String?, supported: Boolean) { + fun bodyHasSupportedEncoding(encoding: String?, isSupported: Boolean) { val result = ioUtils.bodyHasSupportedEncoding(encoding) - assertEquals(supported, result) + assertThat(result).isEqualTo(isSupported) } companion object { diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/JsonConverterTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/JsonConverterTest.kt index e07cf0778..f1ec4c03c 100644 --- a/library/src/test/java/com/chuckerteam/chucker/internal/support/JsonConverterTest.kt +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/JsonConverterTest.kt @@ -1,8 +1,6 @@ package com.chuckerteam.chucker.internal.support -import java.util.Date -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue +import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test class JsonConverterTest { @@ -12,49 +10,20 @@ class JsonConverterTest { val instance1 = JsonConverter.instance val instance2 = JsonConverter.instance - assertTrue(instance1 == instance2) - } - - @Test - fun testGsonConfiguration_willParseDateTime() { - val json = JsonConverter.instance.toJson(DateTestClass(Date(0))) - assertEquals( - """ - { - "date": "Jan 1, 1970 1:00:00 AM" - } - """.trimIndent(), - json - ) - } - - @Test - fun testGsonConfiguration_willUseLowerCaseWithUnderscores() { - val json = JsonConverter.instance.toJson(NamingTestClass("aCamelCaseString")) - assertEquals( - """ - { - "a_long_name_with_camel_case": "aCamelCaseString" - } - """.trimIndent(), - json - ) + assertThat(instance1).isEqualTo(instance2) } @Test fun testGsonConfiguration_willSerializeNulls() { val json = JsonConverter.instance.toJson(NullTestClass(null)) - assertEquals( + assertThat(json).isEqualTo( """ { "string": null } - """.trimIndent(), - json + """.trimIndent() ) } - inner class DateTestClass(var date: Date) inner class NullTestClass(var string: String?) - inner class NamingTestClass(var aLongNameWithCamelCase: String) } diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/LiveDataCombineLatestTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/LiveDataCombineLatestTest.kt new file mode 100644 index 000000000..4336db214 --- /dev/null +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/LiveDataCombineLatestTest.kt @@ -0,0 +1,72 @@ +package com.chuckerteam.chucker.internal.support + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import com.chuckerteam.chucker.test +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test + +class LiveDataCombineLatestTest { + @get:Rule val instantExecutorRule = InstantTaskExecutorRule() + + private val inputA = MutableLiveData() + private val inputB = MutableLiveData() + + private val upstream = inputA.combineLatest(inputB) + + @Test + fun firstEmptyValue_preventsDownstreamEmissions() { + upstream.test { + inputB.value = 1 + inputB.value = 2 + inputB.value = 3 + + expectNoData() + } + } + + @Test + fun secondEmptyValue_preventsDownstreamEmissions() { + upstream.test { + inputA.value = true + inputA.value = false + + expectNoData() + } + } + + @Test + fun bothEmittedValues_areCombinedDownstream() { + upstream.test { + inputA.value = true + inputB.value = 1 + + assertThat(expectData()).isEqualTo(true to 1) + } + } + + @Test + fun lastFirstValue_isCombinedWithNewestSecondValues() { + upstream.test { + inputA.value = true + inputB.value = 1 + assertThat(expectData()).isEqualTo(true to 1) + + inputB.value = 2 + assertThat(expectData()).isEqualTo(true to 2) + } + } + + @Test + fun lastSecondValue_isCombinedWithNewestFirstValues() { + upstream.test { + inputA.value = true + inputB.value = 1 + assertThat(expectData()).isEqualTo(true to 1) + + inputA.value = false + assertThat(expectData()).isEqualTo(false to 1) + } + } +} diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/LiveDataDistinctUntilChangedTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/LiveDataDistinctUntilChangedTest.kt new file mode 100644 index 000000000..0480635ef --- /dev/null +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/LiveDataDistinctUntilChangedTest.kt @@ -0,0 +1,88 @@ +package com.chuckerteam.chucker.internal.support + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.distinctUntilChanged +import com.chuckerteam.chucker.test +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test + +class LiveDataDistinctUntilChangedTest { + @get:Rule val instantExecutorRule = InstantTaskExecutorRule() + + @Test + fun initialUpstreamData_isEmittedDownstream() { + val upstream = MutableLiveData(null) + + upstream.distinctUntilChanged().test { + assertThat(expectData()).isNull() + } + } + + @Test + fun emptyUpstream_isNotEmittedDownstream() { + val upstream = MutableLiveData() + + upstream.distinctUntilChanged().test { + expectNoData() + } + } + + @Test + fun newDistinctData_isEmittedDownstream() { + val upstream = MutableLiveData() + + upstream.distinctUntilChanged().test { + upstream.value = 1 + assertThat(expectData()).isEqualTo(1) + + upstream.value = 2 + assertThat(expectData()).isEqualTo(2) + + upstream.value = null + assertThat(expectData()).isNull() + + upstream.value = 2 + assertThat(expectData()).isEqualTo(2) + } + } + + @Test + fun newIndistinctData_isNotEmittedDownstream() { + val upstream = MutableLiveData() + + upstream.distinctUntilChanged().test { + upstream.value = null + assertThat(expectData()).isNull() + + upstream.value = null + expectNoData() + + upstream.value = "" + assertThat(expectData()).isEmpty() + + upstream.value = "" + expectNoData() + } + } + + @Test + fun customFunction_canBeUsedToDistinguishData() { + val upstream = MutableLiveData>() + + upstream.distinctUntilChanged { old, new -> old.first == new.first }.test { + upstream.value = 1 to "" + assertThat(expectData()).isEqualTo(1 to "") + + upstream.value = 1 to "a" + expectNoData() + + upstream.value = 2 to "b" + assertThat(expectData()).isEqualTo(2 to "b") + + upstream.value = 3 to "b" + assertThat(expectData()).isEqualTo(3 to "b") + } + } +} diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/OkHttpUtilsTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/OkHttpUtilsTest.kt index ae7a14a79..54fde2cb8 100644 --- a/library/src/test/java/com/chuckerteam/chucker/internal/support/OkHttpUtilsTest.kt +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/OkHttpUtilsTest.kt @@ -1,13 +1,11 @@ package com.chuckerteam.chucker.internal.support +import com.google.common.truth.Truth.assertThat import io.mockk.every import io.mockk.mockk import okhttp3.Headers import okhttp3.Request import okhttp3.Response -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class OkHttpUtilsTest { @@ -17,23 +15,23 @@ class OkHttpUtilsTest { val mockResponse = mockk() every { mockResponse.header("Content-Length") } returns null - assertEquals(-1, mockResponse.contentLenght) + assertThat(mockResponse.contentLength).isEqualTo(-1) } @Test - fun contentLength_withZeroLenght_returnsZero() { + fun contentLength_withZeroLength_returnsZero() { val mockResponse = mockk() every { mockResponse.header("Content-Length") } returns "0" - assertEquals(0L, mockResponse.contentLenght) + assertThat(mockResponse.contentLength).isEqualTo(0L) } @Test - fun contentLength_withRealLenght_returnsValue() { + fun contentLength_withRealLength_returnsValue() { val mockResponse = mockk() every { mockResponse.header("Content-Length") } returns "42" - assertEquals(42L, mockResponse.contentLenght) + assertThat(mockResponse.contentLength).isEqualTo(42L) } @Test @@ -41,7 +39,7 @@ class OkHttpUtilsTest { val mockResponse = mockk() every { mockResponse.header("Transfer-Encoding") } returns "gzip" - assertFalse(mockResponse.isChunked) + assertThat(mockResponse.isChunked).isFalse() } @Test @@ -50,7 +48,7 @@ class OkHttpUtilsTest { every { mockResponse.header("Content-Length") } returns null every { mockResponse.header("Transfer-Encoding") } returns null - assertFalse(mockResponse.isChunked) + assertThat(mockResponse.isChunked).isFalse() } @Test @@ -58,7 +56,7 @@ class OkHttpUtilsTest { val mockResponse = mockk() every { mockResponse.header("Transfer-Encoding") } returns "chunked" - assertTrue(mockResponse.isChunked) + assertThat(mockResponse.isChunked).isTrue() } @Test @@ -66,7 +64,7 @@ class OkHttpUtilsTest { val mockResponse = mockk() every { mockResponse.headers() } returns Headers.of("Content-Encoding", "gzip") - assertTrue(mockResponse.isGzipped) + assertThat(mockResponse.isGzipped).isTrue() } @Test @@ -74,7 +72,7 @@ class OkHttpUtilsTest { val mockResponse = mockk() every { mockResponse.headers() } returns Headers.of("Content-Encoding", "identity") - assertFalse(mockResponse.isGzipped) + assertThat(mockResponse.isGzipped).isFalse() } @Test @@ -82,7 +80,7 @@ class OkHttpUtilsTest { val mockRequest = mockk() every { mockRequest.headers() } returns Headers.of("Content-Encoding", "gzip") - assertTrue(mockRequest.isGzipped) + assertThat(mockRequest.isGzipped).isTrue() } @Test @@ -90,6 +88,6 @@ class OkHttpUtilsTest { val mockRequest = mockk() every { mockRequest.headers() } returns Headers.of("Content-Encoding", "identity") - assertFalse(mockRequest.isGzipped) + assertThat(mockRequest.isGzipped).isFalse() } } diff --git a/library/src/test/java/com/chuckerteam/chucker/internal/support/TeeSourceTest.kt b/library/src/test/java/com/chuckerteam/chucker/internal/support/TeeSourceTest.kt new file mode 100644 index 000000000..17e662f07 --- /dev/null +++ b/library/src/test/java/com/chuckerteam/chucker/internal/support/TeeSourceTest.kt @@ -0,0 +1,185 @@ +package com.chuckerteam.chucker.internal.support + +import com.google.common.truth.Truth.assertThat +import java.io.File +import java.io.IOException +import kotlin.random.Random +import okio.Buffer +import okio.ByteString +import okio.Okio +import okio.Source +import okio.Timeout +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.io.TempDir + +class TeeSourceTest { + private val teeCallback = TestTeeCallback() + + @Test + fun bytesReadFromUpstream_areAvailableDownstream(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = TestSource() + val downstream = Buffer() + + val teeSource = TeeSource(testSource, testFile, teeCallback) + Okio.buffer(teeSource).use { it.readAll(downstream) } + + assertThat(downstream.snapshot()).isEqualTo(testSource.content) + } + + @Test + fun bytesReadFromUpstream_areAvailableToSideChannel(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = TestSource() + val downstream = Buffer() + + val teeSource = TeeSource(testSource, testFile, teeCallback) + Okio.buffer(teeSource).use { it.readAll(downstream) } + + assertThat(teeCallback.fileContent).isEqualTo(testSource.content) + } + + @Test + fun bytesPulledFromUpstream_arePulledToSideChannel_alongTheDownstream(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val repetitions = Random.nextInt(1, 100) + // Okio uses 8KiB as a single size read. + val testSource = TestSource(8_192 * repetitions) + + val teeSource = TeeSource(testSource, testFile, teeCallback) + Okio.buffer(teeSource).use { source -> + repeat(repetitions) { index -> + source.readByteString(8_192) + + val subContent = testSource.content.substring(0, (index + 1) * 8_192) + Okio.buffer(Okio.source(testFile)).use { + assertThat(it.readByteString()).isEqualTo(subContent) + } + } + } + } + + @Test + fun tooBigSources_informOfFailures_inSideChannel(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = TestSource(10_000) + val downstream = Buffer() + + val teeSource = TeeSource(testSource, testFile, teeCallback, readBytesLimit = 9_999) + Okio.buffer(teeSource).use { it.readAll(downstream) } + + assertThat(teeCallback.exception) + .hasMessageThat() + .isEqualTo("Capacity of 9999 bytes exceeded") + } + + @Test + fun tooBigSources_doNotResultInSuccess_ifTheUpstreamIsExhausted(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = TestSource(10_000) + val downstream = Buffer() + + val teeSource = TeeSource(testSource, testFile, teeCallback, readBytesLimit = 9_999) + Okio.buffer(teeSource).use { it.readAll(downstream) } + + assertThat(teeCallback.isSuccess).isFalse() + } + + @Test + fun tooBigSources_areAvailableDownstream(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = TestSource(10_000) + val downstream = Buffer() + + val teeSource = TeeSource(testSource, testFile, teeCallback, readBytesLimit = 9_999) + Okio.buffer(teeSource).use { it.readAll(downstream) } + + assertThat(downstream.snapshot()).isEqualTo(testSource.content) + } + + @Test + fun readException_informOfFailures_inSideChannel(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + val testSource = ThrowingSource + + val teeSource = TeeSource(testSource, testFile, teeCallback) + + assertThrows { + Okio.buffer(teeSource).use { it.readByte() } + } + + assertThat(teeCallback.exception) + .hasMessageThat() + .isEqualTo("Hello there!") + } + + @Test + fun notConsumedUpstream_isNotConsideredSuccess(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + // Okio uses 8KiB as a single size read. + val testSource = TestSource(8_192 * 2) + + val teeSource = TeeSource(testSource, testFile, teeCallback) + Okio.buffer(teeSource).use { source -> + source.readByteString(8_192) + } + + assertThat(teeCallback.exception) + .hasMessageThat() + .isEqualTo("Upstream was not fully consumed") + } + + @Test + fun partiallyReadBytesFromUpstream_areAvailableToSideChannel(@TempDir tempDir: File) { + val testFile = File(tempDir, "testFile") + // Okio uses 8KiB as a single size read. + val testSource = TestSource(8_192 * 2) + + val teeSource = TeeSource(testSource, testFile, teeCallback) + Okio.buffer(teeSource).use { source -> + source.readByteString(8_192) + } + + assertThat(teeCallback.fileContent).isEqualTo(testSource.content.substring(0, 8_192)) + } + + private class TestSource(contentLength: Int = 1_000) : Source { + val content: ByteString = ByteString.of(*Random.nextBytes(contentLength)) + private val buffer = Buffer().apply { write(content) } + + override fun read(sink: Buffer, byteCount: Long): Long = buffer.read(sink, byteCount) + + override fun close() = buffer.close() + + override fun timeout(): Timeout = buffer.timeout() + } + + private object ThrowingSource : Source { + override fun read(sink: Buffer, byteCount: Long): Long { + throw IOException("Hello there!") + } + + override fun close() = Unit + + override fun timeout(): Timeout = Timeout.NONE + } + + private class TestTeeCallback : TeeSource.Callback { + private var file: File? = null + val fileContent get() = file?.let { Okio.buffer(Okio.source(it)).readByteString() } + var exception: IOException? = null + var isSuccess = false + private set + + override fun onSuccess(file: File) { + isSuccess = true + this.file = file + } + + override fun onFailure(exception: IOException, file: File) { + this.exception = exception + this.file = file + } + } +} diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index f24579121..8cff3729f 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -9,7 +9,6 @@ android:allowBackup="false" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:supportsRtl="true" android:theme="@style/AppTheme" tools:ignore="GoogleAppIndexingWarning"> diff --git a/sample/src/main/java/com/chuckerteam/chucker/sample/HttpBinClient.kt b/sample/src/main/java/com/chuckerteam/chucker/sample/HttpBinClient.kt index 9fe51b54b..ad790f8fb 100644 --- a/sample/src/main/java/com/chuckerteam/chucker/sample/HttpBinClient.kt +++ b/sample/src/main/java/com/chuckerteam/chucker/sample/HttpBinClient.kt @@ -86,6 +86,8 @@ class HttpBinClient( deny().enqueue(cb) cache("Mon").enqueue(cb) cache(30).enqueue(cb) + redirectTo("https://ascii.cl?parameter=%22Click+on+%27URL+Encode%27%21%22").enqueue(cb) + redirectTo("https://ascii.cl?parameter=\"Click on 'URL Encode'!\"").enqueue(cb) } }