├── .circleci └── config.yml ├── .gitattributes ├── .gitignore ├── Gemfile ├── LICENSE ├── README.md ├── build.gradle ├── dependencies.gradle ├── docs ├── Step10.png ├── Step11.png ├── Step12.png ├── rxredux.png ├── step0.png ├── step1.png ├── step13.png ├── step2.png ├── step3.png ├── step4.png ├── step5.png ├── step6.png ├── step7.png ├── step8.png └── step9.png ├── fastlane ├── Appfile ├── Fastfile ├── Screengrabfile └── metadata │ └── android │ ├── en-US │ └── images │ │ └── phoneScreenshots │ │ ├── Screengrab_PopularRepositoriesActivity_State_1_1534635673219.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_2_1534635674502.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_3_1534635676297.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_4_1534635677659.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_5_1534635679569.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_6_1534635680926.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_7_1534635682782.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_8_1534635684113.png │ │ └── Screengrab_PopularRepositoriesActivity_State_9_1534635685796.png │ ├── fr-FR │ └── images │ │ └── phoneScreenshots │ │ ├── Screengrab_PopularRepositoriesActivity_State_1_1534635691480.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_2_1534635692844.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_3_1534635694693.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_4_1534635696123.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_5_1534635698092.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_6_1534635699473.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_7_1534635701379.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_8_1534635702731.png │ │ └── Screengrab_PopularRepositoriesActivity_State_9_1534635704369.png │ ├── ja-JP │ └── images │ │ └── phoneScreenshots │ │ ├── Screengrab_PopularRepositoriesActivity_State_1_1534635710565.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_2_1534635711883.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_3_1534635713677.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_4_1534635715072.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_5_1534635716975.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_6_1534635718338.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_7_1534635720214.png │ │ ├── Screengrab_PopularRepositoriesActivity_State_8_1534635721606.png │ │ └── Screengrab_PopularRepositoriesActivity_State_9_1534635723272.png │ └── screenshots.html ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── library ├── .gitignore ├── build.gradle ├── gradle.properties └── src │ ├── main │ └── kotlin │ │ └── com │ │ └── freeletics │ │ └── rxredux │ │ ├── ObservableReduxStore.kt │ │ ├── Reducer.kt │ │ ├── ReducerException.kt │ │ ├── SideEffect.kt │ │ └── SimpleObserver.kt │ └── test │ └── kotlin │ └── com │ └── freeletics │ └── rxredux │ └── ObservableReduxTest.kt ├── sample ├── .gitignore ├── .readme-images │ ├── screen1.png │ └── screen2.png ├── README.md ├── build.gradle ├── docs │ ├── pagination-sequence.png │ ├── rxredux.png │ ├── sideeffect1-ui.png │ └── sideeffect2-ui.png ├── proguard-rules.pro ├── screenshots │ ├── PopularRepositoriesActivity_State_1.png │ ├── PopularRepositoriesActivity_State_2.png │ ├── PopularRepositoriesActivity_State_3.png │ ├── PopularRepositoriesActivity_State_4.png │ ├── PopularRepositoriesActivity_State_5.png │ ├── PopularRepositoriesActivity_State_6.png │ ├── PopularRepositoriesActivity_State_7.png │ ├── PopularRepositoriesActivity_State_8.png │ └── PopularRepositoriesActivity_State_9.png └── src │ ├── androidTest │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── freeletics │ │ │ └── rxredux │ │ │ ├── PopularRepositoriesActivityTest.kt │ │ │ ├── QueueingScreenshotTaker.kt │ │ │ ├── RecordingPopularRepositoriesViewBinding.kt │ │ │ ├── SampleAppRunner.kt │ │ │ └── SampleTestApplication.kt │ └── resources │ │ └── response1.json │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── freeletics │ │ │ └── rxredux │ │ │ ├── PopularRepositoriesActivity.kt │ │ │ ├── PopularRepositoriesAdapter.kt │ │ │ ├── PopularRepositoriesViewBinding.kt │ │ │ ├── PopularRepositoriesViewModel.kt │ │ │ ├── SampleApplication.kt │ │ │ ├── SimpleViewModelProviderFactory.kt │ │ │ ├── ViewBindingFactory.kt │ │ │ ├── businesslogic │ │ │ ├── github │ │ │ │ ├── GithubApi.kt │ │ │ │ ├── GithubApiFacade.kt │ │ │ │ ├── GithubRepository.kt │ │ │ │ └── GithubSearchResults.kt │ │ │ └── pagination │ │ │ │ └── PaginationStateMachine.kt │ │ │ ├── di │ │ │ ├── AndroidScheduler.kt │ │ │ ├── ApplicationComponent.kt │ │ │ └── ApplicationModule.kt │ │ │ └── util │ │ │ └── Extensions.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ ├── ic_star_black_24dp.xml │ │ └── ic_warning.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── item_load_next.xml │ │ └── item_repository.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values-fr │ │ └── strings.xml │ │ ├── values-ja │ │ └── strings.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── test │ └── java │ │ └── com │ │ └── freeletics │ │ └── rxredux │ │ ├── PopularRepositoriesJvmTest.kt │ │ └── TestComponent.kt │ └── testSpec │ ├── java │ └── com │ │ └── freeletics │ │ ├── di │ │ └── TestApplicationModule.kt │ │ └── rxredux │ │ ├── Data.kt │ │ ├── MockWebServerUtils.kt │ │ └── PopularRepositoriesSpec.kt │ └── resources │ ├── response1.json │ └── response2.json └── settings.gradle /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | gradle-publish: freeletics/gradle-publish@1.0.4 5 | 6 | # Common anchors 7 | # PR cache anchors 8 | gradle_cache_key: &gradle_cache_key 9 | key: v1-dependencies-{{ checksum "dependencies.gradle" }} 10 | restore_gradle_cache: &restore_gradle_cache 11 | restore_cache: 12 | <<: *gradle_cache_key 13 | save_gradle_cache: &save_gradle_cache 14 | save_cache: 15 | <<: *gradle_cache_key 16 | paths: 17 | - ~/.gradle/caches 18 | - ~/.gradle/wrapper 19 | 20 | executors: 21 | android: 22 | docker: 23 | - image: circleci/android:api-28 24 | working_directory: ~/repo 25 | environment: 26 | JAVA_TOOL_OPTIONS: "-Xmx1536m" 27 | GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=2 -Dkotlin.incremental=false" 28 | TERM: dumb 29 | 30 | jobs: 31 | build_and_test: 32 | executor: android 33 | steps: 34 | - checkout 35 | - *restore_gradle_cache 36 | - run: 37 | name: Build library 38 | command: ./gradlew :library:build 39 | - run: 40 | name: Run library tests 41 | command: ./gradlew :library:test 42 | - run: 43 | name: Build sample 44 | command: ./gradlew :sample:assembleDebug 45 | - run: 46 | name: Run sample tests 47 | command: ./gradlew :sample:testDebug 48 | - *save_gradle_cache 49 | 50 | workflows: 51 | version: 2 52 | 53 | master-pipeline: 54 | jobs: 55 | - build_and_test: 56 | filters: 57 | branches: 58 | only: 59 | - master 60 | - gradle-publish/publish_artifacts: 61 | executor: gradle-publish/circleci-android 62 | context: "android-maven-publish" 63 | requires: 64 | - build_and_test 65 | 66 | check-pr: 67 | jobs: 68 | - build_and_test: 69 | filters: 70 | branches: 71 | ignore: 72 | - master 73 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.html linguist-language=Kotlin 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/libraries 5 | /.idea/modules.xml 6 | /.idea/workspace.xml 7 | .DS_Store 8 | /build 9 | /captures 10 | .externalNativeBuild 11 | 12 | .idea* 13 | *.thumbnails 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 Freeletics GmbH 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated 2 | 3 | We've stopped maintaining this library because we are moving from RxJava to Kotlin Coroutines and `Flow`. Take a look at [FlowRedux](https://github.com/freeletics/FlowRedux) for a spiritual successor to this library. 4 | 5 | # RxRedux 6 | 7 | [![CircleCI](https://circleci.com/gh/freeletics/RxRedux.svg?style=svg)](https://circleci.com/gh/freeletics/RxRedux) 8 | [![Download](https://maven-badges.herokuapp.com/maven-central/com.freeletics.rxredux/rxredux/badge.svg) ](https://maven-badges.herokuapp.com/maven-central/com.freeletics.rxredux/rxredux) 9 | 10 | A Redux store implementation entirely based on RxJava (inspired by [redux-observable](https://redux-observable.js.org)) 11 | that helps to isolate side effects. RxRedux is (kind of) a replacement for RxJava's `.scan()` operator. 12 | 13 | ![RxRedux In a Nutshell](https://raw.githubusercontent.com/freeletics/RxRedux/master/docs/rxredux.png) 14 | 15 | ## Dependency 16 | Dependencies are hosted on Maven Central: 17 | 18 | ```groovy 19 | implementation 'com.freeletics.rxredux:rxredux:1.0.1' 20 | ``` 21 | Keep in mind that this library is written in kotlin which means you also need to add `kotlin-stdlib` to a project using RxRedux. 22 | 23 | #### Snapshot 24 | Latest snapshot (directly published from master branch from Travis CI): 25 | 26 | ```groovy 27 | allprojects { 28 | repositories { 29 | // Your repositories. 30 | // ... 31 | // Add url to snapshot repository 32 | maven { 33 | url "https://oss.sonatype.org/content/repositories/snapshots/" 34 | } 35 | } 36 | } 37 | 38 | ``` 39 | 40 | ```groovy 41 | implementation 'com.freeletics.rxredux:rxredux:1.0.2-SNAPSHOT' 42 | ``` 43 | 44 | ## How is this different from other Redux implementations like [Mobius](https://github.com/spotify/mobius) 45 | In contrast to any other Redux inspired library out there, this library is really backed on top of RxJava (Mobius just offers some extensions to use RxJava for async works). 46 | This library offers a custom RxJava operator `.reduxStore( initialState, sideEffects, reducer )` and treats upstream events as `Actions`. 47 | 48 | ## Kotlin coroutine-based implementation 49 | 50 | If you are already using [Kotlin coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html) or planning to use it in your project - 51 | check [CoRedux](https://github.com/freeletics/coredux). This library implements Redux store, using same approach as RxRedux, 52 | but uses [Kotlin coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html) instead of RxJava. 53 | 54 | # Redux Store 55 | A Store is basically an observable container for state. 56 | This library provides a kotlin extension function `.reduxStore(initialState, sideEffects, reducer)` to create such a state container. 57 | It takes an `initialState` and a list of `SideEffect` and a `Reducer` 58 | 59 | # Action 60 | An Action is a command to "do something" in the store. 61 | An `Action` can be triggered by the user of your app (i.e. UI interaction like clicking a button) but also a `SideEffect` can trigger actions. 62 | Every Action goes through the reducer. 63 | If an `Action` is not changing the state at all by the `Reducer` (because it's handled as a side effect), just return the previous state. 64 | Furthermore, `SideEffects` can be registered for a certain type of `Action`. 65 | 66 | # Reducer 67 | A `Reducer` is basically a function `(State, Action) -> State` that takes the current State and an Action to compute a new State. 68 | Every `Action` goes through the state reducer. 69 | If an `Action` is not changing the state at all by the `Reducer` (because it's handled as a side effect), just return the previous state. 70 | 71 | # Side Effect 72 | A Side Effect is a function of type `(Observable, StateAccessor) -> Observable`. 73 | **So basically it's Actions in and Actions out.** 74 | You can think of a `SideEffect` as a use case in clean architecture: It should do just one job. 75 | Every `SideEffect` can trigger multiple `Actions` (remember it returns `Observable`) which go through the `Reducer` but can also trigger other `SideEffects` registered for the corresponding `Action`. 76 | An `Action` can also have a `payload`. For example, if you load some data from backend, you emit the loaded data as an `Action` like `data class DataLoadedAction (val data : FooData)`. 77 | The mantra an Action is a command to do something is still true: in that case it means data is loaded, do with it "something". 78 | 79 | # StateAccessor 80 | Whenever a `SideEffect` needs to know the current State it can use `StateAccessor` to grab the latest state from Redux Store. `StateAccessor` is basically just a function `() -> State` to grab the latest State anytime you need it. 81 | 82 | # Usage 83 | Let's create a simple Redux Store for Pagination: Goal is to display a list of `Persons` on screen. 84 | **For a complete example check [the sample application incl. README](sample/README.md)** 85 | but for the sake of simplicity let's stick with this simple "list of persons example": 86 | 87 | ``` kotlin 88 | data class State { 89 | val currentPage : Int, 90 | val persons : List, // The list of persons 91 | val loadingNextPage : Boolean, 92 | val errorLoadingNextPage : Throwable? 93 | } 94 | 95 | val initialState = State( 96 | currentPage = 0, 97 | persons = emptyList(), 98 | loadingNextPage = false, 99 | errorLoadingNextPage = null 100 | ) 101 | ``` 102 | 103 | ```kotlin 104 | sealed class Action { 105 | object LoadNextPageAction : Action() // Action to load the first page. Triggered by the user. 106 | 107 | data class PageLoadedAction(val personsLoaded : List, val page : Int) : Action() // Persons has been loaded 108 | object LoadPageAction : Action() // Started loading the list of persons 109 | data class ErrorLoadingNextPageAction(val error : Throwable) : Action() // An error occurred while loading 110 | } 111 | ``` 112 | 113 | ```kotlin 114 | // SideEffect is just a type alias for such a function: 115 | fun loadNextPageSideEffect (actions : Observable, state: StateAccessor) : Observable = 116 | actions 117 | .ofType(LoadNextPageAction::class.java) // This side effect only runs for actions of type LoadNextPageAction 118 | .switchMap { 119 | // do network request 120 | val currentState : State = state() 121 | val nextPage = state.currentPage + 1 122 | backend.getPersons(nextPage) 123 | .map { persons : List -> 124 | PageLoadedAction( 125 | personsLoaded = persons, 126 | page = nextPage 127 | ) 128 | } 129 | .onErrorReturn { error -> ErrorLoadingNextPageAction(error) } 130 | .startWith(LoadPageAction) 131 | } 132 | ``` 133 | 134 | ```kotlin 135 | // Reducer is just a typealias for a function 136 | fun reducer(state : State, action : Action) : State = 137 | when(action) { 138 | is LoadPageAction -> state.copy (loadingNextPage = true) 139 | is ErrorLoadingNextPageAction -> state.copy( loadingNextPage = false, errorLoadingNextPage = action.error) 140 | is PageLoadedAction -> state.copy( 141 | loadingNextPage = false, 142 | errorLoadingNextPage = null 143 | persons = state.persons + action.persons, 144 | page = action.page 145 | ) 146 | else -> state // Reducer is actually not handling this action (a SideEffect does it) 147 | } 148 | ``` 149 | 150 | 151 | ```kotlin 152 | val actions : Observable = ... 153 | val sideEffects : List = listOf(::loadNextPageSideEffect, ... ) 154 | 155 | actions 156 | .reduxStore( initialState, sideEffects, ::reducer ) 157 | .subscribe( state -> view.render(state) ) 158 | ``` 159 | 160 | The [following video](https://youtu.be/M7lx9Y9ANYo) (click on it) illustrates the workflow: 161 | 162 | [![RxRedux explanation](https://i.ytimg.com/vi/M7lx9Y9ANYo/hqdefault.jpg?sqp=-oaymwEXCNACELwBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAqwunKP2_qGE0HYUlquWkFccM5MA)](https://youtu.be/M7lx9Y9ANYo) 163 | 164 | 165 | 0. Let's take a look at the following illustration: 166 | The blue box is the `View` (think UI). 167 | The `Presenter` or `ViewModel` has not been drawn for the sake of readability but you can think of having such additional layers between View and Redux State Machine. 168 | The yellow box represents a `Store`. 169 | The grey box is the `reducer`. 170 | The pink box is a `SideEffect` 171 | Additionally, a green circle represents `State` and a red circle represents an `Action` (see next step). 172 | On the right you see a UI mock of a mobile app to illustrate UI changes. 173 | 174 | 1. `NextPageAction` gets triggered from the UI (by scrolling at the end of the list). Every `Action` goes through the `reducer` and all `SideEffects` registered for this type of Action. 175 | 176 | 2. `Reducer` is not interested in `NextPageAction`. So while `NextPageAction` goes through the reducer, it doesn't change the state. 177 | 178 | 3. `loadNextPageSideEffect` (pink box), however, cares about `NextPageAction`. This is the trigger to run the side-effect. 179 | 180 | 4. So `loadNextPageSideEffect` takes `NextPageAction` and starts doing the job and makes the http request to load the next page from backend. Before doing that, this side effect starts with emitting `LoadPageAction`. 181 | 182 | 5. `Reducer` takes `LoadPageAction` emitted from the side effect and reacts on it by "reducing the state". 183 | This means `Reducer` knows how to react on `LoadPageAction` to compute the new state (showing progress indicator at the bottom of the list). 184 | Please note that the state has changed (highlighted in green) which also results in changing the UI (progress indicator at the end of the list). 185 | 186 | 6. Once `loadNextPageSideEffect` gets the result back from backend, the side effect emits a new `PageLoadedAction`. 187 | This Action contains a "payload" - the loaded data. 188 | 189 | ```kotlin 190 | data class PageLoadedAction(val personsLoaded : List, val page : Int) 191 | ``` 192 | 193 | 7. As any other Action `PageLoadedAction` goes through the `Reducer`. The Reducer processes this Action and computes a new state out of it by appending the loaded data to the already existing data (progress bar also is hidden). 194 | 195 | Final remark: 196 | This system allows you to create a plugin in system of `SideEffects` that are highly reusable and specific to do a single use case. 197 | 198 | ![Step12](https://raw.githubusercontent.com/freeletics/RxRedux/master/docs/step13.png) 199 | 200 | Also `SideEffects` can be invoked by `Actions` from other `SideEffects`. 201 | 202 | 203 | **For a complete example check [the sample application incl. README](https://github.com/freeletics/RxRedux/master/sample)** 204 | 205 | # FAQ 206 | 207 | ## I get a `StackoverflowException` 208 | This is a common pitfall and is most of the time caused by the fact that a `SideEffect` emits an `Action` as output that it also consumes from upstream leading to an infinite loop. 209 | 210 | ```kotlin 211 | 212 | val sideEffect: SideEffect = { actions, state -> 213 | actions.map { it * 2 } 214 | } 215 | 216 | val inputActions = Observable.just(1) 217 | 218 | inputActions 219 | .reduxStore( 220 | initialState = "InitialState", 221 | sideEffects = listOf(sideEffect) 222 | ) { state, action -> 223 | ... 224 | } 225 | ``` 226 | 227 | The problem is that from upstream we get `Int 1`. 228 | But since `SideEffect` reacts on that action `Int 1` too, it computes `1 * 2` and emits `2`, which then again gets handled by the same SideEffect ` 2 * 2 = 4` and emits `4`, which then again gets handled by the same SideEffect `4 * 2 = 8` and emits `8`, which then getst handled by the same SideEffect and so on (endless loop) ... 229 | 230 | ## Who processes an `Action` first: `Reducer` or `SideEffect`? 231 | 232 | Since every Action runs through both `Reducer` and registered `SideEffects` this is a valid question. 233 | Technically speaking `Reducer` gets every `Action` from upstream before the registered `SideEffects`. 234 | The idea behind this is that a `Reducer` may have already changed the state before a `SideEffect` start processing the Action. 235 | 236 | For example let's assume upstream only emits exactly one Action (because then it's simpler to illustrate the sequence of workflow): 237 | 238 | ```kotlin 239 | // 1. upstream emits events 240 | val upstreamActions = Observable.just( SomeAction() ) 241 | 242 | val sideEffect1: SideEffect = { actions, state -> 243 | // 3. Runs because of SomeAction 244 | actions.filter { it is SomeAction }.map { OtherAction() } 245 | } 246 | 247 | val sideEffect2: SideEffect ={ actions, state -> 248 | // 5. Runs because of OtherAction 249 | actions.filter { it is OtherAction }.map { YetAnotherAction() } 250 | } 251 | 252 | upstreamActions 253 | .reduxStore( 254 | initialState = ... , 255 | sideEffects = listOf(sideEffect) 256 | ) { state, action -> 257 | // 2. This runs first because of SomeAction 258 | ... 259 | // 4. This runs again because of OtherAction (emitted by SideEffect1) 260 | ... 261 | // 6. This runs again because of YetAnotherAction emitted from SideEffect2) 262 | }.subscribe( ... ) 263 | ``` 264 | 265 | So the workflow is as follows: 266 | 1. Upstream emits `SomeAction` 267 | 2. `reducer` processes `SomeAction` 268 | 3. `SideEffect1` reacts on `SomeAction` and emits `OtherAction` as output 269 | 4. `reducer` processes `OtherAction` 270 | 5. `SideEffect2` reacts on `OtherAction` and emits `YetAnotherAction` 271 | 6. `reducer` processes `YetAnotherAction` 272 | 273 | ## Can I use `val` and `fun` for `SideEffects` or `Reducer`? 274 | 275 | Absolutely. `SideEffect` is just a type alias for a function `(actions: Observable, state: StateAccessor) -> Observable`. 276 | 277 | In kotlin you can use a lambda for that like this: 278 | ```kotlin 279 | val sideEffect1: SideEffect = { actions, state -> 280 | actions.filter { it is SomeAction }.map { OtherAction() } 281 | } 282 | ``` 283 | 284 | of write a function (instead of a lambda): 285 | 286 | ```kotlin 287 | fun sideEffect2(actions : Observable, state : StateAccessor) : Observable { 288 | return actions 289 | .filter { it is SomeAction }.map { OtherAction() } 290 | } 291 | ``` 292 | 293 | Both are totally equal and can be used like that: 294 | 295 | ```kotlin 296 | upstreamActions 297 | .reduxStore( 298 | initialState = ... , 299 | sideEffects = listOf(sideEffect1, ::sideEffect2) 300 | ) { state, action -> 301 | ... 302 | } 303 | .subscribe( ... ) 304 | ``` 305 | 306 | The same is valid for Reducer. Reducer is just a type alias for a function `(State, Action) -> State` 307 | You can define your reducer as lambda or function: 308 | 309 | ```kotlin 310 | val reducer = { state, action -> ... } 311 | 312 | // or 313 | 314 | fun reducer(state : State, action : Action) : State { 315 | ... 316 | } 317 | ``` 318 | 319 | ## Is `distinctUntilChanged` considered as best practice? 320 | Yes it is because `.reduxStore(...)` is not taking care of only emitting state that has been changed 321 | compared to previous state. 322 | Therefore, `.distinctUntilChanged()` is considered as best practice. 323 | ```kotlin 324 | actions 325 | .reduxStore( ... ) 326 | .distinctUntilChanged() 327 | .subscribe { state -> view.render(state) } 328 | ``` 329 | 330 | ## What if I would like to have a SideEffect that returns no Action? 331 | 332 | For example, let's say you just store something in a database but you don't need a Action as result 333 | piped backed to your redux store. In that case you can simple use `Observable.empty()` like this: 334 | 335 | ```kotlin 336 | fun saveToDatabaseSideEffect(actions : Observable, stateAccessor : StateAccessor) { 337 | return actions.flatmap { 338 | saveToDb(...) 339 | Observable.empty() // just return this to not emit an Action 340 | } 341 | } 342 | ``` 343 | 344 | ## How do I cancel ongoing `SideEffects` if a certain `Action` happens? 345 | 346 | Let's assume you have a simple `SideEffect` that is triggered by `Action1`. 347 | Whenever `Action2` is emitted our `SideEffect` should stop. 348 | In RxJava this is quite easy to do by using `.takeUntil()` 349 | 350 | ```kotlin 351 | fun mySideEffect(actions : Observable, stateAccessor : StateAccessor) = 352 | actions 353 | .ofType(Action1::class.java) 354 | .flatMap { 355 | ... 356 | doSomething() 357 | } 358 | .takeUntil(actions.ofType(Action2::class.java)) // Once Action2 triggers the whole SideEffect gets canceled. 359 | ``` 360 | 361 | ## Do I need an Action to start observing data? 362 | Let's say you would like to start observing a database right from the start inside your Store. 363 | This sounds pretty much like as soon as you have subscribers to your Store and therefore you don't need a dedicated Action to start observing the database. 364 | 365 | ```kotlin 366 | fun observeDatabaseSideEffect(_ : Observable, _ : StateAccessor) : Observable = 367 | database // please notice that we dont use Observable at all 368 | .queryItems() 369 | .map { items -> DatabaseLoadedAction(items) } 370 | ``` 371 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | apply from: 'dependencies.gradle' 3 | 4 | buildscript { 5 | apply from: 'dependencies.gradle' 6 | repositories { 7 | google() 8 | mavenCentral() 9 | jcenter() 10 | } 11 | dependencies { 12 | classpath gradlePlugins.android 13 | classpath gradlePlugins.kotlin 14 | classpath gradlePlugins.karumi 15 | classpath gradlePlugins.mavenPublish 16 | classpath gradlePlugins.dokka 17 | } 18 | } 19 | 20 | allprojects { 21 | repositories { 22 | google() 23 | mavenCentral() 24 | jcenter() 25 | maven { url "https://jitpack.io" } 26 | } 27 | } 28 | 29 | task clean(type: Delete) { 30 | delete rootProject.buildDir 31 | } 32 | -------------------------------------------------------------------------------- /dependencies.gradle: -------------------------------------------------------------------------------- 1 | ext.versions = [ 2 | agp : '3.3.2', 3 | compileSdk : 28, 4 | targetSdk : 28, 5 | minSdk : 21, 6 | 7 | mavenPublish : '0.8.0', 8 | dokka : '0.9.18', 9 | dagger : '2.24', 10 | kotlin : '1.3.41', 11 | rxJava : '2.2.11', 12 | rxAndroid : '2.1.1', 13 | rxBinding : '2.2.0', 14 | rxKotlin : '2.4.0', 15 | rxRelay : '2.1.0', 16 | rxReplayingShare : '2.1.1', 17 | okhttp : '4.1.0', 18 | 19 | retrofit : '2.6.1', 20 | moshi : '1.8.0', 21 | timber : '4.7.1', 22 | 23 | support : '28.0.0', 24 | supportConstraintLayout: '1.1.3', 25 | androidArch : '1.1.1', 26 | 27 | junit : '4.12', 28 | kotlinMockito : '1.5.0', 29 | assertJ : '3.8.0', 30 | karumi : '2.2.0', 31 | testRunner : '1.0.2', 32 | espresso : '3.0.2', 33 | screengrab : '1.2.0', 34 | deviceAnimationsRule : '0.0.2', 35 | ] 36 | 37 | ext.libraries = [ 38 | dagger : "com.google.dagger:dagger:$versions.dagger", 39 | daggerCompiler : "com.google.dagger:dagger-compiler:$versions.dagger", 40 | kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin", 41 | kotlinReflect : "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin", 42 | moshi : "com.squareup.moshi:moshi:$versions.moshi", 43 | moshiKotlin : "com.squareup.moshi:moshi-kotlin:$versions.moshi", 44 | moshiCodeGen : "com.squareup.moshi:moshi-kotlin-codegen:$versions.moshi", 45 | timber : "com.jakewharton.timber:timber:$versions.timber", 46 | 47 | rxJava : "io.reactivex.rxjava2:rxjava:$versions.rxJava", 48 | rxAndroid : "io.reactivex.rxjava2:rxandroid:$versions.rxAndroid", 49 | rxBinding : "com.jakewharton.rxbinding2:rxbinding-kotlin:$versions.rxBinding", 50 | rxKotlin : "io.reactivex.rxjava2:rxkotlin:$versions.rxKotlin", 51 | rxRelay : "com.jakewharton.rxrelay2:rxrelay:$versions.rxRelay", 52 | rxReplayingShareKotlin: "com.jakewharton.rx2:replaying-share-kotlin:$versions.rxReplayingShare", 53 | koptionalRxJava2 : "com.gojuno.koptional:koptional-rxjava2-extensions:$versions.koptional", 54 | retrofit : "com.squareup.retrofit2:retrofit:$versions.retrofit", 55 | retrofitMoshi : "com.squareup.retrofit2:converter-moshi:$versions.retrofit", 56 | retrofitRxJava : "com.squareup.retrofit2:adapter-rxjava2:$versions.retrofit", 57 | okhttp : "com.squareup.okhttp3:okhttp:$versions.okhttp" 58 | ] 59 | 60 | ext.supportLibraries = [ 61 | annotations : "com.android.support:support-annotations:$versions.support", 62 | appCompat : "com.android.support:appcompat-v7:$versions.support", 63 | recyclerView : "com.android.support:recyclerview-v7:$versions.support", 64 | constraintLayout: "com.android.support.constraint:constraint-layout:$versions.supportConstraintLayout", 65 | design : "com.android.support:design:$versions.support", 66 | viewModel : "android.arch.lifecycle:extensions:$versions.androidArch" 67 | ] 68 | 69 | ext.testLibraries = [ 70 | junit : "junit:junit:$versions.junit", 71 | mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp", 72 | okhttpTls : "com.squareup.okhttp3:okhttp-tls:$versions.okhttp", 73 | androidArchTesting : "android.arch.core:core-testing:$versions.androidArch", 74 | testRunner : "com.android.support.test:runner:$versions.testRunner", 75 | testRules : "com.android.support.test:rules:$versions.testRunner", 76 | espresso : "com.android.support.test.espresso:espresso-core:$versions.espresso", 77 | espressoContrib : "com.android.support.test.espresso:espresso-contrib:$versions.espresso", 78 | screengrab : "tools.fastlane:screengrab:$versions.screengrab", 79 | deviceAnimationsRule : "com.github.VictorAlbertos:DeviceAnimationTestRule:$versions.deviceAnimationsRule" 80 | ] 81 | 82 | ext.gradlePlugins = [ 83 | android : "com.android.tools.build:gradle:$versions.agp", 84 | kotlin : "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin", 85 | karumi : "com.karumi:shot:$versions.karumi", 86 | mavenPublish : "com.vanniktech:gradle-maven-publish-plugin:$versions.mavenPublish", 87 | dokka : "org.jetbrains.dokka:dokka-gradle-plugin:$versions.dokka", 88 | ] 89 | -------------------------------------------------------------------------------- /docs/Step10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/Step10.png -------------------------------------------------------------------------------- /docs/Step11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/Step11.png -------------------------------------------------------------------------------- /docs/Step12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/Step12.png -------------------------------------------------------------------------------- /docs/rxredux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/rxredux.png -------------------------------------------------------------------------------- /docs/step0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step0.png -------------------------------------------------------------------------------- /docs/step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step1.png -------------------------------------------------------------------------------- /docs/step13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step13.png -------------------------------------------------------------------------------- /docs/step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step2.png -------------------------------------------------------------------------------- /docs/step3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step3.png -------------------------------------------------------------------------------- /docs/step4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step4.png -------------------------------------------------------------------------------- /docs/step5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step5.png -------------------------------------------------------------------------------- /docs/step6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step6.png -------------------------------------------------------------------------------- /docs/step7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step7.png -------------------------------------------------------------------------------- /docs/step8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step8.png -------------------------------------------------------------------------------- /docs/step9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/docs/step9.png -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one 2 | package_name("com.freeletics.rxredux") # e.g. com.krausefx.app 3 | 4 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:android) 17 | 18 | platform :android do 19 | desc "Runs all the tests" 20 | lane :test do 21 | gradle(task: "test") 22 | end 23 | 24 | desc "Submit a new Beta Build to Crashlytics Beta" 25 | lane :beta do 26 | gradle(task: "clean assembleRelease") 27 | crashlytics 28 | 29 | # sh "your_script.sh" 30 | # You can also use other beta testing services here 31 | end 32 | 33 | desc "Deploy a new version to the Google Play" 34 | lane :deploy do 35 | gradle(task: "clean assembleRelease") 36 | upload_to_play_store 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /fastlane/Screengrabfile: -------------------------------------------------------------------------------- 1 | locales ["en-US", "fr-FR", "ja-JP"] 2 | clear_previous_screenshots true 3 | app_apk_path "sample/build/outputs/apk/debug/sample-debug.apk" 4 | tests_apk_path "sample/build/outputs/apk/androidTest/debug/sample-debug-androidTest.apk" 5 | test_instrumentation_runner "com.freeletics.rxredux.SampleAppRunner" 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_1_1534635673219.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_1_1534635673219.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_2_1534635674502.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_2_1534635674502.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_3_1534635676297.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_3_1534635676297.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_4_1534635677659.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_4_1534635677659.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_5_1534635679569.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_5_1534635679569.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_6_1534635680926.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_6_1534635680926.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_7_1534635682782.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_7_1534635682782.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_8_1534635684113.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_8_1534635684113.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_9_1534635685796.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_9_1534635685796.png -------------------------------------------------------------------------------- /fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_1_1534635691480.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_1_1534635691480.png -------------------------------------------------------------------------------- /fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_2_1534635692844.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_2_1534635692844.png -------------------------------------------------------------------------------- /fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_3_1534635694693.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_3_1534635694693.png -------------------------------------------------------------------------------- /fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_4_1534635696123.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_4_1534635696123.png -------------------------------------------------------------------------------- /fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_5_1534635698092.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_5_1534635698092.png -------------------------------------------------------------------------------- /fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_6_1534635699473.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_6_1534635699473.png -------------------------------------------------------------------------------- /fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_7_1534635701379.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_7_1534635701379.png -------------------------------------------------------------------------------- /fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_8_1534635702731.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_8_1534635702731.png -------------------------------------------------------------------------------- /fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_9_1534635704369.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/fr-FR/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_9_1534635704369.png -------------------------------------------------------------------------------- /fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_1_1534635710565.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_1_1534635710565.png -------------------------------------------------------------------------------- /fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_2_1534635711883.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_2_1534635711883.png -------------------------------------------------------------------------------- /fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_3_1534635713677.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_3_1534635713677.png -------------------------------------------------------------------------------- /fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_4_1534635715072.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_4_1534635715072.png -------------------------------------------------------------------------------- /fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_5_1534635716975.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_5_1534635716975.png -------------------------------------------------------------------------------- /fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_6_1534635718338.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_6_1534635718338.png -------------------------------------------------------------------------------- /fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_7_1534635720214.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_7_1534635720214.png -------------------------------------------------------------------------------- /fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_8_1534635721606.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_8_1534635721606.png -------------------------------------------------------------------------------- /fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_9_1534635723272.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_9_1534635723272.png -------------------------------------------------------------------------------- /fastlane/metadata/android/screenshots.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fastlane/screengrab 5 | 6 | 66 | 67 | 68 |

