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)
}
}