From a6ed2d4296d3d1ff700a0965f012f6f4d14dfb92 Mon Sep 17 00:00:00 2001 From: Volodymyr Buberenko Date: Sat, 4 Apr 2020 22:24:10 +0300 Subject: [PATCH] Release 3.2.0 (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove scroll flags (#210) * Fix/gradle properties (#211) * Allow Gradle parallel build * Fix version name * Fix for curl command (#214) * Fix R8 minification crash on TransactionViewModel creation. (#219) * Big resources renaming (#216) * Fix clear action crash when application is dead (#222) * Fix for crash on Save transaction action (#221) * Show warning is transaction is null, fix crash in Save action * Uncomment sample transactions * Replace mulptiple returning with multiple throw due to detekt issue * Add message into IOException, update string for request being not ready * Fix for NPE in NotificationHelper (#223) * Add additional check fo transaction being not null before getting its notificationText * Extract transaction item from transactionBuffer * ViewModel refactoring (#220) * Update ViewModel dependency, refactor TransactionViewModel * Dependencies clean up * Switch to ViewModel on the main screen * Fix depleting bytes from the response. (#226) * Use HttpUrl instead of Uri for parsing URL data. * Do not read image sources directly from the response. * Simplify gzip logic. * Move gzip checks from IoUtils to OkHttpUtils. * Remove unused 'Response.hasBody()' extension. * Update library/src/main/java/com/chuckerteam/chucker/internal/support/OkHttpUtils.kt * Revert resource renaming (#227) * Revert renaming * Add changelgos for 3.1.2 (#230) * Add missing section to release 3.1.1 and 3.1.2 (#232) * Update Github templates (#235) * Update templates * Remove redundant dot * Remove default `no` from the checkbox * Switch to platform defined HTTP code constants (#238) * Add instruction for checkbox selection (#237) * Fix HttpHeader serialization when obfuscation is enabled (#240) * Update README (#243) * Add Action to validate the Gradle Wrapper (#245) * Gradle to 6.2 (#247) * Do not refresh transaction when it is not being affected. (#246) * Do not refresh transaction when it is not being affected. * Use correct null-aware comparison for HttpTransaction. * Add switching between encoded and decoded URL formats. (#233) * Add switching between encoded and decoded URL formats. * Make URL encoding transaction specific. * Change test name for upcoming #244 PR. * Use LiveDataRecord for combineLatest tests. * Properly switch encoding and decoding URL. * Show encoding icon only when it is viable. * Add encoded URL samples to HttpBinClient. * Avoid using 'this' scoping mechanism for URL. * Fix typo in feature request comment (#251) * RTL Support (#208) * Remove ltr forcing and replace ScrollView in Overview * Replace Overview layout, add rtl support for it * Add textDirection and textAlignment property for API 21+ * Fix host textview constraints * Replace android:src with app:srcCompat * Update ids for layouts to avoid clashes * Update Material components to stable * Remove supportsRTL tag from Manifest, update Gradle plugin * Styles update * Remove supportsRTL from library manifest * Revert usage of supportVectorDrawables to avoid crashes on APIs 16-19 due to notifications icons * Fix lint issue with vector drawable * Response search fixes (#256) * Fix response search to be case insensitive * Add minimum required symbols * Fix invalid options menu in response fragment * Feature/tls info (#252) * Add UI for TLS info * Implement logic for retrieving TLS info * Address code review feedback * Switch views to ViewBinding (#253) * Switch to ViewBinding in activities * Switch to ViewBinding in ErrorsAdapter, add formattedDate field into Throwable models * Transaction adapter switch to ViewBinding * Remove variable for formatted date from models * Switch to ViewBinding in TransactionPayloadAdapter * Switch to ViewBinding in TransactionPaayload and TransactionOverviewFragments * Switch list fragments to ViewBinding * Fix link for tutorial opening * Rename views * Address code review feedback * Hide SSL field if isSSL is null * Libs update (#260) * Update tools versions * JUnit update * Feature/truth (#258) * Add Truth, update part of test classes * Convert other tests to use Truth, fix date parser test * Add Truth assertions to FormatUtilsTest, fix ktlint issue * Update assertions to a proper syntax * Add missing ThrowableViewModel (#257) * Add Error ViewModel, update title in TransactionActivity in onCreate * Switch from errors to throwable naming to have a uniform naming style * Rename toolbar title * Migrating from Travis to GH Actions (#262) * Setup GH Actions * Run only on Linux * Remove Travis File * Run only gradlew directly * Update targetSDK and Kotlin (#264) * Add stale builds canceller (#265) * Add filters * Update Gradle wrapper validation workflow * Update pre-merge workflow * Fixed various Lints (#268) * fixed typos * fixed KDocs * Replace Travis badge with GH Actions badge (#269) * Remove redundant JvmName (#274) * Fix margins and paddings for payload items (#277) * Add selective interception. (#279) * Add selective interception. * Update README.md. * Align formatting in README with other points. * Avoid header name duplication. * Strip interception header also in the no-op library. * UX improvements (#275) * Add icon for non-https transactions * Update secondary color to be more contrast * Simplify protocol resources setting * Add tests to format utils (#281) * add tests to format utils * fixes after code review * formatting fix Co-authored-by: adammasyk * format utils test refactor (#285) * format utils test refactor * share text test refactor * Migrate to Kotlin Coroutines (#270) * Add coroutine as a dependency in build.gradle * Migrate AsyncTasks to kotlin coroutines * Migrate executors with the coroutines in repositories * Multi cast upstream response for Chucker consumption. (#267) * Multi cast response upstream for Chucker consumption. * Read buffer prefix before potentially gunzipping it. * Inform Chucker about unprocessed responses. * Simplify multi casting logic. * Move read offset to a variable. * Inline one-line method. * Give better control over TeeSource read results. * Add documentation to TeeSource. * Close side channel when capacity is exceeded. Co-authored-by: Volodymyr Buberenko * Remove unnecessary mock method. (#289) * removed redundant Gson configurations (#290) * increased test coverage for format utils (#291) Co-authored-by: Karthik R * added few test cases for json formatting (#295) * Properly handle unexhausted network responses (#288) * Handle properly not consumed upstream body. * Handle IO issues while reading from file. * Update dependencies (#296) * Update depencies * Update OkHttp to 3.12.10 * Handle empty and missing response bodies. (#250) * Add failing test cases. * Remove unused const. * Gzip response body if it was gunzipped. * Add test cases for regular bodies in Chucker. * Fix rule formatting. * Use proper name for application interceptor. * Return original response downstream. * Account for no content with gzip encoding. * Use Truth for Chucker tests. * Honor empty plaintext bodies. * Revert changes to HttpBinClient. * Update library/src/test/java/com/chuckerteam/chucker/ChuckerInterceptorDelegate.kt * Update library/src/main/java/com/chuckerteam/chucker/internal/support/OkHttpUtils.kt Co-authored-by: Nicola Corti Co-authored-by: Volodymyr Buberenko * Add hasFixed size to RecyclerViews (#297) * Detekt to 1.7.4 (#299) * Revert "Add selective interception. (#279)" (#300) This reverts commit d14ed642b5bcff55e08b7976bf5701a0cc45f585. * Prepare 3.2.0 (#298) * Update versions info * Update Changelog * Fix links and update date Co-authored-by: Michał Sikora Co-authored-by: Nicola Corti Co-authored-by: Sergey Chelombitko <119192+technoir42@users.noreply.github.com> Co-authored-by: Michał Sikora Co-authored-by: Hitanshu Dhawan Co-authored-by: adammasyk Co-authored-by: adammasyk Co-authored-by: Nikhil Chaudhari Co-authored-by: karthik rk Co-authored-by: Karthik R --- .github/ISSUE_TEMPLATE/bug_report.md | 24 +- .github/ISSUE_TEMPLATE/feature_request.md | 17 +- .github/PULL_REQUEST_TEMPLATE | 20 +- .../workflows/gradle-wrapper-validation.yml | 20 + .github/workflows/pre-merge.yaml | 18 + .travis.yml | 25 - CHANGELOG.md | 71 +++ README.md | 16 +- build.gradle | 32 +- gradle.properties | 8 +- gradle/kotlin-static-analysis.gradle | 3 +- gradle/wrapper/gradle-wrapper.jar | Bin 55190 -> 58695 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 51 +- gradlew.bat | 18 +- library/build.gradle | 10 + library/src/main/AndroidManifest.xml | 4 +- .../chucker/api/ChuckerCollector.kt | 13 +- .../chucker/api/ChuckerInterceptor.kt | 149 +++-- .../chucker/api/RetentionManager.kt | 9 +- .../internal/data/entity/HttpHeader.kt | 7 +- .../internal/data/entity/HttpTransaction.kt | 63 +- .../data/entity/HttpTransactionTuple.kt | 14 + .../HttpTransactionDatabaseRepository.kt | 34 +- .../repository/HttpTransactionRepository.kt | 8 +- .../RecordedThrowableDatabaseRepository.kt | 19 +- .../repository/RecordedThrowableRepository.kt | 8 +- .../data/repository/RepositoryProvider.kt | 4 +- .../internal/data/room/ChuckerDatabase.kt | 2 +- .../internal/data/room/HttpTransactionDao.kt | 8 +- .../data/room/RecordedThrowableDao.kt | 8 +- .../support/AndroidCacheFileFactory.kt | 16 + .../internal/support/ClearDatabaseService.kt | 11 +- .../chucker/internal/support/FileFactory.kt | 7 + .../chucker/internal/support/FormatUtils.kt | 4 +- .../chucker/internal/support/FormattedUrl.kt | 49 ++ .../chucker/internal/support/JsonConverter.kt | 5 - .../chucker/internal/support/LiveDataUtils.kt | 68 +++ .../internal/support/NotificationHelper.kt | 4 +- .../chucker/internal/support/OkHttpUtils.kt | 31 +- .../internal/support/SearchHighlightUtil.kt | 2 +- .../chucker/internal/support/TeeSource.kt | 101 +++ .../chucker/internal/ui/HomePageAdapter.kt | 6 +- .../chucker/internal/ui/MainActivity.kt | 63 +- .../chucker/internal/ui/MainViewModel.kt | 24 +- .../chucker/internal/ui/error/ErrorAdapter.kt | 77 --- .../ThrowableActivity.kt} | 97 ++- .../internal/ui/throwable/ThrowableAdapter.kt | 66 ++ .../ThrowableListFragment.kt} | 61 +- .../ui/throwable/ThrowableViewModel.kt | 25 + .../ui/transaction/ProtocolResources.kt | 10 + .../ui/transaction/TransactionActivity.kt | 49 +- .../ui/transaction/TransactionAdapter.kt | 114 ++-- .../ui/transaction/TransactionListFragment.kt | 43 +- .../TransactionOverviewFragment.kt | 105 ++-- .../transaction/TransactionPayloadAdapter.kt | 41 +- .../transaction/TransactionPayloadFragment.kt | 137 +++-- .../ui/transaction/TransactionViewModel.kt | 44 +- .../drawable/chucker_ic_decoded_url_white.xml | 10 + .../drawable/chucker_ic_encoded_url_white.xml | 11 + .../src/main/res/drawable/chucker_ic_http.xml | 10 + ...ic_https_grey.xml => chucker_ic_https.xml} | 2 +- .../res/drawable/chucker_ic_save_white.xml | 2 +- .../main/res/layout/chucker_activity_main.xml | 4 +- ...ror.xml => chucker_activity_throwable.xml} | 17 +- .../layout/chucker_activity_transaction.xml | 13 +- ...ml => chucker_fragment_throwable_list.xml} | 11 +- .../chucker_fragment_transaction_list.xml | 7 +- .../chucker_fragment_transaction_overview.xml | 574 ++++++++++-------- .../chucker_fragment_transaction_payload.xml | 9 +- ...or.xml => chucker_list_item_throwable.xml} | 1 - .../layout/chucker_list_item_transaction.xml | 20 +- .../chucker_transaction_item_body_line.xml | 2 +- .../chucker_transaction_item_headers.xml | 8 +- .../layout/chucker_transaction_item_image.xml | 2 +- ...hucker_error.xml => chucker_throwable.xml} | 0 ...s_list.xml => chucker_throwables_list.xml} | 0 .../src/main/res/menu/chucker_transaction.xml | 9 +- .../res/menu/chucker_transactions_list.xml | 2 +- library/src/main/res/values-night/colors.xml | 2 +- library/src/main/res/values-v21/styles.xml | 13 +- library/src/main/res/values/colors.xml | 2 +- library/src/main/res/values/strings.xml | 19 +- library/src/main/res/values/styles.xml | 32 +- .../chucker/ChuckerInterceptorDelegate.kt | 59 ++ .../chucker/TestTransactionFactory.kt | 95 +++ .../java/com/chuckerteam/chucker/TestUtils.kt | 47 ++ .../chucker/api/ChuckerInterceptorTest.kt | 182 ++++-- .../support/FormatUtilsSharedTextTest.kt | 51 ++ .../internal/support/FormatUtilsTest.kt | 190 +++++- .../internal/support/FormattedUrlTest.kt | 91 +++ .../chucker/internal/support/IOUtilsTest.kt | 31 +- .../internal/support/JsonConverterTest.kt | 39 +- .../support/LiveDataCombineLatestTest.kt | 72 +++ .../LiveDataDistinctUntilChangedTest.kt | 88 +++ .../internal/support/OkHttpUtilsTest.kt | 28 +- .../chucker/internal/support/TeeSourceTest.kt | 185 ++++++ sample/src/main/AndroidManifest.xml | 1 - .../chucker/sample/HttpBinClient.kt | 2 + 99 files changed, 2769 insertions(+), 1119 deletions(-) create mode 100644 .github/workflows/gradle-wrapper-validation.yml create mode 100644 .github/workflows/pre-merge.yaml delete mode 100644 .travis.yml create mode 100644 library/src/main/java/com/chuckerteam/chucker/internal/support/AndroidCacheFileFactory.kt create mode 100644 library/src/main/java/com/chuckerteam/chucker/internal/support/FileFactory.kt create mode 100644 library/src/main/java/com/chuckerteam/chucker/internal/support/FormattedUrl.kt create mode 100644 library/src/main/java/com/chuckerteam/chucker/internal/support/LiveDataUtils.kt create mode 100644 library/src/main/java/com/chuckerteam/chucker/internal/support/TeeSource.kt delete mode 100644 library/src/main/java/com/chuckerteam/chucker/internal/ui/error/ErrorAdapter.kt rename library/src/main/java/com/chuckerteam/chucker/internal/ui/{error/ErrorActivity.kt => throwable/ThrowableActivity.kt} (54%) create mode 100644 library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableAdapter.kt rename library/src/main/java/com/chuckerteam/chucker/internal/ui/{error/ErrorListFragment.kt => throwable/ThrowableListFragment.kt} (56%) create mode 100644 library/src/main/java/com/chuckerteam/chucker/internal/ui/throwable/ThrowableViewModel.kt create mode 100644 library/src/main/java/com/chuckerteam/chucker/internal/ui/transaction/ProtocolResources.kt create mode 100644 library/src/main/res/drawable/chucker_ic_decoded_url_white.xml create mode 100644 library/src/main/res/drawable/chucker_ic_encoded_url_white.xml create mode 100644 library/src/main/res/drawable/chucker_ic_http.xml rename library/src/main/res/drawable/{chucker_ic_https_grey.xml => chucker_ic_https.xml} (93%) rename library/src/main/res/layout/{chucker_activity_error.xml => chucker_activity_throwable.xml} (84%) rename library/src/main/res/layout/{chucker_fragment_error_list.xml => chucker_fragment_throwable_list.xml} (86%) rename library/src/main/res/layout/{chucker_list_item_error.xml => chucker_list_item_throwable.xml} (97%) rename library/src/main/res/menu/{chucker_error.xml => chucker_throwable.xml} (100%) rename library/src/main/res/menu/{chucker_errors_list.xml => chucker_throwables_list.xml} (100%) create mode 100644 library/src/test/java/com/chuckerteam/chucker/ChuckerInterceptorDelegate.kt create mode 100644 library/src/test/java/com/chuckerteam/chucker/TestTransactionFactory.kt create mode 100644 library/src/test/java/com/chuckerteam/chucker/internal/support/FormatUtilsSharedTextTest.kt create mode 100644 library/src/test/java/com/chuckerteam/chucker/internal/support/FormattedUrlTest.kt create mode 100644 library/src/test/java/com/chuckerteam/chucker/internal/support/LiveDataCombineLatestTest.kt create mode 100644 library/src/test/java/com/chuckerteam/chucker/internal/support/LiveDataDistinctUntilChangedTest.kt create mode 100644 library/src/test/java/com/chuckerteam/chucker/internal/support/TeeSourceTest.kt 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 87b738cbd051603d91cc39de6cb000dd98fe6b02..f3d88b1c2faf2fc91d853cd5d4242b5547257070 100644 GIT binary patch delta 25668 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

r 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 Hp`iW;?oWjv delta 22507 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 + - 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) } }