en-US

69 |
70 | 71 | 72 | 75 | 76 | 77 | 82 | 87 | 92 | 97 | 102 | 107 | 112 | 117 | 122 | 123 |
73 | phoneScreenshots 74 |
78 | 79 | en-US phoneScreenshots 80 | 81 | 83 | 84 | en-US phoneScreenshots 85 | 86 | 88 | 89 | en-US phoneScreenshots 90 | 91 | 93 | 94 | en-US phoneScreenshots 95 | 96 | 98 | 99 | en-US phoneScreenshots 100 | 101 | 103 | 104 | en-US phoneScreenshots 105 | 106 | 108 | 109 | en-US phoneScreenshots 110 | 111 | 113 | 114 | en-US phoneScreenshots 115 | 116 | 118 | 119 | en-US phoneScreenshots 120 | 121 |
124 |

fr-FR

125 |
126 | 127 | 128 | 131 | 132 | 133 | 138 | 143 | 148 | 153 | 158 | 163 | 168 | 173 | 178 | 179 |
129 | phoneScreenshots 130 |
134 | 135 | fr-FR phoneScreenshots 136 | 137 | 139 | 140 | fr-FR phoneScreenshots 141 | 142 | 144 | 145 | fr-FR phoneScreenshots 146 | 147 | 149 | 150 | fr-FR phoneScreenshots 151 | 152 | 154 | 155 | fr-FR phoneScreenshots 156 | 157 | 159 | 160 | fr-FR phoneScreenshots 161 | 162 | 164 | 165 | fr-FR phoneScreenshots 166 | 167 | 169 | 170 | fr-FR phoneScreenshots 171 | 172 | 174 | 175 | fr-FR phoneScreenshots 176 | 177 |
180 |

ja-JP

181 |
182 | 183 | 184 | 187 | 188 | 189 | 194 | 199 | 204 | 209 | 214 | 219 | 224 | 229 | 234 | 235 |
185 | phoneScreenshots 186 |
190 | 191 | ja-JP phoneScreenshots 192 | 193 | 195 | 196 | ja-JP phoneScreenshots 197 | 198 | 200 | 201 | ja-JP phoneScreenshots 202 | 203 | 205 | 206 | ja-JP phoneScreenshots 207 | 208 | 210 | 211 | ja-JP phoneScreenshots 212 | 213 | 215 | 216 | ja-JP phoneScreenshots 217 | 218 | 220 | 221 | ja-JP phoneScreenshots 222 | 223 | 225 | 226 | ja-JP phoneScreenshots 227 | 228 | 230 | 231 | ja-JP phoneScreenshots 232 | 233 |
236 |
237 | 238 |
239 |
240 | 341 | 342 | 343 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Oct 02 14:46:05 CEST 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /library/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /library/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'java-library' 2 | apply plugin: 'kotlin' 3 | apply plugin: 'org.jetbrains.dokka' 4 | 5 | gradle.ext.isCiServer = System.getenv().containsKey("CI") 6 | logger.warn("Running on CI: ${gradle.ext.isCiServer}") 7 | 8 | if (gradle.ext.isCiServer) { 9 | apply plugin: "com.vanniktech.maven.publish" 10 | mavenPublish { 11 | targets { 12 | uploadArchives { 13 | releaseRepositoryUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" 14 | snapshotRepositoryUrl = "https://oss.sonatype.org/content/repositories/snapshots/" 15 | } 16 | } 17 | } 18 | } 19 | 20 | dependencies { 21 | implementation libraries.kotlinStdlib 22 | implementation libraries.rxJava 23 | 24 | testImplementation testLibraries.junit 25 | } 26 | 27 | sourceCompatibility = "1.7" 28 | targetCompatibility = "1.7" 29 | 30 | dokka { 31 | outputFormat = 'html' 32 | outputDirectory = "$buildDir/javadoc" 33 | } 34 | -------------------------------------------------------------------------------- /library/gradle.properties: -------------------------------------------------------------------------------- 1 | VERSION_NAME=1.0.2-SNAPSHOT 2 | 3 | GROUP=com.freeletics.rxredux 4 | POM_ARTIFACT_ID=rxredux 5 | POM_NAME=RxRedux 6 | POM_PACKAGING=jar 7 | 8 | POM_DESCRIPTION=RxRedux 9 | POM_INCEPTION_YEAR=2018 10 | 11 | POM_URL=http://github.com/freeletics/RxRedux/ 12 | POM_SCM_URL=http://github.com/freeletics/RxRedux/ 13 | POM_SCM_CONNECTION=scm:git:git://github.com/freeletics/RxRedux.git 14 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/freeletics/RxRedux.git 15 | 16 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 17 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt 18 | POM_LICENCE_DIST=repo 19 | 20 | POM_DEVELOPER_ID=Freeletics 21 | POM_DEVELOPER_NAME=Freeletics 22 | 23 | signing.keyId=4F8720BE 24 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/freeletics/rxredux/ObservableReduxStore.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import io.reactivex.Observable 4 | import io.reactivex.Observer 5 | import io.reactivex.disposables.CompositeDisposable 6 | import io.reactivex.disposables.Disposable 7 | import io.reactivex.observers.SerializedObserver 8 | import io.reactivex.plugins.RxJavaPlugins 9 | import io.reactivex.subjects.PublishSubject 10 | import io.reactivex.subjects.Subject 11 | 12 | /** 13 | * A ReduxStore is a RxJava based implementation of Redux and redux-observable.js.org. 14 | * A ReduxStore takes Actions from upstream as input events. 15 | * [SideEffect]s can be registered to listen for a certain 16 | * Action to react on a that Action as a (impure) side effect and create yet another Action as 17 | * output. Every Action goes through the a [Reducer], which is basically a pure function that takes 18 | * the current State and an Action to compute a new State. 19 | * The new state will be emitted downstream to any listener interested in it. 20 | * 21 | * A ReduxStore observable never reaches onComplete(). If a error occurs in the [Reducer] or in any 22 | * side effect ([Throwable] has been thrown) then the ReduxStore reaches onError() as well and 23 | * according to the reactive stream specs the store cannot recover the error state. 24 | * 25 | * @param initialStateSupplier A function that computes the initial state. The computation is 26 | * done lazily once an observer subscribes and it is done on the [io.reactivex.Scheduler] that 27 | * you have specified in subscribeOn(). The computed initial state will be emitted directly 28 | * in onSubscribe() 29 | * @param sideEffects The sideEffects. See [SideEffect] 30 | * @param reducer The reducer. See [Reducer]. 31 | * @param S The type of the State 32 | * @param A The type of the Actions 33 | */ 34 | fun Observable.reduxStore( 35 | initialStateSupplier: () -> S, 36 | sideEffects: Iterable>, 37 | reducer: Reducer 38 | ): Observable { 39 | return RxJavaPlugins.onAssembly( 40 | ObservableReduxStore( 41 | initialStateSupplier = initialStateSupplier, 42 | upstreamActionsObservable = this, 43 | reducer = reducer, 44 | sideEffects = sideEffects 45 | ) 46 | ) 47 | } 48 | 49 | /** 50 | * Just a convenience method to use a fixed value as initial state (rather than a supplier function). 51 | * However, under the hood it creates a fixed supplier function that captures this fixed value. 52 | * 53 | * @see reduxStore 54 | * @param initialState The initial state. The initial state is emitted directly in on onSubscribe(). 55 | * @param sideEffects The SideEffects. See [SideEffect]. 56 | * @param reducer The reducer. See [Reducer]. 57 | */ 58 | fun Observable.reduxStore( 59 | initialState: S, 60 | sideEffects: Iterable>, 61 | reducer: Reducer 62 | ): Observable = reduxStore( 63 | initialStateSupplier = { initialState }, 64 | sideEffects = sideEffects, 65 | reducer = reducer 66 | ) 67 | 68 | /** 69 | * Just a convenience method to use vararg for arbitrary many sideeffects instead a list of SideEffects. 70 | * See [reduxStore] documentation. 71 | * 72 | * @see reduxStore 73 | */ 74 | fun Observable.reduxStore( 75 | initialState: S, 76 | vararg sideEffects: SideEffect, 77 | reducer: Reducer 78 | ): Observable = reduxStore( 79 | initialState = initialState, 80 | sideEffects = sideEffects.toList(), 81 | reducer = reducer 82 | ) 83 | 84 | /** 85 | * Just a convenience method to use vararg for arbitrary many sideeffects instead a list of SideEffects. 86 | * See [reduxStore] documentation. 87 | * 88 | * @see reduxStore 89 | */ 90 | fun Observable.reduxStore( 91 | initialStateSupplier: () -> S, 92 | vararg sideEffects: SideEffect, 93 | reducer: Reducer 94 | ): Observable = reduxStore( 95 | initialStateSupplier = initialStateSupplier, 96 | sideEffects = sideEffects.toList(), 97 | reducer = reducer 98 | ) 99 | 100 | /** 101 | * Use [Observable.reduxStore] to create an instance of this kind of Observable. 102 | * 103 | * @param S The type of the State 104 | * @param A The type of the Actions 105 | * @see [Observable.reduxStore] 106 | */ 107 | private class ObservableReduxStore( 108 | /** 109 | * The initial state. This one will be emitted directly in onSubscribe(). 110 | * The supplier is runs on the Scheduler that has been specified in .subscribeOn(MyScheduler). 111 | */ 112 | private val initialStateSupplier: () -> S, 113 | /** 114 | * The upstream that emits Actions (i.e. actions triggered by an User through User Interface) 115 | */ 116 | private val upstreamActionsObservable: Observable, 117 | 118 | /** 119 | * The Iterable of all sideEffects. A [SideEffect] takes an action, does something meaningful and returns another action. 120 | * Every Action is handled by the [Reducer] to create a new State. 121 | */ 122 | private val sideEffects: Iterable>, 123 | 124 | /** 125 | * The Reducer. Takes the current state and an action and computes a new state. 126 | */ 127 | private val reducer: Reducer 128 | ) : Observable() { 129 | 130 | override fun subscribeActual(observer: Observer) { 131 | val disposables = CompositeDisposable() 132 | 133 | // avoids threading issues 134 | val serializedObserver = SerializedObserver(observer) 135 | val storeObserver = ReduxStoreObserver( 136 | actualObserver = serializedObserver, 137 | internalDisposables = disposables, 138 | initialState = initialStateSupplier(), 139 | reducer = reducer 140 | ) 141 | 142 | // Stream to cancel the subscriptions 143 | val actionsSubject = PublishSubject.create() 144 | 145 | 146 | actionsSubject.subscribe(storeObserver) // This will make the reducer run on each action 147 | 148 | // TODO should SideEffects be composed with ObservableTransformer? 149 | // That would be the more idiomatic way I guess. 150 | sideEffects.forEach { sideEffect -> 151 | disposables += sideEffect(actionsSubject, storeObserver::currentState) 152 | .subscribe({ action -> 153 | // Loop the "output" actions of a SideEffect back into the actions stream 154 | actionsSubject.onNext(action) 155 | 156 | // TODO how to get this run on the origin ReduxStore subscribeOn() Scheduler? 157 | // I don't think that this is possible to implement. May need some scheduler 158 | // passed in as parameter similar to what Observable.timer() does. 159 | 160 | }, { error -> 161 | actionsSubject.onError(error) // Error in SideEffect causes whole stream to fail 162 | }, { 163 | // Swallow onComplete because just if one SideEffect reaches onComplete we don't want to make 164 | // everything incl. ReduxStore and other SideEffects reach onComplete 165 | } 166 | ) 167 | } 168 | 169 | upstreamActionsObservable.subscribe( 170 | UpstreamObserver( 171 | actionsSubject = actionsSubject, 172 | internalDisposables = disposables 173 | ) 174 | ) 175 | } 176 | 177 | /** 178 | * Simple observer for internal reduxStore 179 | */ 180 | private class ReduxStoreObserver( 181 | private val actualObserver: Observer, 182 | private val internalDisposables: CompositeDisposable, 183 | initialState: S, 184 | private val reducer: Reducer 185 | ) : SimpleObserver() { 186 | 187 | @Volatile 188 | private var state: S = initialState 189 | 190 | /** 191 | * Get the current state 192 | */ 193 | internal fun currentState(): S = state 194 | 195 | override fun onSubscribeActually(d: Disposable) { 196 | // start with the initial state 197 | actualObserver.onSubscribe(d) 198 | actualObserver.onNext(currentState()) 199 | 200 | // TODO do we need to add Disposable d to internal Disposables? 201 | // I think it is handled by the actualObserver.onSubscribe() 202 | } 203 | 204 | @Synchronized 205 | override fun onNextActually(t: A) { 206 | val currentState = currentState() 207 | val newState = try { 208 | reducer(currentState, t) 209 | } catch (error: Throwable) { 210 | onError(ReducerException(state = currentState, action = t, cause = error)) 211 | return 212 | } 213 | state = newState 214 | actualObserver.onNext(newState) 215 | } 216 | 217 | override fun onErrorActually(t: Throwable) { 218 | actualObserver.onError(t) 219 | } 220 | 221 | override fun onCompleteActually() { 222 | actualObserver.onComplete() 223 | } 224 | 225 | override fun disposeActually() { 226 | internalDisposables.dispose() 227 | } 228 | 229 | override fun isDisposedActually(): Boolean = internalDisposables.isDisposed 230 | } 231 | 232 | private operator fun CompositeDisposable.plusAssign(disposable: Disposable) { 233 | this.add(disposable) 234 | } 235 | 236 | /** 237 | * Observer for upstream Actions. 238 | * All Actions are basically forwarded to actionSubject 239 | */ 240 | private class UpstreamObserver( 241 | private val actionsSubject: Subject, 242 | private val internalDisposables: CompositeDisposable 243 | ) : SimpleObserver() { 244 | 245 | private lateinit var disposable: Disposable 246 | 247 | override fun onSubscribeActually(d: Disposable) { 248 | disposable = d 249 | internalDisposables.add(disposable) 250 | } 251 | 252 | override fun onNextActually(t: T) { 253 | actionsSubject.onNext(t) 254 | } 255 | 256 | override fun onErrorActually(t: Throwable) { 257 | actionsSubject.onError(t) 258 | } 259 | 260 | override fun onCompleteActually() { 261 | actionsSubject.onComplete() 262 | } 263 | 264 | override fun disposeActually() { 265 | // Nothing to do. 266 | // InternalDisposables takes care of disposing all internal disposables at once 267 | } 268 | 269 | override fun isDisposedActually(): Boolean = disposable.isDisposed 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/freeletics/rxredux/Reducer.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | /** 4 | * A simple type alias for a reducer function. 5 | * A Reducer takes a State and an Action as input and produces a state as output. 6 | * 7 | * If a reducer should not react on a Action, just return the old State. 8 | * 9 | * @param S The type of the state 10 | * @param A The type of the Actions 11 | */ 12 | typealias Reducer = (currentState: S, newAction: A) -> S 13 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/freeletics/rxredux/ReducerException.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | class ReducerException( 4 | state: Any, 5 | action: Any, 6 | cause: Throwable 7 | ) : RuntimeException("Exception was thrown by reducer, state = '$state', action = '$action'", cause) 8 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/freeletics/rxredux/SideEffect.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import io.reactivex.Observable 4 | 5 | 6 | /** 7 | * It is a function which takes a stream of actions and returns a stream of actions. Actions in, actions out 8 | * (concept borrowed from redux-observable.js.or - so called epics). 9 | * @param actions Input action. Every SideEffect should be responsible to handle a single Action 10 | * (i.e using filter or ofType operator) 11 | * @param state [StateAccessor] to get the latest state of the state machine 12 | */ 13 | // TODO find better name? 14 | typealias SideEffect = (actions: Observable, state: StateAccessor) -> Observable 15 | 16 | 17 | /** 18 | * The StateAccessor is basically just a deferred way to get a state of a [ObservableReduxStore] at any given point in time. 19 | * So you have to call this method to get the state. 20 | */ 21 | // TODO find better name 22 | typealias StateAccessor = () -> S 23 | -------------------------------------------------------------------------------- /library/src/main/kotlin/com/freeletics/rxredux/SimpleObserver.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | 4 | import java.util.concurrent.atomic.AtomicReference 5 | 6 | import io.reactivex.Observer 7 | import io.reactivex.disposables.Disposable 8 | import io.reactivex.exceptions.* 9 | import io.reactivex.internal.disposables.DisposableHelper 10 | import io.reactivex.observers.LambdaConsumerIntrospection 11 | import io.reactivex.plugins.RxJavaPlugins 12 | 13 | internal abstract class SimpleObserver : AtomicReference(), Observer, Disposable, LambdaConsumerIntrospection { 14 | 15 | override fun onSubscribe(s: Disposable) { 16 | if (DisposableHelper.setOnce(this, s)) { 17 | try { 18 | onSubscribeActually(this) 19 | } catch (ex: Throwable) { 20 | Exceptions.throwIfFatal(ex) 21 | s.dispose() 22 | onError(ex) 23 | } 24 | 25 | } 26 | } 27 | 28 | /** 29 | * Already takes care of error handling in onSubscribe 30 | */ 31 | protected abstract fun onSubscribeActually(d: Disposable) 32 | 33 | override fun onNext(t: T) { 34 | if (!isDisposed) { 35 | try { 36 | onNextActually(t) 37 | } catch (e: Throwable) { 38 | Exceptions.throwIfFatal(e) 39 | get().dispose() 40 | onError(e) 41 | } 42 | 43 | } 44 | } 45 | 46 | /** 47 | * Already takes care of error handling. All you have to do is really just to deal with the concrete implementation 48 | */ 49 | protected abstract fun onNextActually(t: T) 50 | 51 | override fun onError(t: Throwable) { 52 | if (!isDisposed) { 53 | lazySet(DisposableHelper.DISPOSED) 54 | try { 55 | onErrorActually(t) 56 | } catch (e: Throwable) { 57 | Exceptions.throwIfFatal(e) 58 | RxJavaPlugins.onError(CompositeException(t, e)) 59 | } 60 | 61 | } 62 | } 63 | 64 | /** 65 | * Alrady takes care of error handling. All a subclass has to do is really just react on onError events 66 | */ 67 | protected abstract fun onErrorActually(t: Throwable) 68 | 69 | override fun onComplete() { 70 | if (!isDisposed) { 71 | lazySet(DisposableHelper.DISPOSED) 72 | try { 73 | onCompleteActually() 74 | } catch (e: Throwable) { 75 | Exceptions.throwIfFatal(e) 76 | RxJavaPlugins.onError(e) 77 | } 78 | 79 | } 80 | } 81 | 82 | /** 83 | * Already take care of error handling. All subclass have to do is to react on complete event per se. 84 | * Disposing is automatically done 85 | */ 86 | protected abstract fun onCompleteActually() 87 | 88 | override fun dispose() { 89 | DisposableHelper.dispose(this) 90 | disposeActually() 91 | } 92 | 93 | /** 94 | * Actually dispose internal subclass logic 95 | */ 96 | protected abstract fun disposeActually() 97 | 98 | override fun isDisposed(): Boolean { 99 | return get() == DisposableHelper.DISPOSED && isDisposedActually() 100 | } 101 | 102 | /** 103 | * Return true if the subclass has done disposable job and therefore the whole object can be seen as disposed 104 | */ 105 | protected abstract fun isDisposedActually(): Boolean 106 | 107 | override fun hasCustomOnError(): Boolean = true 108 | 109 | companion object { 110 | private const val serialVersionUID = -7251123623727123452L 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /library/src/test/kotlin/com/freeletics/rxredux/ObservableReduxTest.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import io.reactivex.Observable 4 | import io.reactivex.schedulers.Schedulers 5 | import io.reactivex.subjects.PublishSubject 6 | import org.junit.Assert 7 | import org.junit.Test 8 | import java.util.concurrent.atomic.AtomicInteger 9 | 10 | class ObservableReduxTest { 11 | 12 | @Test 13 | fun `SideEffects react on upstream Actions but Reducer Reacts first`() { 14 | val inputs = listOf("InputAction1", "InputAction2") 15 | val inputActions = Observable.fromIterable(inputs) 16 | val sideEffect1: SideEffect = 17 | { actions, state -> actions.filter { inputs.contains(it) }.map { it + "SideEffect1" } } 18 | val sideEffect2: SideEffect = 19 | { actions, state -> actions.filter { inputs.contains(it) }.map { it + "SideEffect2" } } 20 | 21 | inputActions 22 | .reduxStore( 23 | initialState = "InitialState", 24 | sideEffects = listOf(sideEffect1, sideEffect2) 25 | ) { state, action -> 26 | action 27 | } 28 | .test() 29 | .assertValues( 30 | "InitialState", 31 | "InputAction1", 32 | "InputAction1SideEffect1", 33 | "InputAction1SideEffect2", 34 | "InputAction2", 35 | "InputAction2SideEffect1", 36 | "InputAction2SideEffect2" 37 | ) 38 | .assertComplete() 39 | .assertNoErrors() 40 | } 41 | 42 | @Test 43 | fun `Empty upstream just emits initial state and completes`() { 44 | val upstream: Observable = Observable.empty() 45 | upstream.reduxStore( 46 | "InitialState", 47 | sideEffects = emptyList() 48 | ) { state, action -> state } 49 | .test() 50 | .assertNoErrors() 51 | .assertValues("InitialState") 52 | .assertComplete() 53 | } 54 | 55 | @Test 56 | fun `Error upstream just emits initial state and run in onError`() { 57 | val exception = Exception("FakeException") 58 | val upstream: Observable = Observable.error(exception) 59 | upstream.reduxStore( 60 | "InitialState", 61 | sideEffects = emptyList() 62 | ) { state, action -> state } 63 | .test() 64 | .assertValues("InitialState") 65 | .assertError(exception) 66 | } 67 | 68 | @Test 69 | fun `disposing reduxStore disposes all side effects and upstream`() { 70 | var disposedSideffectsCount = 0 71 | val dummyAction = "SomeAction" 72 | val upstream = PublishSubject.create() 73 | val outputedStates = ArrayList() 74 | var outputedError: Throwable? = null 75 | var outputCompleted = false 76 | 77 | val sideEffect1: SideEffect = { actions, state -> 78 | actions.filter { it == dummyAction }.map { "SideEffectAction1" } 79 | .doOnDispose { disposedSideffectsCount++ } 80 | } 81 | 82 | 83 | val sideEffect2: SideEffect = { actions, state -> 84 | actions.filter { it == dummyAction }.map { "SideEffectAction2" } 85 | .doOnDispose { disposedSideffectsCount++ } 86 | } 87 | 88 | val disposable = upstream 89 | .reduxStore( 90 | "InitialState", sideEffect1, sideEffect2 91 | ) { state, action -> 92 | action 93 | } 94 | .subscribeOn(Schedulers.io()) 95 | .subscribe( 96 | { outputedStates.add(it) }, 97 | { outputedError = it }, 98 | { outputCompleted = true }) 99 | 100 | Thread.sleep(100) // I know it's bad, but it does the job 101 | 102 | // Trigger some action 103 | upstream.onNext(dummyAction) 104 | 105 | Thread.sleep(100) // I know it's bad, but it does the job 106 | 107 | // Dispose the whole cain 108 | disposable.dispose() 109 | 110 | // Verify everything is fine 111 | Assert.assertEquals(2, disposedSideffectsCount) 112 | Assert.assertFalse(upstream.hasObservers()) 113 | Assert.assertEquals( 114 | listOf( 115 | "InitialState", 116 | dummyAction, 117 | "SideEffectAction1", 118 | "SideEffectAction2" 119 | ), 120 | outputedStates 121 | ) 122 | Assert.assertNull(outputedError) 123 | Assert.assertFalse(outputCompleted) 124 | } 125 | 126 | @Test 127 | fun `SideEffect that returns no Action is supported`() { 128 | 129 | fun returnNoActionEffect( 130 | actions: Observable, 131 | accessor: StateAccessor 132 | ): Observable = actions.flatMap { 133 | Observable.empty() 134 | } 135 | 136 | 137 | val action1 = "Action1" 138 | val action2 = "Action2" 139 | val action3 = "Action3" 140 | val initial = "Initial" 141 | 142 | Observable.just(action1, action2, action3) 143 | .reduxStore("Initial", sideEffects = listOf(::returnNoActionEffect)) { state, action -> 144 | state + action 145 | } 146 | .test() 147 | .assertValues( 148 | initial, 149 | initial + action1, 150 | initial + action1 + action2, 151 | initial + action1 + action2 + action3 152 | ) 153 | .assertNoErrors() 154 | } 155 | 156 | @Test 157 | fun `exception in reducer enhanced with state and action`() { 158 | val testException = Exception("test") 159 | 160 | Observable 161 | .just("Action1") 162 | .reduxStore("Initial", sideEffects = emptyList()) { _, _ -> 163 | throw testException 164 | } 165 | .test() 166 | .assertError(ReducerException::class.java) 167 | .assertErrorMessage("Exception was thrown by reducer, state = 'Initial', action = 'Action1'") 168 | } 169 | 170 | @Test 171 | fun `subscribing new observer calls initial state supplier on subscribeOn scheduler`() { 172 | val ioSchedulerNamePrefix = "Thread[RxCachedThreadScheduler-" 173 | val initialStateCount = AtomicInteger() 174 | val initialStateSupplierCallingThread = Array(2) { null } 175 | val initialState = "initial State" 176 | val action1 = "Action 1" 177 | val testThread = Thread.currentThread() 178 | 179 | val observable: Observable = Observable.fromCallable { action1 } 180 | .observeOn(Schedulers.newThread()) 181 | .reduxStore(initialStateSupplier = { 182 | val index = initialStateCount.getAndIncrement() 183 | initialStateSupplierCallingThread[index] = Thread.currentThread() 184 | initialState 185 | }, 186 | sideEffects = emptyList(), 187 | reducer = { _, action -> action } 188 | ).subscribeOn(Schedulers.io()) 189 | .take(2) 190 | 191 | 192 | val observer1 = observable.test() 193 | val observer2 = observable.test() 194 | 195 | observer1.awaitTerminalEvent() 196 | observer2.awaitTerminalEvent() 197 | 198 | observer1.assertValues(initialState, action1) 199 | observer2.assertValues(initialState, action1) 200 | 201 | observer1.assertNoErrors() 202 | observer2.assertNoErrors() 203 | 204 | Assert.assertEquals(2, initialStateCount.get()) 205 | Assert.assertNotSame(testThread, initialStateSupplierCallingThread[0]) 206 | Assert.assertNotSame(testThread, initialStateSupplierCallingThread[1]) 207 | Assert.assertTrue( 208 | initialStateSupplierCallingThread[0] 209 | .toString() 210 | .startsWith(ioSchedulerNamePrefix) 211 | ) 212 | Assert.assertTrue( 213 | initialStateSupplierCallingThread[1] 214 | .toString() 215 | .startsWith(ioSchedulerNamePrefix) 216 | ) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /sample/.readme-images/screen1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/.readme-images/screen1.png -------------------------------------------------------------------------------- /sample/.readme-images/screen2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/.readme-images/screen2.png -------------------------------------------------------------------------------- /sample/README.md: -------------------------------------------------------------------------------- 1 | # RxRedux Pagination example 2 | 3 | This example shows an app that loads a list of popular repositories (number of stars) on Github. 4 | It users github api endpoint to query popular repositories. 5 | Github doesn't give us the whole list of repositories in one single response but offers pagination 6 | to load the next page once you hit the end of the list and need more repositories to display. 7 | 8 | ![First page](https://github.com/freeletics/RxRedux/blob/master/sample/.readme-images/screen1.png?raw=true) 9 | ![Second page](https://github.com/freeletics/RxRedux/blob/master/sample/.readme-images/screen2.png?raw=true) 10 | 11 | To implement that of course we use `RxRedux`. The User can trigger `LoadFirstPageAction` and 12 | `LoadNextPageAction`. 13 | This Actions are handled by:` 14 | - `fun loadFirstPageSideEffect(action : Observable) : Observable` 15 | - `fun loadNextPageSideEffect(action : Observable) : Observable` 16 | 17 | Furthermore, if a error occurs while loading the next page an internal Action 18 | (not triggered by the user) `ErrorLoadingPageAction` is emitted 19 | which is handled by another internal SideEffect: 20 | `fun showAndHideLoadingErrorSideEffect(action : Observable) : Observable` takes care 21 | of showing and hiding a `SnackBar` that is used to display an error on screen. 22 | 23 | 24 | ## SideEffects 25 | As a user of this app scrolls to the end of the list, the next page of popular Github repositories is loaded. 26 | The real deal with `RxRedux` is `SideEffect` (Action in, Actions out) as we will try to highlight in the following example (source code is available on [Github](https://github.com/freeletics/RxRedux/tree/master/sample)). 27 | 28 | To set up our Redux Store with RxRedux we use `.reduxStore()`: 29 | 30 | ```kotlin 31 | // Actions triggered by the user in the UI / View Layer 32 | val userActions : Observable = ... 33 | 34 | actionsFromUser 35 | .observeOn(Schedulers.io()) 36 | .reduxStore( 37 | initialState = State.LOADING, 38 | sideEffects = listOf(::loadNextPageSideEffect, ::showAndHideErrorSnackbarSideEffect, ... ), 39 | reducer = ::reducer 40 | ) 41 | .distinctUntilChanged() 42 | .subscribe { state -> view.render(state) } 43 | ``` 44 | 45 | For the sake of readability we just want to focus on two side effects in this blog post to highlight the how easy it is to compose (and reuse) functionality via `SideEffects` in `RxRedux` (but you can check the full sample code on [Github](https://github.com/freeletics/RxRedux/tree/master/sample)) 46 | 47 | 48 | ```kotlin 49 | fun loadNextPageSideEffect(actions : Observable, state : StateAccessor) : Observable = 50 | actions 51 | .ofType(LoadNextPageAction::class.java) 52 | .switchMap { 53 | val currentState : State = state() 54 | val nextPage : Int = currentState.page + 1 55 | 56 | githubApi.loadNextPage(nextPage) 57 | .map { repositoryList -> 58 | PageLoadedAction(repositoryList, nextPage) // Action with the loaded items as "payload" 59 | } 60 | .startWith( StartLoadingNextPageAction ) 61 | .onErrorReturn { error -> ErrorLoadingPageAction(error) } 62 | } 63 | ``` 64 | 65 | Let's recap what `loadeNextPageSideEffect()` does: 66 | 67 | 1. This `SideEffect` only triggers on `LoadNextPageAction` (emitted in `actionsFromUser`) 68 | 2. Before making the http request this SideEffect emits a `StartLoadingNextPageAction`. This action runs through the `Reducer` and the output is a new State that causes the UI to display a loading indicator at the end of the list. 69 | 3. Once the http request completes `PageLoadedAction` is emitted and processed by the `Reducer` as well to compute the new state. In other words: the loading indicator is hidden and the loaded data is added to the list of Github repositories displayed on the screen. 70 | 4. If an error occures while making the http request, we catch it an emit a `ErrorLoadingPageAction`. We will see in a minute how we process this action (spoiler: with another SideEffect). 71 | 72 | The state transitions (for the happy path - no http networking error) are reflected in the UI as follows: 73 | 74 | ![RxRedux](https://raw.githubusercontent.com/freeletics/RxRedux/master/sample/docs/sideeffect1-ui.png) 75 | 76 | 77 | So let's talk how to handle the http networking error case. 78 | In `RxRedux` a `SideEffect` emits `Actions`. 79 | These Actions go through the Reducer but are alse piped back into the system. 80 | That allows other `SideEffect` to react on `Actions` emitted by a `SideEffect`. 81 | We do exactly that to show and hide a `Snackbar` in case that loading the next page fails. 82 | Remember: `loadNextPageSideEffect` emits a `ErrorLoadingPageAction`. 83 | 84 | ```kotlin 85 | fun showAndHideErrorSnackbarSideEffect(actions : Observable, state : StateAccessor) : Observable = 86 | actions 87 | .ofType(ErrorLoadingPageAction::class.java) // <-- HERE 88 | .switchMap { action -> 89 | Observable.timer(3, TimeUnit.SECONDS) 90 | .map { HideLoadNextPageErrorAction(action.error) } 91 | .startWith( ShowLoadNextPageErrorAction(action.error) ) 92 | } 93 | ``` 94 | 95 | What `showAndHideErrorSnackbarSideEffect()` does is the following: 96 | 97 | 1. This side effect only triggers on `ErrorLoadingPageAction` 98 | 2. We show a Snackbar for 3 seconds on the screen by using `Observable.timer(3, SECONDS)`. We do that by emitting `ShowLoadNextPageErrorAction` first. `Reducer`will then change the state to show Snackbar. 99 | 3. After 3 seconds we emit `HideLoadNextPageErrorActionHideLoadNextPageErrorAction`. Again, the reducer takes care to compute new state that causes the UI to hide the Snackbar. 100 | 101 | ![RxRedux](https://raw.githubusercontent.com/freeletics/RxRedux/master/sample/docs/sideeffect2-ui.png) 102 | 103 | Confused? Here is a (pseudo) sequence diagram that illustrates how action flows from SideEffect to other SideEffects and the Reducer: 104 | 105 | ![RxRedux](https://raw.githubusercontent.com/freeletics/RxRedux/master/sample/docs/pagination-sequence.png) 106 | 107 | Please note that every Action goes through the `Reducer` first. 108 | This is an explicit design choice to allow the `Reducer` to change state before `SideEffects` start. 109 | If Reducer doesn't really care about an action (i.e. `ErrorLoadingPageAction`) Reducer just returns the previous State. 110 | 111 | Of course one could say "why do you need this overhead just to display a Snackbar"? 112 | The reason is that now this is testable. 113 | Moreover, `showAndHideErrorSnackbarSideEffect()` can be reused. 114 | For Example: If you add a new functionality like loading data from database, error handling is just emitting an Action and `showAndHideErrorSnackbarSideEffect()` will do the magic for you. 115 | With `SideEffects` you can create a plugin system. 116 | 117 | # Testing 118 | Testing is fairly easy in a state machine based architecure because all you have to do trigger 119 | input actions and then check for state changes caused by an action. 120 | So at the end it's basically `assertEquals(expectedState, actualStates)`. 121 | 122 | ## Functional testing 123 | Of course we could test our side effects and reducers individually. 124 | However, since they are pure functions, we believe that writing functional tests for the whole system 125 | adds more value then single unit tests. 126 | Actually we have two kind of functional tests: 127 | 128 | 1. Functional tests that run on JVM: Here we basically have no real UI but just a mocked one that 129 | records states that should be rendered over time. Eventually, this allows us to do `assertEquals(expectedState, recordedStates)` 130 | 2. Functional tests that run on real Android Device: Same idea as for functional tests on JVM, in this case, however, we run our tests on a real android device interacting with real android UI widgets. We use `ViewBinding` to interact with UI Widgets. While running the function tests we use a `RecordingViewBinding` that again records the state changes over time which then allows us to check `assertEquals(expectedState, recordedStates)`. 131 | 132 | ## Screenshot testing 133 | Since our app is state driven and a state change also triggers a UI change, we can easily screenshot 134 | test our app since we only have to wait until a state transition happen and then make a screenshot. 135 | The procedure looks as follows 136 | 137 | 1. Record the screenshots with `./gradlew executeScreenshotTests -Precord`. 138 | You have to run this whenever you change your UI on purpose. 139 | 2. Run verification with `./gradlew executeScreenshotTests`. 140 | This runs the test and compares the screenshots with the previously recored screenshots (see step 1.) 141 | 3. See test report in `RxRedux/sample/build/reports/shot/verification/index.html` 142 | 143 | Please keep in mind that you always have to use the same device to run your screenshot test. 144 | The screenshots added to this repository have been taken from a Nexus 5X emulator (default settings) running Android API 26. 145 | 146 | ### Language Localization 147 | We can go one step further and create screenshots for each language we support in our app. 148 | We use [fastlane](https://fastlane.tools) for that. From command line run 149 | 150 | ``` 151 | fastlane screengrab 152 | ``` 153 | 154 | You can see the generated report in `fastlane/metadata/android/screeshots.html`. 155 | This report can be used to do localization QA. 156 | -------------------------------------------------------------------------------- /sample/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: 'shot' 6 | 7 | kapt { 8 | arguments { 9 | arg("moshi.generated", "javax.annotation.Generated") 10 | } 11 | } 12 | 13 | shot { 14 | appId = 'com.freeletics.rxredux' 15 | } 16 | 17 | android { 18 | compileSdkVersion versions.compileSdk 19 | defaultConfig { 20 | applicationId "com.freeletics.rxredux" 21 | targetSdkVersion versions.targetSdk 22 | minSdkVersion versions.minSdk 23 | versionCode 1 24 | versionName "1.0" 25 | testInstrumentationRunner "com.freeletics.rxredux.SampleAppRunner" 26 | } 27 | buildTypes { 28 | release { 29 | minifyEnabled false 30 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 31 | } 32 | } 33 | 34 | compileOptions { 35 | sourceCompatibility 1.8 36 | targetCompatibility 1.8 37 | } 38 | 39 | sourceSets { 40 | test { 41 | java.srcDirs += ['src/testSpec/java'] 42 | resources.srcDirs += ['src/testSpec/resources'] 43 | } 44 | 45 | androidTest { 46 | java.srcDirs += ['src/testSpec/java'] 47 | resources.srcDirs += ['src/testSpec/resources'] 48 | } 49 | } 50 | 51 | testOptions { 52 | animationsDisabled = true 53 | } 54 | 55 | lintOptions { 56 | disable 'GoogleAppIndexingWarning','InvalidPackage' 57 | } 58 | } 59 | 60 | dependencies { 61 | implementation libraries.kotlinStdlib 62 | implementation supportLibraries.appCompat 63 | implementation supportLibraries.recyclerView 64 | implementation supportLibraries.constraintLayout 65 | implementation supportLibraries.design 66 | implementation supportLibraries.viewModel 67 | implementation libraries.retrofit 68 | implementation libraries.retrofitRxJava 69 | implementation libraries.retrofitMoshi 70 | implementation libraries.rxRelay 71 | implementation libraries.rxJava 72 | implementation libraries.rxAndroid 73 | implementation libraries.timber 74 | implementation libraries.rxBinding 75 | implementation libraries.okhttp 76 | implementation libraries.moshiKotlin 77 | implementation libraries.moshi 78 | 79 | kapt libraries.moshiCodeGen 80 | implementation libraries.moshi 81 | implementation libraries.dagger 82 | kapt libraries.daggerCompiler 83 | implementation project(':library') 84 | implementation testLibraries.okhttpTls 85 | 86 | testImplementation testLibraries.junit 87 | testImplementation testLibraries.androidArchTesting 88 | testImplementation testLibraries.mockWebServer 89 | testImplementation libraries.moshiKotlin 90 | testImplementation libraries.moshi 91 | 92 | kaptTest libraries.daggerCompiler 93 | androidTestImplementation testLibraries.junit 94 | androidTestImplementation testLibraries.testRunner 95 | androidTestImplementation testLibraries.espresso 96 | androidTestImplementation testLibraries.espressoContrib 97 | androidTestImplementation testLibraries.testRules 98 | androidTestImplementation testLibraries.screengrab 99 | androidTestImplementation testLibraries.deviceAnimationsRule 100 | androidTestImplementation testLibraries.mockWebServer 101 | androidTestImplementation libraries.moshiKotlin 102 | androidTestImplementation libraries.moshi 103 | } 104 | -------------------------------------------------------------------------------- /sample/docs/pagination-sequence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/docs/pagination-sequence.png -------------------------------------------------------------------------------- /sample/docs/rxredux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/docs/rxredux.png -------------------------------------------------------------------------------- /sample/docs/sideeffect1-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/docs/sideeffect1-ui.png -------------------------------------------------------------------------------- /sample/docs/sideeffect2-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/docs/sideeffect2-ui.png -------------------------------------------------------------------------------- /sample/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/screenshots/PopularRepositoriesActivity_State_1.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/screenshots/PopularRepositoriesActivity_State_2.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/screenshots/PopularRepositoriesActivity_State_3.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/screenshots/PopularRepositoriesActivity_State_4.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/screenshots/PopularRepositoriesActivity_State_5.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/screenshots/PopularRepositoriesActivity_State_6.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/screenshots/PopularRepositoriesActivity_State_7.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/screenshots/PopularRepositoriesActivity_State_8.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/screenshots/PopularRepositoriesActivity_State_9.png -------------------------------------------------------------------------------- /sample/src/androidTest/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/freeletics/rxredux/PopularRepositoriesActivityTest.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.content.Intent 4 | import android.support.test.espresso.Espresso 5 | import android.support.test.espresso.ViewAction 6 | import android.support.test.espresso.action.GeneralLocation 7 | import android.support.test.espresso.action.GeneralSwipeAction 8 | import android.support.test.espresso.action.Press 9 | import android.support.test.espresso.action.Swipe 10 | import android.support.test.espresso.action.ViewActions 11 | import android.support.test.espresso.contrib.RecyclerViewActions 12 | import android.support.test.espresso.matcher.ViewMatchers 13 | import android.support.test.rule.ActivityTestRule 14 | import android.support.test.rule.GrantPermissionRule 15 | import android.support.test.runner.AndroidJUnit4 16 | import android.support.v7.widget.RecyclerView 17 | import com.freeletics.rxredux.businesslogic.pagination.PaginationStateMachine 18 | import io.reactivex.Observable 19 | import io.victoralbertos.device_animation_test_rule.DeviceAnimationTestRule 20 | import okhttp3.mockwebserver.MockWebServer 21 | import org.junit.ClassRule 22 | import org.junit.Rule 23 | import org.junit.Test 24 | import org.junit.runner.RunWith 25 | import tools.fastlane.screengrab.Screengrab 26 | import tools.fastlane.screengrab.UiAutomatorScreenshotStrategy 27 | import tools.fastlane.screengrab.locale.LocaleTestRule 28 | 29 | 30 | @RunWith(AndroidJUnit4::class) 31 | class PopularRepositoriesActivityTest { 32 | 33 | @get:Rule 34 | val activityTestRule = ActivityTestRule(PopularRepositoriesActivity::class.java, false, false) 35 | 36 | @get:Rule 37 | val permission = GrantPermissionRule.grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) 38 | 39 | companion object { 40 | @get:ClassRule 41 | @JvmStatic 42 | val localeTestRule = LocaleTestRule() 43 | 44 | @get:ClassRule 45 | @JvmStatic 46 | var deviceAnimationTestRule = DeviceAnimationTestRule() 47 | } 48 | 49 | @Test 50 | fun runTests() { 51 | // Setup test environment 52 | Screengrab.setDefaultScreenshotStrategy(UiAutomatorScreenshotStrategy()) 53 | 54 | PopularRepositoriesSpec( 55 | screen = AndroidScreen(activityTestRule), 56 | stateHistory = StateHistory(AndroidStateRecorder()), 57 | config = ScreenConfig(mockWebServer = MockWebServer().setupForHttps()) 58 | ).runTests() 59 | } 60 | 61 | class AndroidScreen( 62 | private val activityRule: ActivityTestRule 63 | ) : Screen { 64 | override fun scrollToEndOfList() { 65 | Espresso 66 | .onView(ViewMatchers.withId(R.id.recyclerView)) 67 | .perform( 68 | RecyclerViewActions.scrollToPosition( 69 | RecordingPopularRepositoriesViewBinding.INSTANCE.lastPositionInAdapter() - 1 70 | ) 71 | ) 72 | 73 | Espresso 74 | .onView(ViewMatchers.withId(R.id.recyclerView)) 75 | .perform(swipeFromBottomToTop()) 76 | 77 | } 78 | 79 | override fun retryLoadingFirstPage() { 80 | Espresso.onView(ViewMatchers.withId(R.id.error)) 81 | .perform(ViewActions.click()) 82 | } 83 | 84 | override fun loadFirstPage() { 85 | activityRule.launchActivity(Intent()) 86 | } 87 | 88 | private fun swipeFromBottomToTop(): ViewAction { 89 | return GeneralSwipeAction( 90 | Swipe.FAST, GeneralLocation.BOTTOM_CENTER, 91 | GeneralLocation.TOP_CENTER, Press.FINGER 92 | ) 93 | } 94 | } 95 | 96 | inner class AndroidStateRecorder : StateRecorder { 97 | override fun renderedStates(): Observable = 98 | RecordingPopularRepositoriesViewBinding.INSTANCE.recordedStates 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/freeletics/rxredux/QueueingScreenshotTaker.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.app.Activity 4 | import android.content.ContextWrapper 5 | import android.os.Handler 6 | import android.os.Looper 7 | import android.view.View 8 | import android.view.ViewTreeObserver 9 | import com.facebook.testing.screenshot.Screenshot 10 | import com.freeletics.rxredux.businesslogic.pagination.PaginationStateMachine 11 | import io.reactivex.subjects.Subject 12 | import timber.log.Timber 13 | import tools.fastlane.screengrab.Screengrab 14 | import java.util.* 15 | 16 | 17 | /** 18 | * This is a simple queue system ensures that a screenshot is taken of every state transition. 19 | * 20 | * This ensures that if state transitions happens very quickly one after each other it adds some delay 21 | * and processes them sequentially by waiting until first state is rendered (layouted) and drawn (take also a screenshot) 22 | * and then continue processing the next state. 23 | */ 24 | class QueueingScreenshotTaker( 25 | rootView: View, 26 | private val subject: Subject, 27 | private val dispatchRendering: (PaginationStateMachine.State) -> Unit 28 | ) : ViewTreeObserver.OnPreDrawListener { 29 | 30 | init { 31 | rootView.viewTreeObserver.addOnPreDrawListener(this) 32 | } 33 | 34 | private val activity: Activity = rootView.getActivity() 35 | 36 | private val handler = Handler(Looper.getMainLooper()) 37 | 38 | private val queue: Queue = LinkedList() 39 | private var screenshotCounter = 1 40 | 41 | fun enqueue(state: PaginationStateMachine.State) { 42 | Timber.d("Enqueueing $state") 43 | queue.offer(QueueEntry(state, WaitingState.ENQUEUED)) 44 | dispatchNextWaitingStateIfNothingWaitedToBeDrawn() 45 | } 46 | 47 | private fun dispatchNextWaitingStateIfNothingWaitedToBeDrawn() { 48 | if (queue.isNotEmpty()) { 49 | val queueEntry = queue.peek() 50 | if (queueEntry.waitingState == WaitingState.ENQUEUED) { 51 | Timber.d("Ready to render (layouting) ${queueEntry.state}. Queue $queue") 52 | queueEntry.waitingState = WaitingState.WAITING_TO_BE_DRAWN 53 | dispatchRendering(queueEntry.state) 54 | } else { 55 | Timber.d("Cannot dispatchNextWaitingStateIfNothingWaitedToBeDrawn() because head of queue is already waiting to be drawn $queue") 56 | } 57 | } 58 | } 59 | 60 | override fun onPreDraw(): Boolean { 61 | Timber.d("onPreDraw. Queue $queue") 62 | if (queue.isNotEmpty()) { 63 | val topOfQueue = queue.peek() 64 | Timber.d("Top of the queue $topOfQueue") 65 | if (topOfQueue.waitingState == WaitingState.WAITING_TO_BE_DRAWN) { 66 | topOfQueue.waitingState = WaitingState.WAITING_FOR_SCREENSHOT 67 | handler.postDelayed({ 68 | val (state, _) = queue.poll() 69 | val screenshotName = "PopularRepositoriesActivity_State_${screenshotCounter++}" 70 | Screenshot.snapActivity(activity).setName(screenshotName) 71 | .record() 72 | Screengrab.screenshot("Screengrab_$screenshotName") 73 | Timber.d("Drawn $state. Screenshot taken. Queue $queue") 74 | subject.onNext(state) 75 | dispatchNextWaitingStateIfNothingWaitedToBeDrawn() 76 | }, 1000) // Wait until all frames has been drawn 77 | } 78 | } 79 | return true 80 | } 81 | 82 | private fun View.getActivity(): Activity { 83 | var context = getContext() 84 | while (context is ContextWrapper) { 85 | if (context is Activity) { 86 | return context 87 | } 88 | context = context.baseContext 89 | } 90 | 91 | throw RuntimeException("Could not find parent Activity for $this") 92 | } 93 | 94 | private enum class WaitingState { 95 | ENQUEUED, 96 | WAITING_TO_BE_DRAWN, 97 | WAITING_FOR_SCREENSHOT 98 | } 99 | 100 | private data class QueueEntry( 101 | val state: PaginationStateMachine.State, 102 | var waitingState: WaitingState 103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/freeletics/rxredux/RecordingPopularRepositoriesViewBinding.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.view.ViewGroup 4 | import com.freeletics.rxredux.businesslogic.pagination.PaginationStateMachine 5 | import io.reactivex.Observable 6 | import io.reactivex.schedulers.Schedulers 7 | import io.reactivex.subjects.ReplaySubject 8 | import timber.log.Timber 9 | 10 | 11 | class RecordingPopularRepositoriesViewBinding(rootView: ViewGroup) : PopularRepositoriesViewBinding(rootView) { 12 | companion object { 13 | lateinit var INSTANCE: RecordingPopularRepositoriesViewBinding 14 | } 15 | 16 | private val subject = ReplaySubject.create() 17 | val recordedStates: Observable = 18 | subject.observeOn(Schedulers.io()) 19 | private val screenshotTaker = QueueingScreenshotTaker( 20 | rootView = rootView, 21 | subject = subject, 22 | dispatchRendering = { super.render(it) } 23 | ) 24 | 25 | fun lastPositionInAdapter() = adapter.itemCount 26 | 27 | init { 28 | INSTANCE = this // I'm just to lazy to setup dagger properly :( 29 | } 30 | 31 | override fun render(state: PaginationStateMachine.State) { 32 | Timber.d("Screen State to render: $state") 33 | screenshotTaker.enqueue(state) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/freeletics/rxredux/SampleAppRunner.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.os.Bundle 6 | import android.support.test.runner.AndroidJUnitRunner 7 | import com.facebook.testing.screenshot.ScreenshotRunner 8 | 9 | 10 | class SampleAppRunner : AndroidJUnitRunner() { 11 | override fun newApplication( 12 | cl: ClassLoader?, 13 | className: String?, 14 | context: Context? 15 | ): Application { 16 | val application = 17 | super.newApplication(cl, SampleTestApplication::class.java.canonicalName, context) 18 | return application 19 | } 20 | 21 | override fun onCreate(args: Bundle) { 22 | super.onCreate(args) 23 | ScreenshotRunner.onCreate(this, args) 24 | } 25 | 26 | override fun finish(resultCode: Int, results: Bundle) { 27 | ScreenshotRunner.onDestroy() 28 | super.finish(resultCode, results) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/freeletics/rxredux/SampleTestApplication.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.view.ViewGroup 4 | import com.freeletics.di.TestApplicationModule 5 | import com.freeletics.rxredux.di.DaggerApplicationComponent 6 | import io.reactivex.android.schedulers.AndroidSchedulers 7 | 8 | class SampleTestApplication : SampleApplication() { 9 | 10 | override fun componentBuilder(builder: DaggerApplicationComponent.Builder) = 11 | builder.applicationModule( 12 | TestApplicationModule( 13 | baseUrl = "http://127.0.0.1:$MOCK_WEB_SERVER_PORT", 14 | androidScheduler = AndroidSchedulers.mainThread(), 15 | viewBindingInstantiatorMap = mapOf, ViewBindingInstantiator>( 16 | PopularRepositoriesActivity::class.java to { rootView: ViewGroup -> 17 | RecordingPopularRepositoriesViewBinding( 18 | rootView 19 | ) 20 | } 21 | ) 22 | ) 23 | ) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /sample/src/androidTest/resources/response1.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_count": 20, 3 | "incomplete_results": false, 4 | "items": [ 5 | { 6 | "id": 11, 7 | "name": "Repo11", 8 | "stargazers_count": 100 9 | }, 10 | { 11 | "id": 12, 12 | "name": "Repo12", 13 | "stargazers_count": 100 14 | }, 15 | { 16 | "id": 13, 17 | "name": "Repo13", 18 | "stargazers_count": 100 19 | }, 20 | { 21 | "id": 14, 22 | "name": "Repo14", 23 | "stargazers_count": 100 24 | }, 25 | { 26 | "id": 15, 27 | "name": "Repo15", 28 | "stargazers_count": 100 29 | }, 30 | { 31 | "id": 16, 32 | "name": "Repo16", 33 | "stargazers_count": 100 34 | }, 35 | { 36 | "id": 17, 37 | "name": "Repo17", 38 | "stargazers_count": 100 39 | }, 40 | { 41 | "id": 18, 42 | "name": "Repo18", 43 | "stargazers_count": 100 44 | }, 45 | { 46 | "id": 19, 47 | "name": "Repo19", 48 | "stargazers_count": 100 49 | }, 50 | { 51 | "id": 20, 52 | "name": "Repo10", 53 | "stargazers_count": 100 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/PopularRepositoriesActivity.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.app.Activity 4 | import android.arch.lifecycle.Observer 5 | import android.arch.lifecycle.ViewModel 6 | import android.arch.lifecycle.ViewModelProvider 7 | import android.arch.lifecycle.ViewModelProviders 8 | import android.os.Bundle 9 | import android.support.v7.app.AppCompatActivity 10 | import com.freeletics.rxredux.businesslogic.pagination.Action 11 | import com.freeletics.rxredux.util.viewModel 12 | import io.reactivex.disposables.CompositeDisposable 13 | import kotlinx.android.synthetic.main.activity_main.* 14 | import javax.inject.Inject 15 | import javax.inject.Provider 16 | 17 | class PopularRepositoriesActivity : AppCompatActivity() { 18 | 19 | @Inject 20 | lateinit var viewModelProvider: Provider 21 | 22 | private val viewModel by lazy { 23 | viewModel(SimpleViewModelProviderFactory(viewModelProvider)) 24 | } 25 | 26 | @Inject 27 | lateinit var viewBindingFactory: ViewBindingFactory 28 | 29 | private val viewBinding by lazy { 30 | viewBindingFactory.create( 31 | PopularRepositoriesActivity::class.java, 32 | rootView 33 | ) 34 | } 35 | 36 | private val disposables = CompositeDisposable() 37 | 38 | override fun onCreate(savedInstanceState: Bundle?) { 39 | super.onCreate(savedInstanceState) 40 | setContentView(R.layout.activity_main) 41 | applicationComponent.inject(this) 42 | 43 | viewModel.state.observe(this, Observer { 44 | viewBinding.render(it!!) 45 | }) 46 | 47 | disposables.add( 48 | viewBinding.endOfRecyclerViewReached 49 | .map { Action.LoadNextPageAction } 50 | .subscribe(viewModel.input) 51 | ) 52 | 53 | viewModel.input.accept(Action.LoadFirstPageAction) 54 | 55 | disposables.add( 56 | viewBinding.retryLoadFirstPage 57 | .map { Action.LoadFirstPageAction } 58 | .subscribe(viewModel.input) 59 | ) 60 | } 61 | 62 | override fun onDestroy() { 63 | super.onDestroy() 64 | disposables.dispose() 65 | } 66 | 67 | private val Activity.applicationComponent 68 | get() = (application as SampleApplication).applicationComponent 69 | } 70 | 71 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/PopularRepositoriesAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.support.v7.widget.RecyclerView 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.TextView 8 | import com.freeletics.rxredux.businesslogic.github.GithubRepository 9 | 10 | const val VIEW_TYPE_REPO = 1 11 | const val VIEW_TYPE_LOADING_NEXT = 2 12 | 13 | 14 | class MainAdapter(val layoutInflater: LayoutInflater) : 15 | RecyclerView.Adapter() { 16 | 17 | var items = emptyList() 18 | var showLoading = false 19 | 20 | private fun realSize() = items.size + (if (showLoading) 1 else 0) 21 | 22 | override fun getItemViewType(position: Int): Int = 23 | if (showLoading && position == items.size) // Its the last item and show loading 24 | VIEW_TYPE_LOADING_NEXT 25 | else 26 | VIEW_TYPE_REPO 27 | 28 | 29 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = 30 | when (viewType) { 31 | VIEW_TYPE_REPO -> GitRepositoryViewHolder( 32 | layoutInflater.inflate( 33 | R.layout.item_repository, 34 | parent, 35 | false 36 | ) 37 | ) 38 | VIEW_TYPE_LOADING_NEXT -> LoadingNextPageViewHolder( 39 | layoutInflater.inflate( 40 | R.layout.item_load_next, 41 | parent, 42 | false 43 | ) 44 | ) 45 | else -> throw IllegalArgumentException("ViewType $viewType is unexpected") 46 | } 47 | 48 | override fun getItemCount(): Int = realSize() 49 | 50 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 51 | if (holder is GitRepositoryViewHolder) { 52 | holder.bind(items[position]) 53 | } 54 | } 55 | 56 | inner class GitRepositoryViewHolder(v: View) : RecyclerView.ViewHolder(v) { 57 | private val repoName = v.findViewById(R.id.repoName) 58 | private val starCount = v.findViewById(R.id.starCount) 59 | 60 | fun bind(repo: GithubRepository) { 61 | repo.apply { 62 | repoName.text = name 63 | starCount.text = stars.toString() 64 | } 65 | } 66 | } 67 | 68 | inner class LoadingNextPageViewHolder(v: View) : RecyclerView.ViewHolder(v) 69 | } 70 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/PopularRepositoriesViewBinding.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.support.design.widget.Snackbar 4 | import android.support.v7.widget.RecyclerView 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import com.freeletics.rxredux.businesslogic.github.GithubRepository 9 | import com.freeletics.rxredux.businesslogic.pagination.PaginationStateMachine 10 | import com.jakewharton.rxbinding2.view.clicks 11 | import io.reactivex.Observable 12 | import timber.log.Timber 13 | import java.util.concurrent.TimeUnit 14 | 15 | 16 | open class PopularRepositoriesViewBinding(protected val rootView: ViewGroup) { 17 | 18 | protected val recyclerView: RecyclerView = rootView.findViewById(R.id.recyclerView) 19 | protected val adapter: MainAdapter = MainAdapter(LayoutInflater.from(rootView.context)) 20 | protected val loading: View = rootView.findViewById(R.id.loading) 21 | protected val error: View = rootView.findViewById(R.id.error) 22 | protected var snackBar: Snackbar? = null 23 | 24 | init { 25 | recyclerView.adapter = adapter 26 | } 27 | 28 | val retryLoadFirstPage = error.clicks() 29 | 30 | val endOfRecyclerViewReached = Observable.create { emitter -> 31 | val listener = object : RecyclerView.OnScrollListener() { 32 | override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { 33 | super.onScrollStateChanged(recyclerView, newState) 34 | 35 | val endReached = !recyclerView!!.canScrollVertically(1) 36 | Timber.d("Scroll changed: $endReached") 37 | if (endReached) { 38 | emitter.onNext(Unit) 39 | } 40 | } 41 | } 42 | 43 | emitter.setCancellable { recyclerView.removeOnScrollListener(listener) } 44 | 45 | recyclerView.addOnScrollListener(listener) 46 | }.debounce(200, TimeUnit.MILLISECONDS) 47 | 48 | open fun render(state: PaginationStateMachine.State) = 49 | when (state) { 50 | PaginationStateMachine.State.LoadingFirstPageState -> { 51 | recyclerView.gone() 52 | loading.visible() 53 | error.gone() 54 | } 55 | is PaginationStateMachine.State.ShowContentState -> { 56 | showRecyclerView(items = state.items, showLoadingNext = false) 57 | } 58 | 59 | is PaginationStateMachine.State.ShowContentAndLoadNextPageState -> { 60 | showRecyclerView(items = state.items, showLoadingNext = true) 61 | recyclerView.scrollToPosition(adapter.items.count()) // Scroll to the last item 62 | } 63 | 64 | is PaginationStateMachine.State.ShowContentAndLoadNextPageErrorState -> { 65 | showRecyclerView(items = state.items, showLoadingNext = false) 66 | snackBar = 67 | Snackbar.make( 68 | rootView, 69 | R.string.unexpected_error, 70 | Snackbar.LENGTH_INDEFINITE 71 | ) 72 | snackBar!!.show() 73 | } 74 | 75 | is PaginationStateMachine.State.ErrorLoadingFirstPageState -> { 76 | recyclerView.gone() 77 | loading.gone() 78 | error.visible() 79 | snackBar?.dismiss() 80 | } 81 | } 82 | 83 | 84 | private fun showRecyclerView(items: List, showLoadingNext: Boolean) { 85 | recyclerView.visible() 86 | loading.gone() 87 | error.gone() 88 | 89 | adapter.items = items 90 | adapter.showLoading = showLoadingNext 91 | adapter.notifyDataSetChanged() 92 | 93 | snackBar?.dismiss() 94 | } 95 | 96 | private fun View.gone() { 97 | visibility = View.GONE 98 | } 99 | 100 | private fun View.visible() { 101 | visibility = View.VISIBLE 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/PopularRepositoriesViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.arch.lifecycle.LiveData 4 | import android.arch.lifecycle.MutableLiveData 5 | import android.arch.lifecycle.ViewModel 6 | import com.freeletics.rxredux.businesslogic.pagination.Action 7 | import com.freeletics.rxredux.businesslogic.pagination.PaginationStateMachine 8 | import com.freeletics.rxredux.di.AndroidScheduler 9 | import com.jakewharton.rxrelay2.PublishRelay 10 | import com.jakewharton.rxrelay2.Relay 11 | import io.reactivex.Scheduler 12 | import io.reactivex.android.schedulers.AndroidSchedulers 13 | import io.reactivex.disposables.CompositeDisposable 14 | import io.reactivex.functions.Consumer 15 | import javax.inject.Inject 16 | 17 | class PopularRepositoriesViewModel @Inject constructor( 18 | paginationStateMachine: PaginationStateMachine, 19 | @AndroidScheduler androidScheduler : Scheduler 20 | ) : ViewModel() { 21 | 22 | private val inputRelay: Relay = PublishRelay.create() 23 | private val mutableState = MutableLiveData() 24 | private val disposables = CompositeDisposable() 25 | 26 | val input: Consumer = inputRelay 27 | val state: LiveData = mutableState 28 | 29 | init { 30 | disposables.add(inputRelay.subscribe(paginationStateMachine.input)) 31 | disposables.add( 32 | paginationStateMachine.state 33 | .observeOn(androidScheduler) 34 | .subscribe { state -> mutableState.value = state } 35 | ) 36 | } 37 | 38 | override fun onCleared() { 39 | super.onCleared() 40 | disposables.dispose() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/SampleApplication.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.app.Application 4 | import android.view.ViewGroup 5 | import com.freeletics.rxredux.di.ApplicationModule 6 | import com.freeletics.rxredux.di.DaggerApplicationComponent 7 | import io.reactivex.android.schedulers.AndroidSchedulers 8 | import timber.log.Timber 9 | 10 | open class SampleApplication : Application() { 11 | init { 12 | Timber.plant(Timber.DebugTree()) 13 | } 14 | 15 | val applicationComponent by lazy { 16 | DaggerApplicationComponent.builder().apply { 17 | componentBuilder(this) 18 | }.build() 19 | } 20 | 21 | protected open fun componentBuilder(builder: DaggerApplicationComponent.Builder): DaggerApplicationComponent.Builder = 22 | builder.applicationModule( 23 | ApplicationModule( 24 | baseUrl = "https://api.github.com", 25 | androidScheduler = AndroidSchedulers.mainThread(), 26 | viewBindingInstantiatorMap = mapOf, 27 | ViewBindingInstantiator>( 28 | PopularRepositoriesActivity::class.java to { rootView: ViewGroup -> 29 | PopularRepositoriesViewBinding( 30 | rootView 31 | ) 32 | } 33 | ) 34 | ) 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/SimpleViewModelProviderFactory.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.arch.lifecycle.ViewModelProvider 5 | import javax.inject.Provider 6 | 7 | class SimpleViewModelProviderFactory( 8 | private val provider: Provider) : ViewModelProvider.Factory { 9 | 10 | @Suppress("UNCHECKED_CAST") 11 | override fun create(modelClass: Class): T = provider.get() as T 12 | } 13 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/ViewBindingFactory.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.view.ViewGroup 4 | 5 | 6 | typealias ViewBindingInstantiator = (ViewGroup) -> Any 7 | typealias ViewBindingInstantiatorMap = Map, ViewBindingInstantiator> 8 | 9 | @Suppress("UNCHECKED_CAST") 10 | class ViewBindingFactory( 11 | private val instantiatorMap: ViewBindingInstantiatorMap 12 | ) { 13 | 14 | /** 15 | * creates a new ViewBinding 16 | */ 17 | fun create(key: Class<*>, rootView: ViewGroup) = instantiatorMap[key]!!(rootView) as T 18 | } 19 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/businesslogic/github/GithubApi.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux.businesslogic.github 2 | 3 | import io.reactivex.Single 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | 7 | interface GithubApi { 8 | 9 | @GET("search/repositories") 10 | fun search( 11 | @Query("q") query: String, 12 | @Query("sort") sort: String, 13 | @Query("page") page: Int 14 | ): Single 15 | } 16 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/businesslogic/github/GithubApiFacade.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux.businesslogic.github 2 | 3 | import javax.inject.Inject 4 | 5 | /** 6 | * Simple facade that hides the internals from the outside 7 | */ 8 | class GithubApiFacade @Inject constructor(private val githubApi: GithubApi) { 9 | 10 | fun loadNextPage(page: Int) = githubApi.search( 11 | query = "language:java", 12 | page = page, 13 | sort = "stars" 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/businesslogic/github/GithubRepository.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux.businesslogic.github 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class GithubRepository( 8 | val id : Long, 9 | val name : String, 10 | @Json(name="stargazers_count") val stars : Long 11 | ) 12 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/businesslogic/github/GithubSearchResults.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux.businesslogic.github 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class GithubSearchResults( 7 | val items : List 8 | ) 9 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/businesslogic/pagination/PaginationStateMachine.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux.businesslogic.pagination 2 | 3 | import com.freeletics.rxredux.StateAccessor 4 | import com.freeletics.rxredux.businesslogic.github.GithubApiFacade 5 | import com.freeletics.rxredux.businesslogic.github.GithubRepository 6 | import com.freeletics.rxredux.businesslogic.pagination.PaginationStateMachine.State 7 | import com.freeletics.rxredux.reduxStore 8 | import com.jakewharton.rxrelay2.PublishRelay 9 | import com.jakewharton.rxrelay2.Relay 10 | import io.reactivex.Observable 11 | import io.reactivex.schedulers.Schedulers 12 | import timber.log.Timber 13 | import java.util.concurrent.TimeUnit 14 | import javax.inject.Inject 15 | 16 | /** 17 | * Input Actions 18 | */ 19 | sealed class Action { 20 | /** 21 | * Load the next Page. This is typically invoked by the User by reaching the end of a list 22 | */ 23 | object LoadNextPageAction : Action() { 24 | override fun toString(): String = LoadNextPageAction::class.java.simpleName 25 | } 26 | 27 | /** 28 | * Load the first Page. This is initially triggered when the app starts the first time 29 | * but not after screen orientation changes. 30 | */ 31 | object LoadFirstPageAction : Action() { 32 | override fun toString(): String = LoadFirstPageAction::class.java.simpleName 33 | } 34 | } 35 | 36 | /** 37 | * An Error has occurred while loading next page 38 | * This is an internal Action and not public to the outside 39 | */ 40 | private data class ErrorLoadingPageAction(val error: Throwable, val page: Int) : Action() { 41 | override fun toString(): String = 42 | "${ErrorLoadingPageAction::class.java.simpleName} error=${error.message} page=$page" 43 | } 44 | 45 | /** 46 | * The page has been loaded. The result is attached to this Action. 47 | * This is an internal Action and not public to the outside 48 | */ 49 | private data class PageLoadedAction( 50 | val itemsLoaded: List, 51 | val page: Int 52 | ) : Action() { 53 | override fun toString(): String = 54 | "${PageLoadedAction::class.java.simpleName} itemsLoaded=${itemsLoaded.size} page=$page" 55 | } 56 | 57 | /** 58 | * Action that indicates that loading the next page has failed. 59 | * This only is used for loading next page but not first page. 60 | * 61 | * This is an internal Action and not public to the outside 62 | */ 63 | private data class ShowLoadNextPageErrorAction(val error: Throwable, val page: Int) : Action() { 64 | override fun toString(): String = 65 | "${ShowLoadNextPageErrorAction::class.java.simpleName} error=${error.message} page=$page" 66 | } 67 | 68 | /** 69 | * Hides the indicator that loading next page has failed. 70 | * This only is used for loading next page but not first page. 71 | * 72 | * This is an internal Action and not public to the outside 73 | */ 74 | private data class HideLoadNextPageErrorAction(val error: Throwable, val page: Int) : Action() { 75 | override fun toString(): String = 76 | "${HideLoadNextPageErrorAction::class.java.simpleName} error=${error.message} page=$page" 77 | } 78 | 79 | 80 | /** 81 | * This is an internal Action and not public to the outside 82 | */ 83 | private data class StartLoadingNextPage(val page: Int) : Action() { 84 | override fun toString(): String = "${StartLoadingNextPage::class.java.simpleName} page=$page" 85 | } 86 | 87 | 88 | /** 89 | * This statemachine handles loading the next pages. 90 | * It can have the States described in [State]. 91 | */ 92 | class PaginationStateMachine @Inject constructor( 93 | private val github: GithubApiFacade 94 | ) { 95 | 96 | private interface ContainsItems { 97 | val items: List 98 | val page: Int 99 | } 100 | 101 | sealed class State { 102 | /** 103 | * Loading the first page 104 | */ 105 | object LoadingFirstPageState : State() { 106 | override fun toString(): String = LoadingFirstPageState::class.java.simpleName 107 | } 108 | 109 | /** 110 | * An error while loading the first page has occurred 111 | */ 112 | data class ErrorLoadingFirstPageState(val errorMessage: String) : State() { 113 | override fun toString(): String = 114 | "${ErrorLoadingFirstPageState::class.java.simpleName} error=$errorMessage" 115 | } 116 | 117 | /** 118 | * Show the content 119 | */ 120 | data class ShowContentState( 121 | /** 122 | * The items that has been loaded so far 123 | */ 124 | override val items: List, 125 | 126 | /** 127 | * The current Page 128 | */ 129 | override val page: Int 130 | ) : State(), ContainsItems { 131 | override fun toString(): String = 132 | "${ShowContentState::class.java.simpleName} items=${items.size} page=$page" 133 | } 134 | 135 | /** 136 | * This also means loading the next page has been started. 137 | * The difference to [ShowContentState] is that this also means that a progress indicator at 138 | * the bottom of the list of items show be displayed 139 | */ 140 | data class ShowContentAndLoadNextPageState( 141 | /** 142 | * The items that has been loaded so far 143 | */ 144 | override val items: List, 145 | 146 | /** 147 | * The current Page 148 | */ 149 | override val page: Int 150 | ) : State(), ContainsItems { 151 | override fun toString(): String = 152 | "${ShowContentAndLoadNextPageState::class.java.simpleName} items=${items.size} page=$page" 153 | } 154 | 155 | data class ShowContentAndLoadNextPageErrorState( 156 | /** 157 | * The items that has been loaded so far 158 | */ 159 | override val items: List, 160 | 161 | /** 162 | * An error has occurred while loading the next page 163 | */ 164 | val errorMessage: String, 165 | 166 | /** 167 | * The current Page 168 | */ 169 | override val page: Int 170 | ) : State(), ContainsItems { 171 | override fun toString(): String = 172 | "${ShowContentAndLoadNextPageErrorState::class.java.simpleName} error=$errorMessage items=${items.size}" 173 | } 174 | } 175 | 176 | 177 | val input: Relay = PublishRelay.create() 178 | 179 | val state: Observable = input 180 | .doOnNext { Timber.d("Input Action $it") } 181 | .reduxStore( 182 | initialState = State.LoadingFirstPageState, 183 | sideEffects = listOf( 184 | ::loadFirstPageSideEffect, 185 | ::loadNextPageSideEffect, 186 | ::showAndHideLoadingErrorSideEffect 187 | ), 188 | reducer = ::reducer 189 | ) 190 | .distinctUntilChanged() 191 | .doOnNext { Timber.d("RxStore state $it") } 192 | 193 | /** 194 | * Loads the next Page 195 | */ 196 | private fun nextPage(s: State): Observable { 197 | val nextPage = (if (s is ContainsItems) s.page else 0) + 1 198 | 199 | return github.loadNextPage(nextPage) 200 | .subscribeOn(Schedulers.io()) 201 | .toObservable() 202 | .map { result -> 203 | PageLoadedAction( 204 | itemsLoaded = result.items, 205 | page = nextPage 206 | ) 207 | } 208 | .delay(1, TimeUnit.SECONDS) // Add some delay to make the loading indicator appear, 209 | .onErrorReturn { error -> ErrorLoadingPageAction(error, nextPage) } 210 | .startWith(StartLoadingNextPage(nextPage)) 211 | } 212 | 213 | /** 214 | * Load the first Page 215 | */ 216 | private fun loadFirstPageSideEffect( 217 | actions: Observable, 218 | state: StateAccessor 219 | ): Observable = 220 | actions.ofType(Action.LoadFirstPageAction::class.java) 221 | .filter { state() !is ContainsItems } // If first page has already been loaded, do nothing 222 | .switchMap { 223 | nextPage(state()) 224 | } 225 | 226 | /** 227 | * A Side Effect that loads the next page 228 | */ 229 | private fun loadNextPageSideEffect( 230 | actions: Observable, 231 | state: StateAccessor 232 | ): Observable = 233 | actions 234 | .ofType(Action.LoadNextPageAction::class.java) 235 | .switchMap { 236 | nextPage(state()) 237 | } 238 | 239 | /** 240 | * Shows and hides an error after a given time. 241 | * In UI a snackbar showing an error message would be shown / hidden respectively 242 | */ 243 | private fun showAndHideLoadingErrorSideEffect( 244 | actions: Observable, 245 | state: StateAccessor 246 | ): Observable = 247 | actions.ofType(ErrorLoadingPageAction::class.java) 248 | .filter { it.page > 1 } 249 | .switchMap { action -> 250 | Observable.timer(3, TimeUnit.SECONDS) 251 | .map { HideLoadNextPageErrorAction(action.error, action.page) } 252 | .startWith(ShowLoadNextPageErrorAction(action.error, action.page)) 253 | } 254 | 255 | 256 | /** 257 | * The state reducer. 258 | * Takes Actions and the current state to calculate the new state. 259 | */ 260 | private fun reducer(state: State, action: Action): State { 261 | Timber.d("Reducer reacts on $action. Current State $state") 262 | return when (action) { 263 | is StartLoadingNextPage -> { 264 | if (state is ContainsItems && state.page >= 1) 265 | // Load the next page ( 266 | State.ShowContentAndLoadNextPageState(items = state.items, page = state.page) 267 | else 268 | State.LoadingFirstPageState 269 | } 270 | 271 | is PageLoadedAction -> { 272 | val items: List = if (state is ContainsItems) { 273 | state.items + action.itemsLoaded 274 | } else action.itemsLoaded 275 | 276 | State.ShowContentState( 277 | items = items, 278 | page = action.page 279 | ) 280 | } 281 | 282 | is ErrorLoadingPageAction -> if (action.page == 1) { 283 | State.ErrorLoadingFirstPageState( 284 | action.error.localizedMessage 285 | ) 286 | } else { 287 | state 288 | } // page > 1 is handled in showAndHideLoadingErrorSideEffect() 289 | 290 | is ShowLoadNextPageErrorAction -> { 291 | if (state !is ContainsItems) { 292 | throw IllegalStateException("We never loaded the first page") 293 | } 294 | 295 | State.ShowContentAndLoadNextPageErrorState( 296 | items = state.items, 297 | page = state.page, 298 | errorMessage = action.error.localizedMessage 299 | ) 300 | } 301 | 302 | is HideLoadNextPageErrorAction -> { 303 | if (state !is ContainsItems) { 304 | throw IllegalStateException("We never loaded the first page") 305 | } 306 | State.ShowContentState( 307 | items = state.items, 308 | page = state.page 309 | ) 310 | } 311 | 312 | is Action.LoadFirstPageAction, 313 | is Action.LoadNextPageAction -> state // This is handled by loadNextPageSideEffect 314 | } 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/di/AndroidScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux.di 2 | 3 | import javax.inject.Named 4 | 5 | @Named 6 | annotation class AndroidScheduler 7 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/di/ApplicationComponent.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux.di 2 | 3 | import com.freeletics.rxredux.PopularRepositoriesActivity 4 | import dagger.Component 5 | import javax.inject.Singleton 6 | 7 | @Singleton 8 | @Component(modules =[ApplicationModule::class] ) 9 | interface ApplicationComponent { 10 | 11 | fun inject(into: PopularRepositoriesActivity) 12 | } 13 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/di/ApplicationModule.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux.di 2 | 3 | import com.freeletics.rxredux.ViewBindingFactory 4 | import com.freeletics.rxredux.ViewBindingInstantiatorMap 5 | import com.freeletics.rxredux.businesslogic.github.GithubApi 6 | import dagger.Module 7 | import dagger.Provides 8 | import io.reactivex.Scheduler 9 | import okhttp3.OkHttpClient 10 | import retrofit2.Retrofit 11 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 12 | import retrofit2.converter.moshi.MoshiConverterFactory 13 | import javax.inject.Singleton 14 | 15 | @Module 16 | open class ApplicationModule( 17 | private val baseUrl: String, 18 | private val viewBindingInstantiatorMap: ViewBindingInstantiatorMap, 19 | private val androidScheduler: Scheduler 20 | ) { 21 | 22 | @Provides 23 | @Singleton 24 | open fun provideOkHttp(): OkHttpClient = 25 | OkHttpClient.Builder().build() 26 | 27 | @Provides 28 | @Singleton 29 | open fun provideGithubApi(okHttp: OkHttpClient): GithubApi { 30 | val retrofit = 31 | Retrofit.Builder() 32 | .client(okHttp) 33 | .addConverterFactory(MoshiConverterFactory.create()) 34 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 35 | .baseUrl(baseUrl) 36 | .build() 37 | 38 | return retrofit.create(GithubApi::class.java) 39 | } 40 | 41 | @Provides 42 | @Singleton 43 | fun provideViewBindingFactory() = ViewBindingFactory(viewBindingInstantiatorMap) 44 | 45 | @Provides 46 | @Singleton 47 | @AndroidScheduler 48 | fun androidScheduler() = androidScheduler 49 | } 50 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/rxredux/util/Extensions.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux.util 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.arch.lifecycle.ViewModelProvider 5 | import android.arch.lifecycle.ViewModelProviders 6 | import android.support.v4.app.Fragment 7 | import android.support.v4.app.FragmentActivity 8 | 9 | inline fun FragmentActivity.viewModel(factory: ViewModelProvider.Factory) 10 | = ViewModelProviders.of(this, factory)[T::class.java] 11 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_star_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_warning.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 30 | 31 | 32 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/item_load_next.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/item_repository.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 24 | 25 | 35 | 36 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/RxRedux/6326055ac3097f5eea3f7262a5c312eee40d895d/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/values-fr/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | RxRedux France 3 | Une erreur inattendue s\'est produite. 4 | Une erreur inattendue s\'est produite.\nCliquez ici pour réessayer. 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/values-ja/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | RxRedux 日本 3 | エラーが発生しました. 4 | エラーが発生しました.\n再試行するにはここをクリック. 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3F51B5 4 | #303F9F 5 | #FF4081 6 | 7 | -------------------------------------------------------------------------------- /sample/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | RxRedux 3 | An unexpected Error has occurred 4 | An unexpected Error has occurred.\nClick here to retry 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /sample/src/test/java/com/freeletics/rxredux/PopularRepositoriesJvmTest.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import android.arch.core.executor.testing.InstantTaskExecutorRule 4 | import com.freeletics.di.TestApplicationModule 5 | import com.freeletics.rxredux.businesslogic.pagination.Action 6 | import com.freeletics.rxredux.businesslogic.pagination.PaginationStateMachine 7 | import io.reactivex.Observable 8 | import io.reactivex.schedulers.Schedulers 9 | import io.reactivex.subjects.ReplaySubject 10 | import io.reactivex.subjects.Subject 11 | import okhttp3.mockwebserver.MockWebServer 12 | import org.junit.Rule 13 | import org.junit.Test 14 | import timber.log.Timber 15 | 16 | /** 17 | * Example local unit test, which will execute on the development machine (host). 18 | * 19 | * See [testing documentation](http://d.android.com/tools/testing). 20 | */ 21 | class PopularRepositoriesJvmTest { 22 | 23 | class JvmScreen( 24 | private val viewModel: PopularRepositoriesViewModel 25 | ) : Screen, StateRecorder { 26 | val stateSubject: Subject = ReplaySubject.create() 27 | 28 | override fun scrollToEndOfList() { 29 | Observable.just(Action.LoadNextPageAction).subscribe(viewModel.input) 30 | } 31 | 32 | override fun retryLoadingFirstPage() { 33 | Observable.just(Action.LoadFirstPageAction).subscribe(viewModel.input) 34 | } 35 | 36 | override fun loadFirstPage() { 37 | Observable.just(Action.LoadFirstPageAction).subscribe(viewModel.input) 38 | } 39 | 40 | override fun renderedStates(): Observable = stateSubject 41 | } 42 | 43 | 44 | @Rule 45 | @JvmField 46 | val rule = InstantTaskExecutorRule() 47 | 48 | 49 | @Test 50 | fun runTests() { 51 | Timber.plant(object : Timber.Tree() { 52 | override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { 53 | println(message) 54 | t?.printStackTrace() 55 | } 56 | }) 57 | val applicationComponent = DaggerTestComponent.builder().applicationModule( 58 | TestApplicationModule( 59 | baseUrl = "http://127.0.0.1:$MOCK_WEB_SERVER_PORT", 60 | viewBindingInstantiatorMap = emptyMap(), 61 | androidScheduler = Schedulers.trampoline() 62 | ) 63 | ).build() 64 | 65 | val paginationStateMachine = applicationComponent 66 | .paginationStateMachine() 67 | 68 | val viewModel = 69 | PopularRepositoriesViewModel(paginationStateMachine, Schedulers.trampoline()) 70 | val screen = JvmScreen(viewModel) 71 | viewModel.state.observeForever { 72 | screen.stateSubject.onNext(it!!) 73 | } 74 | 75 | val mockWebServer = MockWebServer() 76 | mockWebServer.setupForHttps() 77 | mockWebServer.use { 78 | PopularRepositoriesSpec( 79 | config = ScreenConfig(it), 80 | screen = screen, 81 | stateHistory = StateHistory(screen) 82 | ).runTests() 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /sample/src/test/java/com/freeletics/rxredux/TestComponent.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import com.freeletics.rxredux.businesslogic.github.GithubApi 4 | import com.freeletics.rxredux.businesslogic.pagination.PaginationStateMachine 5 | import com.freeletics.rxredux.di.ApplicationModule 6 | import dagger.Component 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | @Component(modules =[ApplicationModule::class] ) 11 | interface TestComponent { 12 | 13 | fun paginationStateMachine(): PaginationStateMachine 14 | 15 | fun airplaceModeDecoratedGithubApi() : GithubApi 16 | } 17 | -------------------------------------------------------------------------------- /sample/src/testSpec/java/com/freeletics/di/TestApplicationModule.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.di 2 | 3 | import com.freeletics.rxredux.ViewBindingInstantiatorMap 4 | import com.freeletics.rxredux.di.ApplicationModule 5 | import io.reactivex.Scheduler 6 | import okhttp3.OkHttpClient 7 | import java.util.concurrent.TimeUnit 8 | 9 | 10 | class TestApplicationModule( 11 | baseUrl: String, 12 | viewBindingInstantiatorMap: ViewBindingInstantiatorMap, 13 | androidScheduler: Scheduler 14 | ) : ApplicationModule( 15 | baseUrl = baseUrl, 16 | viewBindingInstantiatorMap = viewBindingInstantiatorMap, 17 | androidScheduler = androidScheduler 18 | ) { 19 | 20 | override fun provideOkHttp(): OkHttpClient = 21 | super.provideOkHttp().newBuilder() 22 | .writeTimeout(20, TimeUnit.SECONDS) 23 | .readTimeout(20, TimeUnit.SECONDS) 24 | .connectTimeout(20, TimeUnit.SECONDS) 25 | .also { 26 | // TODO https://github.com/square/okhttp/issues/4183 27 | /* 28 | val clientCertificates = HandshakeCertificates.Builder() 29 | .addTrustedCertificate(localhostCertificate.certificate()) 30 | .build() 31 | 32 | it.sslSocketFactory( 33 | clientCertificates.sslSocketFactory(), 34 | clientCertificates.trustManager() 35 | ) 36 | */ 37 | } 38 | .build() 39 | 40 | 41 | } 42 | -------------------------------------------------------------------------------- /sample/src/testSpec/java/com/freeletics/rxredux/Data.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import com.freeletics.rxredux.businesslogic.github.GithubRepository 4 | 5 | val FIRST_PAGE: List by lazy { 6 | val r = 1..30L 7 | 8 | r.map { i -> 9 | GithubRepository( 10 | id = i, 11 | name = "Repo$i", 12 | stars = 100 - i 13 | ) 14 | } 15 | } 16 | 17 | val SECOND_PAGE: List by lazy { 18 | val r = 31..60L 19 | 20 | r.map { i -> 21 | GithubRepository( 22 | id = i, 23 | name = "Repo$i", 24 | stars = 100 - i 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /sample/src/testSpec/java/com/freeletics/rxredux/MockWebServerUtils.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import com.freeletics.rxredux.businesslogic.github.GithubRepository 4 | import com.freeletics.rxredux.businesslogic.github.GithubSearchResults 5 | import com.squareup.moshi.Moshi 6 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 7 | import okhttp3.mockwebserver.MockResponse 8 | import okhttp3.mockwebserver.MockWebServer 9 | import okhttp3.tls.HeldCertificate 10 | import java.net.InetAddress 11 | 12 | const val MOCK_WEB_SERVER_PORT = 56541 13 | 14 | private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() 15 | private val githubSearchResultsAdapter = moshi.adapter(GithubSearchResults::class.java) 16 | 17 | /* 18 | val localhostCertificate = HeldCertificate.Builder() 19 | .addSubjectAlternativeName(InetAddress.getByName("localhost").canonicalHostName) 20 | .build() 21 | */ 22 | 23 | fun MockWebServer.enqueue200(items: List) { 24 | // TODO why is loading resources not working? 25 | // val body = MainActivityTest::class.java.getResource("response1.json").readText() 26 | 27 | enqueue( 28 | MockResponse() 29 | .setBody(githubSearchResultsAdapter.toJson(GithubSearchResults(items))) 30 | ) 31 | Thread.sleep(200) 32 | } 33 | 34 | fun MockWebServer.setupForHttps(): MockWebServer { 35 | // TODO https://github.com/square/okhttp/issues/4183 36 | /* 37 | val serverCertificates = HandshakeCertificates.Builder() 38 | .heldCertificate(localhostCertificate) 39 | .build() 40 | 41 | useHttps(serverCertificates.sslSocketFactory(), false) 42 | */ 43 | return this 44 | } 45 | -------------------------------------------------------------------------------- /sample/src/testSpec/java/com/freeletics/rxredux/PopularRepositoriesSpec.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.rxredux 2 | 3 | import com.freeletics.rxredux.businesslogic.pagination.PaginationStateMachine 4 | import io.reactivex.Observable 5 | import okhttp3.mockwebserver.MockWebServer 6 | import org.junit.Assert 7 | import timber.log.Timber 8 | import java.util.concurrent.TimeUnit 9 | 10 | 11 | /** 12 | * Abstraction layer that shows what a user can do on the screen 13 | */ 14 | interface Screen { 15 | /** 16 | * Scroll the list to the item at position 17 | */ 18 | fun scrollToEndOfList() 19 | 20 | /** 21 | * Action on the screen: Clicks on the retry button to retry loading the first page 22 | */ 23 | fun retryLoadingFirstPage() 24 | 25 | /** 26 | * Launches the screen. 27 | * After having this called, the screen is visible 28 | */ 29 | fun loadFirstPage() 30 | } 31 | 32 | /** 33 | * Can record states over time. 34 | */ 35 | interface StateRecorder { 36 | /** 37 | * Observable of recorded States 38 | */ 39 | fun renderedStates(): Observable 40 | } 41 | 42 | /** 43 | * Keep the whole history of all states over time 44 | */ 45 | class StateHistory(private val stateRecorder: StateRecorder) { 46 | 47 | /** 48 | * All states that has been captured and asserted in an `on`cl 49 | */ 50 | private var stateHistory: List = emptyList() 51 | 52 | /** 53 | * Waits until the next state is rendered and then retruns a [StateHistorySnapshot] 54 | * or if a timeout happens then a TimeOutException will be thrown 55 | */ 56 | internal fun waitUntilNextRenderedState(): StateHistorySnapshot { 57 | val recordedStates = stateRecorder.renderedStates() 58 | .take(stateHistory.size + 1L) 59 | .toList() 60 | .timeout(1, TimeUnit.MINUTES) 61 | .doOnError { it.printStackTrace() } 62 | .blockingGet() 63 | 64 | val history = stateHistory 65 | stateHistory = recordedStates 66 | 67 | return StateHistorySnapshot( 68 | actualRecordedStates = recordedStates, 69 | verifiedHistory = history 70 | ) 71 | } 72 | 73 | /** 74 | * A Snapshot in time 75 | */ 76 | internal data class StateHistorySnapshot( 77 | /** 78 | * The actual full recorded history of states 79 | */ 80 | val actualRecordedStates: List, 81 | 82 | /** 83 | * full history of all states that we have already verified / validated and 84 | * are sure that this list of states is correct 85 | */ 86 | val verifiedHistory: List 87 | ) 88 | } 89 | 90 | 91 | private data class Given( 92 | private val screen: Screen, 93 | private val stateHistory: StateHistory, 94 | private val composedMessage: String 95 | ) { 96 | 97 | inner class On(private val composedMessage: String) { 98 | 99 | inner class It(private val composedMessage: String) { 100 | 101 | internal fun assertStateRendered(expectedState: PaginationStateMachine.State) { 102 | 103 | val (recordedStates, verifiedHistory) = stateHistory.waitUntilNextRenderedState() 104 | val expectedStates = verifiedHistory + expectedState 105 | Assert.assertEquals( 106 | composedMessage, 107 | expectedStates, 108 | recordedStates 109 | ) 110 | Timber.d("✅ $composedMessage") 111 | 112 | } 113 | } 114 | 115 | infix fun String.byRendering(expectedState: PaginationStateMachine.State) { 116 | val message = this 117 | val it = It("$composedMessage *IT* $message") 118 | it.assertStateRendered(expectedState) 119 | } 120 | } 121 | 122 | fun on(message: String, block: On.() -> Unit) { 123 | val on = On("*GIVEN* $composedMessage *ON* $message") 124 | on.block() 125 | } 126 | } 127 | 128 | /** 129 | * A simple holder object for all required configuration 130 | */ 131 | data class ScreenConfig( 132 | val mockWebServer: MockWebServer 133 | ) 134 | 135 | class PopularRepositoriesSpec( 136 | private val screen: Screen, 137 | private val stateHistory: StateHistory, 138 | private val config: ScreenConfig 139 | ) { 140 | 141 | private fun given(message: String, block: Given.() -> Unit) { 142 | val given = Given(screen, stateHistory, message) 143 | given.block() 144 | } 145 | 146 | fun runTests() { 147 | val server = config.mockWebServer 148 | val connectionErrorMessage = "Failed to connect to /127.0.0.1:$MOCK_WEB_SERVER_PORT" 149 | 150 | given("the device is offline") { 151 | 152 | server.shutdown() 153 | 154 | on("loading first page") { 155 | 156 | screen.loadFirstPage() 157 | 158 | "shows loading first page" byRendering PaginationStateMachine.State.LoadingFirstPageState 159 | 160 | "shows error loading first page" byRendering 161 | PaginationStateMachine.State.ErrorLoadingFirstPageState( 162 | connectionErrorMessage 163 | ) 164 | } 165 | } 166 | 167 | given("device is online (was offline before)") { 168 | 169 | server.enqueue200(FIRST_PAGE) 170 | server.start(MOCK_WEB_SERVER_PORT) 171 | 172 | Thread.sleep(5000) 173 | 174 | on("user clicks retry loading first page") { 175 | 176 | screen.retryLoadingFirstPage() 177 | 178 | "shows loading" byRendering PaginationStateMachine.State.LoadingFirstPageState 179 | 180 | "shows first page" byRendering PaginationStateMachine.State.ShowContentState( 181 | items = FIRST_PAGE, 182 | page = 1 183 | ) 184 | } 185 | 186 | server.enqueue200(SECOND_PAGE) 187 | 188 | on("scrolling to the end of the first page") { 189 | 190 | screen.scrollToEndOfList() 191 | 192 | "shows loading next page" byRendering 193 | PaginationStateMachine.State.ShowContentAndLoadNextPageState( 194 | items = FIRST_PAGE, 195 | page = 1 196 | ) 197 | 198 | "shows next page content" byRendering 199 | PaginationStateMachine.State.ShowContentState( 200 | items = FIRST_PAGE + SECOND_PAGE, 201 | page = 2 202 | ) 203 | } 204 | 205 | } 206 | 207 | given("device is offline again (was online before)") { 208 | 209 | server.shutdown() 210 | 211 | on("scrolling to end of second page") { 212 | 213 | screen.scrollToEndOfList() 214 | 215 | "shows loading next page" byRendering 216 | PaginationStateMachine.State.ShowContentAndLoadNextPageState( 217 | items = FIRST_PAGE + SECOND_PAGE, 218 | page = 2 219 | ) 220 | 221 | "shows error info for few seconds on top of the list of items" byRendering 222 | PaginationStateMachine.State.ShowContentAndLoadNextPageErrorState( 223 | items = FIRST_PAGE + SECOND_PAGE, 224 | page = 2, 225 | errorMessage = connectionErrorMessage 226 | ) 227 | 228 | "hides error information and shows items only" byRendering 229 | PaginationStateMachine.State.ShowContentState( 230 | items = FIRST_PAGE + SECOND_PAGE, 231 | page = 2 232 | 233 | ) 234 | } 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /sample/src/testSpec/resources/response1.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_count": 20, 3 | "incomplete_results": false, 4 | "items": [ 5 | { 6 | "id": 11, 7 | "name": "Repo11", 8 | "stargazers_count": 100 9 | }, 10 | { 11 | "id": 12, 12 | "name": "Repo12", 13 | "stargazers_count": 100 14 | }, 15 | { 16 | "id": 13, 17 | "name": "Repo13", 18 | "stargazers_count": 100 19 | }, 20 | { 21 | "id": 14, 22 | "name": "Repo14", 23 | "stargazers_count": 100 24 | }, 25 | { 26 | "id": 15, 27 | "name": "Repo15", 28 | "stargazers_count": 100 29 | }, 30 | { 31 | "id": 16, 32 | "name": "Repo16", 33 | "stargazers_count": 100 34 | }, 35 | { 36 | "id": 17, 37 | "name": "Repo17", 38 | "stargazers_count": 100 39 | }, 40 | { 41 | "id": 18, 42 | "name": "Repo18", 43 | "stargazers_count": 100 44 | }, 45 | { 46 | "id": 19, 47 | "name": "Repo19", 48 | "stargazers_count": 100 49 | }, 50 | { 51 | "id": 20, 52 | "name": "Repo10", 53 | "stargazers_count": 100 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /sample/src/testSpec/resources/response2.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_count": 20, 3 | "incomplete_results": false, 4 | "items": [ 5 | { 6 | "id": 1, 7 | "name": "Repo1", 8 | "stargazers_count": 100 9 | }, 10 | { 11 | "id": 2, 12 | "name": "Repo2", 13 | "stargazers_count": 100 14 | }, 15 | { 16 | "id": 3, 17 | "name": "Repo3", 18 | "stargazers_count": 100 19 | }, 20 | { 21 | "id": 4, 22 | "name": "Repo4", 23 | "stargazers_count": 100 24 | }, 25 | { 26 | "id": 5, 27 | "name": "Repo5", 28 | "stargazers_count": 100 29 | }, 30 | { 31 | "id": 6, 32 | "name": "Repo6", 33 | "stargazers_count": 100 34 | }, 35 | { 36 | "id": 7, 37 | "name": "Repo7", 38 | "stargazers_count": 100 39 | }, 40 | { 41 | "id": 8, 42 | "name": "Repo8", 43 | "stargazers_count": 100 44 | }, 45 | { 46 | "id": 9, 47 | "name": "Repo9", 48 | "stargazers_count": 100 49 | }, 50 | { 51 | "id": 10, 52 | "name": "Repo10", 53 | "stargazers_count": 100 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':sample', ':library' 2 | --------------------------------------------------------------------------------