├── sample ├── .gitignore ├── docs │ ├── rxredux.png │ ├── sideeffect1-ui.png │ ├── sideeffect2-ui.png │ └── pagination-sequence.png ├── .readme-images │ ├── screen1.png │ └── screen2.png ├── src │ ├── main │ │ ├── res │ │ │ ├── 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 │ │ │ │ ├── colors.xml │ │ │ │ ├── strings.xml │ │ │ │ └── styles.xml │ │ │ ├── values-ja │ │ │ │ └── strings.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values-fr │ │ │ │ └── strings.xml │ │ │ ├── drawable │ │ │ │ ├── ic_warning.xml │ │ │ │ ├── ic_star_black_24dp.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── layout │ │ │ │ ├── item_load_next.xml │ │ │ │ ├── item_repository.xml │ │ │ │ └── activity_main.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── freeletics │ │ │ │ └── coredux │ │ │ │ ├── di │ │ │ │ ├── AndroidScheduler.kt │ │ │ │ ├── ApplicationComponent.kt │ │ │ │ ├── LogSinksModule.kt │ │ │ │ └── ApplicationModule.kt │ │ │ │ ├── businesslogic │ │ │ │ └── github │ │ │ │ │ ├── GithubSearchResults.kt │ │ │ │ │ ├── GithubRepository.kt │ │ │ │ │ ├── GithubApi.kt │ │ │ │ │ └── GithubApiFacade.kt │ │ │ │ ├── util │ │ │ │ └── Extensions.kt │ │ │ │ ├── SimpleViewModelProviderFactory.kt │ │ │ │ ├── ViewBindingFactory.kt │ │ │ │ ├── SampleApplication.kt │ │ │ │ ├── PopularRepositoriesViewModel.kt │ │ │ │ ├── PopularRepositoriesActivity.kt │ │ │ │ ├── PopularRepositoriesAdapter.kt │ │ │ │ └── PopularRepositoriesViewBinding.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── freeletics │ │ │ └── coredux │ │ │ ├── TestLogSinksModule.kt │ │ │ ├── TestComponent.kt │ │ │ └── PopularRepositoriesJvmTest.kt │ ├── testSpec │ │ ├── java │ │ │ └── com │ │ │ │ └── freeletics │ │ │ │ ├── coredux │ │ │ │ ├── Data.kt │ │ │ │ ├── MockWebServerUtils.kt │ │ │ │ └── PopularRepositoriesSpec.kt │ │ │ │ └── di │ │ │ │ └── TestApplicationModule.kt │ │ └── resources │ │ │ ├── response2.json │ │ │ └── response1.json │ └── androidTest │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── com │ │ │ └── freeletics │ │ │ └── coredux │ │ │ ├── SampleAppRunner.kt │ │ │ ├── SampleTestApplication.kt │ │ │ ├── RecordingPopularRepositoriesViewBinding.kt │ │ │ ├── PopularRepositoriesActivityTest.kt │ │ │ └── QueueingScreenshotTaker.kt │ │ └── resources │ │ └── response1.json ├── 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 ├── proguard-rules.pro ├── build.gradle └── README.md ├── library ├── core │ ├── .gitignore │ ├── gradle.properties │ ├── build.gradle │ └── src │ │ ├── main │ │ └── kotlin │ │ │ └── com │ │ │ └── freeletics │ │ │ └── coredux │ │ │ ├── Extras.kt │ │ │ ├── SideEffect.kt │ │ │ ├── SideEffectHelpers.kt │ │ │ ├── Logging.kt │ │ │ └── ReduxStore.kt │ │ └── test │ │ └── kotlin │ │ └── com │ │ └── freeletics │ │ └── coredux │ │ ├── LoggerTest.kt │ │ ├── TestsCommon.kt │ │ ├── SimpleSideEffectTest.kt │ │ ├── CancellableSideEffectTest.kt │ │ ├── SimpleStoreTest.kt │ │ └── StoreWithSideEffectsTest.kt └── log │ ├── timber │ ├── gradle.properties │ ├── src │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── java │ │ │ └── com │ │ │ └── freeletics │ │ │ └── coredux │ │ │ └── log │ │ │ └── timber │ │ │ └── TimberLogSink.kt │ └── build.gradle │ ├── common │ ├── gradle.properties │ ├── build.gradle │ └── src │ │ └── main │ │ └── kotlin │ │ └── com │ │ └── freeletics │ │ └── coredux │ │ └── log │ │ └── common │ │ └── LoggerLogSink.kt │ └── android │ ├── gradle.properties │ ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── com │ │ └── freeletics │ │ └── coredux │ │ └── log │ │ └── android │ │ └── AndroidLogSink.kt │ └── build.gradle ├── .gitattributes ├── Gemfile ├── docs ├── step0.png ├── step1.png ├── step2.png ├── step3.png ├── step4.png ├── step5.png ├── step6.png ├── step7.png ├── step8.png ├── step9.png ├── Step10.png ├── Step11.png ├── Step12.png ├── rxredux.png └── step13.png ├── presentation ├── redux.jpg └── presentation_kotlin_meetup.html ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── settings.gradle ├── scripts ├── dokka-android.gradle ├── publishing.gradle └── dokka.gradle ├── fastlane ├── Appfile ├── 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 ├── Screengrabfile └── Fastfile ├── .gitignore ├── CHANGELOG.md ├── gradle.properties ├── .circleci └── config.yml ├── gradlew.bat ├── dependencies.gradle ├── gradlew ├── LICENSE └── README.md /sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /library/core/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.html linguist-language=Kotlin 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /docs/step0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step0.png -------------------------------------------------------------------------------- /docs/step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step1.png -------------------------------------------------------------------------------- /docs/step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step2.png -------------------------------------------------------------------------------- /docs/step3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step3.png -------------------------------------------------------------------------------- /docs/step4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step4.png -------------------------------------------------------------------------------- /docs/step5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step5.png -------------------------------------------------------------------------------- /docs/step6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step6.png -------------------------------------------------------------------------------- /docs/step7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step7.png -------------------------------------------------------------------------------- /docs/step8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step8.png -------------------------------------------------------------------------------- /docs/step9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step9.png -------------------------------------------------------------------------------- /docs/Step10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/Step10.png -------------------------------------------------------------------------------- /docs/Step11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/Step11.png -------------------------------------------------------------------------------- /docs/Step12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/Step12.png -------------------------------------------------------------------------------- /docs/rxredux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/rxredux.png -------------------------------------------------------------------------------- /docs/step13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/docs/step13.png -------------------------------------------------------------------------------- /presentation/redux.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/presentation/redux.jpg -------------------------------------------------------------------------------- /sample/docs/rxredux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/docs/rxredux.png -------------------------------------------------------------------------------- /sample/docs/sideeffect1-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/docs/sideeffect1-ui.png -------------------------------------------------------------------------------- /sample/docs/sideeffect2-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/docs/sideeffect2-ui.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /library/core/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=core 2 | POM_DESCRIPTION=Redux implementation in Kotlin using coroutines 3 | -------------------------------------------------------------------------------- /sample/.readme-images/screen1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/.readme-images/screen1.png -------------------------------------------------------------------------------- /sample/.readme-images/screen2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/.readme-images/screen2.png -------------------------------------------------------------------------------- /sample/docs/pagination-sequence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/docs/pagination-sequence.png -------------------------------------------------------------------------------- /library/log/timber/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=log-timber 2 | POM_DESCRIPTION=LogSink implementation that uses Timber logger -------------------------------------------------------------------------------- /library/log/common/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=log-common 2 | POM_DESCRIPTION=Provides abstract logger LogSink implementation 3 | -------------------------------------------------------------------------------- /library/log/android/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=log-android 2 | POM_DESCRIPTION=LogSink implementation that uses Android Log class 3 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/screenshots/PopularRepositoriesActivity_State_1.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/screenshots/PopularRepositoriesActivity_State_2.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/screenshots/PopularRepositoriesActivity_State_3.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/screenshots/PopularRepositoriesActivity_State_4.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/screenshots/PopularRepositoriesActivity_State_5.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/screenshots/PopularRepositoriesActivity_State_6.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/screenshots/PopularRepositoriesActivity_State_7.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/screenshots/PopularRepositoriesActivity_State_8.png -------------------------------------------------------------------------------- /sample/screenshots/PopularRepositoriesActivity_State_9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/sample/screenshots/PopularRepositoriesActivity_State_9.png -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':sample', 2 | ':library:core', 3 | ':library:log:common', 4 | ':library:log:android', 5 | ':library:log:timber' 6 | -------------------------------------------------------------------------------- /scripts/dokka-android.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'org.jetbrains.dokka-android' 2 | 3 | dokka { 4 | outputFormat = 'html' 5 | outputDirectory = "$buildDir/javadoc" 6 | } 7 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/di/AndroidScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.di 2 | 3 | import javax.inject.Named 4 | 5 | @Named 6 | annotation class AndroidScheduler 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /library/log/timber/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /library/log/android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /sample/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3F51B5 4 | #303F9F 5 | #FF4081 6 | 7 | -------------------------------------------------------------------------------- /sample/src/main/res/values-ja/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | CoRedux Demo 日本 3 | エラーが発生しました. 4 | エラーが発生しました.\n再試行するにはここをクリック. 5 | 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_1_1534635673219.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeletics/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/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/CoRedux/HEAD/fastlane/metadata/android/ja-JP/images/phoneScreenshots/Screengrab_PopularRepositoriesActivity_State_9_1534635723272.png -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/businesslogic/github/GithubSearchResults.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.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/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | CoRedux Demo 3 | An unexpected Error has occurred 4 | An unexpected Error has occurred.\nClick here to retry 5 | 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/values-fr/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | CoRedux Demo 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/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_warning.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /scripts/publishing.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "com.vanniktech.maven.publish" 2 | 3 | mavenPublish { 4 | targets { 5 | uploadArchives { 6 | releaseRepositoryUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" 7 | snapshotRepositoryUrl = "https://oss.sonatype.org/content/repositories/snapshots/" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/businesslogic/github/GithubRepository.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.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 | -------------------------------------------------------------------------------- /scripts/dokka.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'org.jetbrains.dokka' 2 | 3 | dokka { 4 | outputFormat = 'html' 5 | outputDirectory = "$buildDir/javadoc" 6 | 7 | externalDocumentationLink { 8 | url = new URL("https://kotlinlang.org/api/latest/jvm/stdlib/") 9 | } 10 | externalDocumentationLink { 11 | url = new URL("https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/di/ApplicationComponent.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.di 2 | 3 | import com.freeletics.coredux.PopularRepositoriesActivity 4 | import dagger.Component 5 | import javax.inject.Singleton 6 | 7 | @Singleton 8 | @Component(modules =[ 9 | ApplicationModule::class, 10 | LogSinksModule::class 11 | ] ) 12 | interface ApplicationComponent { 13 | 14 | fun inject(into: PopularRepositoriesActivity) 15 | } 16 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_star_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /sample/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /sample/src/test/java/com/freeletics/coredux/TestLogSinksModule.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.multibindings.ElementsIntoSet 6 | import javax.inject.Singleton 7 | 8 | @Module 9 | object TestLogSinksModule { 10 | @Provides 11 | @ElementsIntoSet 12 | @Singleton 13 | @JvmStatic 14 | fun provideNoLogSinks(): MutableSet = emptySet().toMutableSet() 15 | } -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/util/Extensions.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.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.FragmentActivity 7 | 8 | inline fun FragmentActivity.viewModel(factory: ViewModelProvider.Factory) 9 | = ViewModelProviders.of(this, factory)[T::class.java] 10 | -------------------------------------------------------------------------------- /library/log/common/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'java-library' 2 | apply plugin: 'kotlin' 3 | apply from: dokka 4 | apply from: publishing 5 | 6 | dependencies { 7 | api project(":library:core") 8 | } 9 | 10 | sourceCompatibility = "1.7" 11 | targetCompatibility = "1.7" 12 | 13 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile.class).all { 14 | kotlinOptions { 15 | freeCompilerArgs = ["-Xuse-experimental=kotlin.Experimental"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/businesslogic/github/GithubApi.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.businesslogic.github 2 | 3 | import kotlinx.coroutines.Deferred 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 | ): Deferred 15 | } 16 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/businesslogic/github/GithubApiFacade.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.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/coredux/SimpleViewModelProviderFactory.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 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/coredux/di/LogSinksModule.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.di 2 | 3 | import com.freeletics.coredux.LogSink 4 | import com.freeletics.coredux.log.android.AndroidLogSink 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.multibindings.IntoSet 8 | import kotlinx.coroutines.GlobalScope 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | object LogSinksModule { 13 | @Provides 14 | @IntoSet 15 | @Singleton 16 | @JvmStatic 17 | fun androidStoreLogger(): LogSink = AndroidLogSink(GlobalScope) 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.1] - 2019-05-17 4 | - Fix common log sink may crash on missed initial events (#63) 5 | 6 | ## [1.1.0] - 2019-05-02 7 | - Add common [LogSink] implementation 8 | - Add [Timber](https://github.com/JakeWharton/timber) [LogSink] implementation 9 | - Fix custom event prints class name instead of event name 10 | - Update Kotlin to `1.3.31` version 11 | - Update Kotlin coroutines to `1.2.1` version 12 | 13 | ## [1.0.0] - 2019-03-26 14 | - Add initial core library implementation 15 | - Add initial android log artifact implementation 16 | 17 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/ViewBindingFactory.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 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.getValue(key))(rootView) as T 18 | } 19 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/item_load_next.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /library/log/android/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply from: dokka_android 4 | apply from: publishing 5 | 6 | android { 7 | compileSdkVersion versions.compileSdk 8 | defaultConfig { 9 | targetSdkVersion versions.targetSdk 10 | minSdkVersion versions.minSdk 11 | } 12 | } 13 | 14 | dependencies { 15 | api project(":library:log:common") 16 | } 17 | 18 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile.class).all { 19 | kotlinOptions { 20 | freeCompilerArgs = ["-Xuse-experimental=kotlin.Experimental"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /sample/src/test/java/com/freeletics/coredux/TestComponent.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import com.freeletics.coredux.businesslogic.github.GithubApi 4 | import com.freeletics.coredux.businesslogic.pagination.PaginationStateMachine 5 | import com.freeletics.coredux.di.ApplicationModule 6 | import dagger.Component 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | @Component(modules =[ 11 | ApplicationModule::class, 12 | TestLogSinksModule::class 13 | ] ) 14 | interface TestComponent { 15 | 16 | fun paginationStateMachine(): PaginationStateMachine 17 | 18 | fun airplaceModeDecoratedGithubApi() : GithubApi 19 | } 20 | -------------------------------------------------------------------------------- /library/log/timber/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply from: dokka_android 4 | apply from: publishing 5 | 6 | android { 7 | compileSdkVersion versions.compileSdk 8 | defaultConfig { 9 | targetSdkVersion versions.targetSdk 10 | minSdkVersion versions.minSdk 11 | } 12 | } 13 | 14 | dependencies { 15 | api project(":library:log:common") 16 | 17 | implementation libraries.timber 18 | } 19 | 20 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile.class).all { 21 | kotlinOptions { 22 | freeCompilerArgs = ["-Xuse-experimental=kotlin.Experimental"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sample/src/testSpec/java/com/freeletics/coredux/Data.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import com.freeletics.coredux.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/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 | -------------------------------------------------------------------------------- /library/core/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'java-library' 2 | apply plugin: 'kotlin' 3 | apply from: dokka 4 | apply from: publishing 5 | 6 | test { 7 | useJUnitPlatform { 8 | includeEngines "spek2" 9 | } 10 | } 11 | 12 | dependencies { 13 | api libraries.kotlinStdlib 14 | api libraries.coroutinesCore 15 | 16 | testImplementation testLibraries.spek2Dsl 17 | testImplementation testLibraries.junit 18 | testImplementation libraries.kotlinReflect 19 | testImplementation testLibraries.coroutinesTest 20 | testRuntimeOnly testLibraries.spek2Junit5Runner 21 | testRuntimeOnly testLibraries.junit5Engine 22 | 23 | } 24 | 25 | sourceCompatibility = "1.7" 26 | targetCompatibility = "1.7" 27 | 28 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile.class).all { 29 | kotlinOptions { 30 | freeCompilerArgs = ["-Xuse-experimental=kotlin.Experimental"] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /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/coredux/SampleAppRunner.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 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/coredux/SampleTestApplication.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import android.view.ViewGroup 4 | import com.freeletics.coredux.di.DaggerApplicationComponent 5 | import com.freeletics.di.TestApplicationModule 6 | import kotlinx.coroutines.Dispatchers 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 = Dispatchers.Main, 15 | viewBindingInstantiatorMap = mapOf, ViewBindingInstantiator>( 16 | PopularRepositoriesActivity::class.java to { rootView: ViewGroup -> 17 | RecordingPopularRepositoriesViewBinding( 18 | rootView 19 | ) 20 | } 21 | ) 22 | ) 23 | ) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /library/log/android/src/main/java/com/freeletics/coredux/log/android/AndroidLogSink.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.log.android 2 | 3 | import android.util.Log 4 | import com.freeletics.coredux.log.common.LoggerLogSink 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.GlobalScope 7 | 8 | /** 9 | * Provides [com.freeletics.coredux.LogSink] implementation, that uses Android [Log] class to send log output. 10 | * 11 | * @param scope to receive incoming log messages, default is [GlobalScope] 12 | */ 13 | class AndroidLogSink( 14 | scope: CoroutineScope = GlobalScope 15 | ) : LoggerLogSink(scope) { 16 | override fun debug( 17 | tag: String, 18 | message: String, 19 | throwable: Throwable? 20 | ) { 21 | Log.d(tag, message, throwable) 22 | } 23 | 24 | override fun info( 25 | tag: String, 26 | message: String, 27 | throwable: Throwable? 28 | ) { 29 | Log.i(tag, message, throwable) 30 | } 31 | 32 | override fun warning( 33 | tag: String, 34 | message: String, 35 | throwable: Throwable? 36 | ) { 37 | Log.w(tag, message, throwable) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /library/log/timber/src/main/java/com/freeletics/coredux/log/timber/TimberLogSink.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.log.timber 2 | 3 | import com.freeletics.coredux.log.common.LoggerLogSink 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.GlobalScope 6 | import timber.log.Timber 7 | 8 | /** 9 | * Provides [com.freeletics.coredux.LogSink] implementation, that uses [Timber] class to send log output. 10 | * 11 | * @param scope to receive incoming log messages, default is [GlobalScope] 12 | */ 13 | class TimberLogSink( 14 | scope: CoroutineScope = GlobalScope 15 | ) : LoggerLogSink(scope) { 16 | override fun debug( 17 | tag: String, 18 | message: String, 19 | throwable: Throwable? 20 | ) { 21 | Timber.tag(tag) 22 | Timber.d(throwable, message) 23 | } 24 | 25 | override fun info( 26 | tag: String, 27 | message: String, 28 | throwable: Throwable? 29 | ) { 30 | Timber.tag(tag) 31 | Timber.i(throwable, message) 32 | } 33 | 34 | override fun warning( 35 | tag: String, 36 | message: String, 37 | throwable: Throwable? 38 | ) { 39 | Timber.tag(tag) 40 | Timber.w(throwable, message) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/freeletics/coredux/RecordingPopularRepositoriesViewBinding.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import android.view.ViewGroup 4 | import com.freeletics.coredux.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 | -------------------------------------------------------------------------------- /library/core/src/main/kotlin/com/freeletics/coredux/Extras.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | /** 4 | * Filters [StateReceiver] by only passing states that are distinct from their predecessor state using [isStateDistinct] 5 | * function. 6 | * 7 | * @param [isStateDistinct] receives `previousState` (can be null initially), `newState` and outputs `true` if new state 8 | * should be passed to [StateReceiver] or `false` if not. 9 | */ 10 | fun StateReceiver.distinctUntilChangedBy( 11 | isStateDistinct: (previousState: S?, newState: S) -> Boolean 12 | ): StateReceiver { 13 | var previousState: S? = null 14 | 15 | return { 16 | if (isStateDistinct(previousState, it)) { 17 | previousState = it 18 | this(it) 19 | } 20 | } 21 | } 22 | 23 | /** 24 | * Filters [StateReceiver] by only passing states that are distinct from their predecessor state using states equality, 25 | * only non-equal states will pass. 26 | */ 27 | fun StateReceiver.distinctUntilChanged(): StateReceiver = 28 | distinctUntilChangedBy { previousState, newState -> 29 | previousState != newState 30 | } 31 | 32 | /** 33 | * Subscribe [stateReceiver] to [distinctUntilChanged] state updates on [Store]. 34 | */ 35 | fun Store.subscribeToChangedStateUpdates(stateReceiver: StateReceiver) { 36 | subscribe(stateReceiver.distinctUntilChanged()) 37 | } 38 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/item_repository.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 24 | 25 | 35 | 36 | -------------------------------------------------------------------------------- /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 | 15 | # Publishing 16 | VERSION_NAME=1.2.0-SNAPSHOT 17 | 18 | GROUP=com.freeletics.coredux 19 | 20 | POM_NAME=CoRedux 21 | POM_PACKAGING=jar 22 | 23 | POM_INCEPTION_YEAR=2019 24 | 25 | POM_URL=http://github.com/freeletics/CoRedux/ 26 | POM_SCM_URL=http://github.com/freeletics/CoRedux/ 27 | POM_SCM_CONNECTION=scm:git:git://github.com/freeletics/CoRedux.git 28 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/freeletics/CoRedux.git 29 | 30 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 31 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt 32 | POM_LICENCE_DIST=repo 33 | 34 | POM_DEVELOPER_ID=Freeletics 35 | POM_DEVELOPER_NAME=Freeletics 36 | 37 | signing.keyId=4F8720BE 38 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/SampleApplication.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import android.app.Application 4 | import android.view.ViewGroup 5 | import com.freeletics.coredux.di.ApplicationComponent 6 | import com.freeletics.coredux.di.ApplicationModule 7 | import com.freeletics.coredux.di.DaggerApplicationComponent 8 | import kotlinx.coroutines.Dispatchers 9 | import timber.log.Timber 10 | 11 | open class SampleApplication : Application() { 12 | init { 13 | Timber.plant(Timber.DebugTree()) 14 | } 15 | 16 | val applicationComponent: ApplicationComponent by lazy { 17 | DaggerApplicationComponent.builder().apply { 18 | componentBuilder(this) 19 | }.build() 20 | } 21 | 22 | protected open fun componentBuilder(builder: DaggerApplicationComponent.Builder): DaggerApplicationComponent.Builder = 23 | builder.applicationModule( 24 | ApplicationModule( 25 | baseUrl = "https://api.github.com", 26 | androidScheduler = Dispatchers.Main, 27 | viewBindingInstantiatorMap = mapOf, 28 | ViewBindingInstantiator>( 29 | PopularRepositoriesActivity::class.java to { rootView: ViewGroup -> 30 | PopularRepositoriesViewBinding( 31 | rootView 32 | ) 33 | } 34 | ) 35 | ) 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /sample/src/testSpec/java/com/freeletics/di/TestApplicationModule.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.di 2 | 3 | import com.freeletics.coredux.ViewBindingInstantiatorMap 4 | import com.freeletics.coredux.di.ApplicationModule 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import okhttp3.OkHttpClient 7 | import java.util.concurrent.TimeUnit 8 | 9 | 10 | class TestApplicationModule( 11 | baseUrl: String, 12 | viewBindingInstantiatorMap: ViewBindingInstantiatorMap, 13 | androidScheduler: CoroutineDispatcher 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/main/java/com/freeletics/coredux/PopularRepositoriesViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import android.arch.lifecycle.LiveData 4 | import android.arch.lifecycle.MutableLiveData 5 | import android.arch.lifecycle.ViewModel 6 | import com.freeletics.coredux.businesslogic.pagination.Action 7 | import com.freeletics.coredux.businesslogic.pagination.PaginationStateMachine 8 | import com.freeletics.coredux.di.AndroidScheduler 9 | import kotlinx.coroutines.CoroutineDispatcher 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.Job 12 | import javax.inject.Inject 13 | import kotlin.coroutines.CoroutineContext 14 | 15 | class PopularRepositoriesViewModel @Inject constructor( 16 | paginationStateMachine: PaginationStateMachine, 17 | @AndroidScheduler private val androidScheduler : CoroutineDispatcher 18 | ) : ViewModel(), CoroutineScope { 19 | private val job = Job() 20 | override val coroutineContext: CoroutineContext get() = androidScheduler + job 21 | 22 | private val mutableState = MutableLiveData() 23 | 24 | private val paginationStore = paginationStateMachine.create(this).also { 25 | it.subscribeToChangedStateUpdates { newState: PaginationStateMachine.State -> 26 | mutableState.value = newState 27 | } 28 | } 29 | 30 | val dispatchAction: (Action) -> Unit = paginationStore::dispatch 31 | val state: LiveData = mutableState 32 | 33 | override fun onCleared() { 34 | super.onCleared() 35 | job.cancel() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /sample/src/testSpec/java/com/freeletics/coredux/MockWebServerUtils.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import com.freeletics.coredux.businesslogic.github.GithubRepository 4 | import com.freeletics.coredux.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 | 10 | const val MOCK_WEB_SERVER_PORT = 56541 11 | 12 | private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() 13 | private val githubSearchResultsAdapter = moshi.adapter(GithubSearchResults::class.java) 14 | 15 | /* 16 | val localhostCertificate = HeldCertificate.Builder() 17 | .addSubjectAlternativeName(InetAddress.getByName("localhost").canonicalHostName) 18 | .build() 19 | */ 20 | 21 | fun MockWebServer.enqueue200(items: List) { 22 | // TODO why is loading resources not working? 23 | // val body = MainActivityTest::class.java.getResource("response1.json").readText() 24 | 25 | enqueue( 26 | MockResponse() 27 | .setBody(githubSearchResultsAdapter.toJson(GithubSearchResults(items))) 28 | ) 29 | Thread.sleep(200) 30 | } 31 | 32 | fun MockWebServer.setupForHttps(): MockWebServer { 33 | // TODO https://github.com/square/okhttp/issues/4183 34 | /* 35 | val serverCertificates = HandshakeCertificates.Builder() 36 | .heldCertificate(localhostCertificate) 37 | .build() 38 | 39 | useHttps(serverCertificates.sslSocketFactory(), false) 40 | */ 41 | return this 42 | } 43 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/di/ApplicationModule.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.di 2 | 3 | import com.freeletics.coredux.ViewBindingFactory 4 | import com.freeletics.coredux.ViewBindingInstantiatorMap 5 | import com.freeletics.coredux.businesslogic.github.GithubApi 6 | import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory 7 | import dagger.Module 8 | import dagger.Provides 9 | import kotlinx.coroutines.CoroutineDispatcher 10 | import okhttp3.OkHttpClient 11 | import retrofit2.Retrofit 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: CoroutineDispatcher 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(CoroutineCallAdapterFactory()) 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/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /.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 | jobs: 21 | build_and_test: 22 | docker: 23 | - image: circleci/android:api-28 24 | working_directory: ~/repo 25 | environment: 26 | JVM_OPTS: -Xmx3200m 27 | TERM: dumb 28 | steps: 29 | - checkout 30 | - *restore_gradle_cache 31 | - run: 32 | name: Coredux core build 33 | command: ./gradlew :library:core:build 34 | - run: 35 | name: Coredux core test 36 | command: ./gradlew :library:core:test 37 | - run: 38 | name: Android logsing build 39 | command: ./gradlew :library:log:android:assemble 40 | - run: 41 | name: Build Sample app 42 | command: ./gradlew :sample:assembleDebug 43 | - run: 44 | name: Run sample app tests 45 | command: ./gradlew :sample:testDebug 46 | - *save_gradle_cache 47 | 48 | workflows: 49 | version: 2 50 | 51 | master-pipeline: 52 | jobs: 53 | - build_and_test: 54 | filters: 55 | branches: 56 | only: 57 | - master 58 | - gradle-publish/publish_artifacts: 59 | executor: gradle-publish/circleci-android 60 | context: "android-maven-publish" 61 | requires: 62 | - build_and_test 63 | 64 | check-pr: 65 | jobs: 66 | - build_and_test: 67 | filters: 68 | branches: 69 | ignore: 70 | - master 71 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 30 | 31 | 32 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /library/core/src/main/kotlin/com/freeletics/coredux/SideEffect.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Job 5 | import kotlinx.coroutines.channels.ReceiveChannel 6 | import kotlinx.coroutines.channels.SendChannel 7 | 8 | /** 9 | * Returns a __current__ state of [createStore]. 10 | */ 11 | typealias StateAccessor = () -> S 12 | 13 | /** 14 | * [createStore] side effect that handles cases when action may not update state 15 | * or it may take time to do it. 16 | * 17 | * @param S states type 18 | * @param A actions type 19 | */ 20 | interface SideEffect { 21 | /** 22 | * Preferably _unique per store instance_ name for side effect. 23 | */ 24 | val name: String 25 | 26 | /** 27 | * Starts side effect [Job] in given [CoroutineScope]. 28 | * 29 | * **Not consuming** new actions from [input] will lead to [createStore] error state! 30 | * 31 | * It is up to implementation of side effect to filter actions 32 | * and handle only subset of them. If handling the action takes time, better to launch new coroutine for it 33 | * to not block [createStore] new actions processing - consuming should be fast! Also implementation should care 34 | * about cancelling any previous triggered operations on new input action. 35 | * 36 | * @param input a [ReceiveChannel] of actions that comes from [createStore] 37 | * @param stateAccessor provides a way to get current state of [createStore] 38 | * @param output a [SendChannel] of actions that this side effect should use to produce new actions. If side effect 39 | * doesn't want to handle new action from [input] channel - it could ignore sending action to this [output] channel 40 | * @param logger [SideEffectLogger] instance that should be used to log events 41 | */ 42 | fun CoroutineScope.start( 43 | input: ReceiveChannel, 44 | stateAccessor: StateAccessor, 45 | output: SendChannel, 46 | logger: SideEffectLogger 47 | ) : Job 48 | } 49 | -------------------------------------------------------------------------------- /library/core/src/test/kotlin/com/freeletics/coredux/LoggerTest.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.ObsoleteCoroutinesApi 5 | import kotlinx.coroutines.test.TestCoroutineDispatcher 6 | import kotlinx.coroutines.test.TestCoroutineScope 7 | import org.spekframework.spek2.Spek 8 | import org.spekframework.spek2.style.specification.describe 9 | 10 | @ObsoleteCoroutinesApi 11 | @UseExperimental(ExperimentalCoroutinesApi::class) 12 | class LoggerTest : Spek({ 13 | describe("A Logger") { 14 | val storeName = "some-store" 15 | val testScope by memoized { TestCoroutineScope() } 16 | val loggerDispatcher by memoized { TestCoroutineDispatcher() } 17 | val testLoggers by memoized { listOf( 18 | TestLogger(testScope), 19 | TestLogger(testScope), 20 | TestLogger(testScope) 21 | ) } 22 | 23 | afterEachTest { 24 | testScope.cleanupTestCoroutines() 25 | } 26 | 27 | context("when log sinks are available") { 28 | val logger by memoized { 29 | Logger( 30 | storeName, 31 | testScope, 32 | testLoggers.map { it.sink }, 33 | loggerDispatcher 34 | ) 35 | } 36 | 37 | context("and to log sinks dispatcher is busy") { 38 | beforeEachTest { 39 | loggerDispatcher.pauseDispatcher() 40 | } 41 | 42 | context("on receiving first events") { 43 | beforeEachTest { 44 | logger.logEvent { LogEvent.StoreCreated } 45 | logger.logEvent { LogEvent.ReducerEvent.Start } 46 | loggerDispatcher.resumeDispatcher() 47 | } 48 | 49 | it("should deliver first events on dispatcher availability") { 50 | testLoggers[testLoggers.lastIndex].assertLogEvents( 51 | LogEvent.StoreCreated, 52 | LogEvent.ReducerEvent.Start 53 | ) 54 | } 55 | } 56 | } 57 | } 58 | } 59 | }) 60 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/PopularRepositoriesActivity.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import android.app.Activity 4 | import android.arch.lifecycle.Observer 5 | import android.os.Bundle 6 | import android.support.v7.app.AppCompatActivity 7 | import com.freeletics.coredux.businesslogic.pagination.Action 8 | import com.freeletics.coredux.util.viewModel 9 | import io.reactivex.Observable 10 | import io.reactivex.disposables.CompositeDisposable 11 | import kotlinx.android.synthetic.main.activity_main.* 12 | import javax.inject.Inject 13 | import javax.inject.Provider 14 | 15 | class PopularRepositoriesActivity : AppCompatActivity() { 16 | 17 | @Inject 18 | lateinit var viewModelProvider: Provider 19 | 20 | private val viewModel by lazy { 21 | viewModel(SimpleViewModelProviderFactory(viewModelProvider)) 22 | } 23 | 24 | @Inject 25 | lateinit var viewBindingFactory: ViewBindingFactory 26 | 27 | private val viewBinding by lazy { 28 | viewBindingFactory.create( 29 | PopularRepositoriesActivity::class.java, 30 | rootView 31 | ) 32 | } 33 | 34 | private val disposables = CompositeDisposable() 35 | 36 | override fun onCreate(savedInstanceState: Bundle?) { 37 | super.onCreate(savedInstanceState) 38 | setContentView(R.layout.activity_main) 39 | applicationComponent.inject(this) 40 | 41 | viewModel.state.observe(this, Observer { 42 | viewBinding.render(it!!) 43 | }) 44 | 45 | disposables.add( 46 | Observable 47 | .merge( 48 | viewBinding.endOfRecyclerViewReached.map { Action.LoadNextPageAction }, 49 | viewBinding.retryLoadFirstPage.map { Action.LoadFirstPageAction } 50 | ) 51 | .subscribe(viewModel.dispatchAction) 52 | ) 53 | 54 | viewModel.dispatchAction(Action.LoadFirstPageAction) 55 | } 56 | 57 | override fun onDestroy() { 58 | super.onDestroy() 59 | disposables.dispose() 60 | } 61 | 62 | private val Activity.applicationComponent 63 | get() = (application as SampleApplication).applicationComponent 64 | } 65 | 66 | -------------------------------------------------------------------------------- /sample/src/main/java/com/freeletics/coredux/PopularRepositoriesAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 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.coredux.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 | -------------------------------------------------------------------------------- /presentation/presentation_kotlin_meetup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Implementing CoRedux: from Java to Kotlin 6 | 7 | 72 | 73 | 74 | 75 | 77 | 79 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /sample/src/test/java/com/freeletics/coredux/PopularRepositoriesJvmTest.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import android.arch.core.executor.testing.InstantTaskExecutorRule 4 | import com.freeletics.coredux.businesslogic.pagination.Action 5 | import com.freeletics.coredux.businesslogic.pagination.PaginationStateMachine 6 | import com.freeletics.di.TestApplicationModule 7 | import io.reactivex.Observable 8 | import io.reactivex.subjects.ReplaySubject 9 | import io.reactivex.subjects.Subject 10 | import kotlinx.coroutines.Dispatchers 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.dispatchAction) 30 | } 31 | 32 | override fun retryLoadingFirstPage() { 33 | Observable.just(Action.LoadFirstPageAction).subscribe(viewModel.dispatchAction) 34 | } 35 | 36 | override fun loadFirstPage() { 37 | Observable.just(Action.LoadFirstPageAction).subscribe(viewModel.dispatchAction) 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 = Dispatchers.Unconfined 62 | ) 63 | ).build() 64 | 65 | val paginationStateMachine = applicationComponent 66 | .paginationStateMachine() 67 | 68 | val viewModel = 69 | PopularRepositoriesViewModel(paginationStateMachine, Dispatchers.Unconfined) 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/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.coredux' 15 | } 16 | 17 | android { 18 | compileSdkVersion versions.compileSdk 19 | defaultConfig { 20 | applicationId "com.freeletics.coredux" 21 | targetSdkVersion versions.targetSdk 22 | minSdkVersion versions.minSdk 23 | versionCode 1 24 | versionName "1.0" 25 | testInstrumentationRunner "com.freeletics.coredux.SampleAppRunner" 26 | } 27 | buildTypes { 28 | release { 29 | minifyEnabled false 30 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 31 | } 32 | } 33 | 34 | sourceSets { 35 | test { 36 | java.srcDirs += ['src/testSpec/java'] 37 | resources.srcDirs += ['src/testSpec/resources'] 38 | } 39 | 40 | androidTest { 41 | java.srcDirs += ['src/testSpec/java'] 42 | resources.srcDirs += ['src/testSpec/resources'] 43 | } 44 | } 45 | 46 | compileOptions { 47 | targetCompatibility = JavaVersion.VERSION_1_8 48 | sourceCompatibility = JavaVersion.VERSION_1_8 49 | } 50 | 51 | testOptions { 52 | animationsDisabled = true 53 | } 54 | 55 | lintOptions { 56 | disable 'GoogleAppIndexingWarning','InvalidPackage' 57 | } 58 | } 59 | 60 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile.class).all { 61 | kotlinOptions { 62 | freeCompilerArgs = ["-Xuse-experimental=kotlin.Experimental"] 63 | } 64 | } 65 | 66 | dependencies { 67 | implementation libraries.kotlinStdlib 68 | implementation supportLibraries.appCompat 69 | implementation supportLibraries.recyclerView 70 | implementation supportLibraries.constraintLayout 71 | implementation supportLibraries.design 72 | implementation supportLibraries.viewModel 73 | implementation libraries.retrofit 74 | implementation libraries.retrofitMoshi 75 | implementation libraries.rxJava 76 | implementation libraries.timber 77 | implementation libraries.rxBinding 78 | implementation libraries.okhttp 79 | implementation libraries.moshiKotlin 80 | implementation libraries.moshi 81 | implementation libraries.retrofitCoroutines 82 | implementation libraries.coroutinesAndroid 83 | 84 | kapt libraries.moshiCodeGen 85 | implementation libraries.moshi 86 | implementation libraries.dagger 87 | kapt libraries.daggerCompiler 88 | implementation project(':library:core') 89 | implementation project(':library:log:android') 90 | implementation testLibraries.okhttpTls 91 | 92 | testImplementation testLibraries.junit 93 | testImplementation testLibraries.archCoreTest 94 | testImplementation testLibraries.mockWebServer 95 | 96 | kaptTest libraries.daggerCompiler 97 | androidTestImplementation testLibraries.junit 98 | androidTestImplementation testLibraries.testRunner 99 | androidTestImplementation testLibraries.espressoCore 100 | androidTestImplementation testLibraries.espressoContrib 101 | androidTestImplementation testLibraries.testRules 102 | androidTestImplementation testLibraries.screengrab 103 | androidTestImplementation testLibraries.deviceAnimationRule 104 | androidTestImplementation testLibraries.mockWebServer 105 | androidTestImplementation libraries.moshiKotlin 106 | androidTestImplementation libraries.moshi 107 | } 108 | -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/freeletics/coredux/PopularRepositoriesActivityTest.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 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.coredux.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/main/java/com/freeletics/coredux/PopularRepositoriesViewBinding.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 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.coredux.businesslogic.github.GithubRepository 9 | import com.freeletics.coredux.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/androidTest/java/com/freeletics/coredux/QueueingScreenshotTaker.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 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.coredux.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 | -------------------------------------------------------------------------------- /library/core/src/test/kotlin/com/freeletics/coredux/TestsCommon.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.ObsoleteCoroutinesApi 6 | import kotlinx.coroutines.channels.ReceiveChannel 7 | import kotlinx.coroutines.channels.SendChannel 8 | import kotlinx.coroutines.channels.actor 9 | import kotlinx.coroutines.delay 10 | import kotlinx.coroutines.launch 11 | import kotlinx.coroutines.test.TestCoroutineScope 12 | import org.junit.Assert.assertEquals 13 | 14 | internal const val STATE_LENGTH_SE_NAME = "stateLengthSE" 15 | 16 | internal fun stateLengthSE( 17 | lengthLimit: Int = 10, 18 | loadDelay: Long = 100L 19 | ) = SimpleSideEffect(STATE_LENGTH_SE_NAME) { state, _, _, handler -> 20 | val currentStateLength = state().length 21 | if (currentStateLength <= lengthLimit) { 22 | handler { 23 | delay(loadDelay) 24 | currentStateLength 25 | } 26 | } else { 27 | null 28 | } 29 | } 30 | 31 | internal const val LOGGER_SE_NAME = "LoggerSE" 32 | 33 | internal class LoggerSE: SideEffect { 34 | override val name: String = LOGGER_SE_NAME 35 | 36 | private val recordedActions = mutableListOf() 37 | 38 | val receivedActions get() = recordedActions.toList() 39 | 40 | override fun CoroutineScope.start( 41 | input: ReceiveChannel, 42 | stateAccessor: StateAccessor, 43 | output: SendChannel, 44 | logger: SideEffectLogger 45 | ) = launch { 46 | for (action in input) { 47 | recordedActions.add(action) 48 | } 49 | } 50 | } 51 | 52 | internal const val MULTIPLY_ACTION_SE_NAME = "multiplyActionSE" 53 | 54 | internal fun multiplyActionSE( 55 | produceDelay: Long = 100L 56 | ) = CancellableSideEffect(MULTIPLY_ACTION_SE_NAME) { _, action, _, handler -> 57 | when (action) { 58 | in 100..1000 -> handler { _, output -> 59 | launch { 60 | delay(produceDelay) 61 | output.send(action * 20) 62 | } 63 | } 64 | else -> null 65 | } 66 | } 67 | 68 | internal class TestStateReceiver : StateReceiver { 69 | private val _stateUpdates = mutableListOf() 70 | val stateUpdates get() = _stateUpdates.toList() 71 | 72 | fun assertStates(vararg expectedStates: S) { 73 | val currentStateUpdates = stateUpdates 74 | require (expectedStates.size <= currentStateUpdates.size) { 75 | "Expected ${expectedStates.joinToString(prefix = "[", postfix = "]")} states, " + 76 | "but actually received $currentStateUpdates" 77 | } 78 | val collectedStates = stateUpdates.subList(0, expectedStates.size) 79 | assertEquals(expectedStates.toList(), collectedStates) 80 | } 81 | 82 | override fun invoke(newState: S) { 83 | _stateUpdates.add(newState) 84 | } 85 | } 86 | 87 | internal class TestStateAccessor( 88 | private var currentState: S 89 | ) : StateAccessor { 90 | fun setCurrentState(state: S) { currentState = state } 91 | 92 | override fun invoke(): S = currentState 93 | } 94 | 95 | internal class StubSideEffectLogger : SideEffectLogger { 96 | override fun logSideEffectEvent(event: () -> LogEvent.SideEffectEvent) = Unit 97 | } 98 | 99 | @ExperimentalCoroutinesApi 100 | @ObsoleteCoroutinesApi 101 | internal class TestLogger( 102 | testScope: TestCoroutineScope 103 | ) : LogSink { 104 | private val _logEvents = mutableListOf() 105 | val logEvents get() = _logEvents.toList() 106 | 107 | fun assertLogEvents(vararg expected: LogEvent) { 108 | val receivedLogEvents = logEvents 109 | require (expected.size <= receivedLogEvents.size) { 110 | "Expected ${expected.joinToString(prefix = "[", postfix = "]")} log events, " + 111 | "but actually received $receivedLogEvents" 112 | } 113 | expected.forEachIndexed { index, logEntry -> 114 | assertEquals(logEntry, receivedLogEvents[index].event) 115 | } 116 | } 117 | 118 | override val sink: SendChannel = testScope.actor { 119 | while (true) { 120 | _logEvents.add(receive()) 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /library/core/src/test/kotlin/com/freeletics/coredux/SimpleSideEffectTest.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import junit.framework.TestCase.assertEquals 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.channels.Channel 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.launch 8 | import kotlinx.coroutines.test.TestCoroutineScope 9 | import kotlinx.coroutines.test.runBlockingTest 10 | import org.junit.Assert.assertTrue 11 | import org.spekframework.spek2.Spek 12 | import org.spekframework.spek2.style.specification.describe 13 | 14 | internal typealias SE = SimpleSideEffect 15 | private const val DEFAULT_DELAY = 10L 16 | 17 | @ExperimentalCoroutinesApi 18 | internal object SimpleSideEffectTest : Spek({ 19 | describe("A ${SimpleSideEffect::class.simpleName}") { 20 | val testScope by memoized { TestCoroutineScope() } 21 | val sideEffect by memoized { 22 | SE("test") { state, action, _, handler -> 23 | val currentState = state() 24 | when { 25 | action == 1 && currentState == "" -> handler { 26 | delay(DEFAULT_DELAY) 27 | 100 28 | } 29 | else -> null 30 | } 31 | } 32 | } 33 | val inputChannel by memoized { Channel() } 34 | val outputChannel by memoized { Channel(100) } 35 | val stateAccessor by memoized { TestStateAccessor("") } 36 | val logger by memoized { StubSideEffectLogger() } 37 | 38 | beforeEachTest { 39 | testScope.launch { 40 | with (sideEffect) { 41 | start(inputChannel, stateAccessor, outputChannel, logger) 42 | } 43 | } 44 | } 45 | 46 | afterEachTest { testScope.cleanupTestCoroutines() } 47 | 48 | context("when current state is not \"\"") { 49 | beforeEachTest { stateAccessor.setCurrentState("some-other-state") } 50 | 51 | context("and on new 1 action") { 52 | beforeEachTest { 53 | testScope.launch { inputChannel.send(1) } 54 | testScope.advanceTimeBy(DEFAULT_DELAY + 1) 55 | } 56 | 57 | it("should not call handler") { 58 | assertTrue(outputChannel.isEmpty) 59 | } 60 | } 61 | } 62 | 63 | context("when current state is \"\"") { 64 | context("and on new 2 action") { 65 | beforeEachTest { 66 | testScope.launch { inputChannel.send(2) } 67 | testScope.advanceTimeBy(DEFAULT_DELAY + 1) 68 | } 69 | 70 | it("should not call handler") { 71 | assertTrue(outputChannel.isEmpty) 72 | } 73 | } 74 | 75 | context("and on new 1 action") { 76 | beforeEachTest { 77 | testScope.launch { inputChannel.send(1) } 78 | } 79 | 80 | it("should send 100 action to output channel") { 81 | testScope.advanceTimeBy(DEFAULT_DELAY) 82 | testScope.runBlockingTest { 83 | assertEquals(100, outputChannel.receive()) 84 | } 85 | } 86 | 87 | context("and on second immediate 1 action") { 88 | beforeEachTest { 89 | testScope.advanceTimeBy(DEFAULT_DELAY / 10) 90 | testScope.launch { inputChannel.send(1) } 91 | testScope.advanceTimeBy(DEFAULT_DELAY) 92 | } 93 | 94 | it("should send 100 action to output channel") { 95 | testScope.runBlockingTest { 96 | assertEquals(100, outputChannel.receive()) 97 | } 98 | } 99 | 100 | it("should cancel previous call to handler method") { 101 | val items = mutableListOf() 102 | repeat(3) { 103 | outputChannel.poll()?.let { items.add(it) } 104 | testScope.advanceTimeBy(DEFAULT_DELAY) 105 | } 106 | assertEquals(1, items.size) 107 | } 108 | } 109 | } 110 | } 111 | } 112 | }) 113 | -------------------------------------------------------------------------------- /library/core/src/main/kotlin/com/freeletics/coredux/SideEffectHelpers.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import kotlinx.coroutines.CoroutineName 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.channels.ReceiveChannel 7 | import kotlinx.coroutines.channels.SendChannel 8 | import kotlinx.coroutines.launch 9 | 10 | /** 11 | * Simplified version of [SideEffect] that produces on one input action either one output action or no action. 12 | * 13 | * @param S states type 14 | * @param A actions type 15 | * @param name unique per store instance side effect name 16 | * @param sideEffect function should check for given `action`/`state` combination and return either [Job] 17 | * from `handler {}` coroutine function call or `null`, if side effect does not interested 18 | * in `action` - `state` combination. If `handler {}` coroutine will be called again on new input, 19 | * while previous invocation still running - old one coroutine will be cancelled. 20 | */ 21 | class SimpleSideEffect( 22 | override val name: String, 23 | private val sideEffect: ( 24 | state: StateAccessor, 25 | action: A, 26 | logger: SideEffectLogger, 27 | handler: (suspend (name: String) -> A?) -> Job 28 | ) -> Job? 29 | ) : SideEffect { 30 | 31 | override fun CoroutineScope.start( 32 | input: ReceiveChannel, 33 | stateAccessor: StateAccessor, 34 | output: SendChannel, 35 | logger: SideEffectLogger 36 | ) = launch(context = CoroutineName(name)) { 37 | var job: Job? = null 38 | for (action in input) { 39 | logger.logSideEffectEvent { LogEvent.SideEffectEvent.InputAction(name, action) } 40 | sideEffect(stateAccessor, action, logger) { handler -> 41 | job?.run { 42 | if (isActive) { 43 | logger.logSideEffectEvent { 44 | LogEvent.SideEffectEvent.Custom( 45 | name, 46 | "Cancelling previous job on new $action action" 47 | ) 48 | } 49 | } 50 | cancel() 51 | } 52 | launch { handler(name)?.let { 53 | logger.logSideEffectEvent { LogEvent.SideEffectEvent.DispatchingToReducer(name, it) } 54 | output.send(it) 55 | } } 56 | }?.let { job = it } 57 | } 58 | } 59 | } 60 | 61 | /** 62 | * Version of [SideEffect], that cancels currently executed [Job] and starts a new one. 63 | * 64 | * @param S state type 65 | * @param A action type 66 | * @param name unique per store instance side effect name 67 | * @param sideEffect function should check for given `action`/`state` combination and return either [Job] from 68 | * `handler {}` function call or `null`, if side effect does not interested in `action`/`state` combination. 69 | * If `handler {}` function will be called again on new input, while previous returned [Job] is still running - 70 | * old one [Job] will be cancelled. 71 | */ 72 | class CancellableSideEffect( 73 | override val name: String, 74 | private val sideEffect: ( 75 | state: StateAccessor, 76 | action: A, 77 | logger: SideEffectLogger, 78 | handler: (CoroutineScope.(name: String, output: SendChannel) -> Unit) -> Job 79 | ) -> Job? 80 | ) : SideEffect { 81 | 82 | override fun CoroutineScope.start( 83 | input: ReceiveChannel, 84 | stateAccessor: StateAccessor, 85 | output: SendChannel, 86 | logger: SideEffectLogger 87 | ): Job = launch(context = CoroutineName(name)) { 88 | var job: Job? = null 89 | for (action in input) { 90 | logger.logSideEffectEvent { LogEvent.SideEffectEvent.InputAction(name, action) } 91 | sideEffect(stateAccessor, action, logger) { handler -> 92 | job?.run { 93 | if (isActive) { 94 | logger.logSideEffectEvent { 95 | LogEvent.SideEffectEvent.Custom( 96 | name, 97 | "Cancelling previous job on new $action action" 98 | ) 99 | } 100 | } 101 | cancel() 102 | } 103 | launch { handler(name, output) } 104 | }?.let { job = it } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /library/core/src/test/kotlin/com/freeletics/coredux/CancellableSideEffectTest.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.channels.Channel 5 | import kotlinx.coroutines.delay 6 | import kotlinx.coroutines.launch 7 | import kotlinx.coroutines.test.TestCoroutineScope 8 | import kotlinx.coroutines.test.runBlockingTest 9 | import org.junit.Assert.assertEquals 10 | import org.junit.Assert.assertTrue 11 | import org.spekframework.spek2.Spek 12 | import org.spekframework.spek2.style.specification.describe 13 | 14 | private const val DEFAULT_DELAY_MS = 10L 15 | 16 | @ExperimentalCoroutinesApi 17 | object CancellableSideEffectTest : Spek({ 18 | describe("A ${CancellableSideEffect::class.simpleName}") { 19 | val testScope by memoized { TestCoroutineScope() } 20 | val sideEffect by memoized { 21 | CancellableSideEffect("test") { state, action, _, handler -> 22 | val currentState = state() 23 | when { 24 | action == 1 && currentState == "" -> handler { _, output -> 25 | launch { 26 | delay(DEFAULT_DELAY_MS) 27 | (2..4).forEach { output.send(it) } 28 | } 29 | } 30 | else -> null 31 | } 32 | } 33 | } 34 | val inputChannel by memoized { Channel() } 35 | val outputChannel by memoized { Channel() } 36 | val stateAccessor by memoized { TestStateAccessor("") } 37 | val logger by memoized { StubSideEffectLogger() } 38 | 39 | beforeEachTest { 40 | testScope.launch { 41 | with(sideEffect) { 42 | start(inputChannel, stateAccessor, outputChannel, logger) 43 | } 44 | } 45 | } 46 | 47 | afterEachTest { testScope.cleanupTestCoroutines() } 48 | 49 | context("when current state is not \"\"") { 50 | beforeEachTest { stateAccessor.setCurrentState("some-other-state") } 51 | 52 | context("and on new 1 action") { 53 | beforeEachTest { 54 | testScope.launch { inputChannel.send(1) } 55 | testScope.advanceTimeBy(DEFAULT_DELAY_MS + 1) 56 | } 57 | 58 | it("should not call handler function") { 59 | assertTrue(outputChannel.isEmpty) 60 | } 61 | } 62 | } 63 | 64 | context("when current state is \"\"") { 65 | context("and on new 2 action") { 66 | beforeEachTest { 67 | testScope.launch { inputChannel.send(2) } 68 | testScope.advanceTimeBy(DEFAULT_DELAY_MS + 1) 69 | } 70 | 71 | it("should not call handler function") { 72 | assertTrue(outputChannel.isEmpty) 73 | } 74 | } 75 | } 76 | 77 | context("and on new 1 action") { 78 | beforeEachTest { testScope.launch { inputChannel.send(1) } } 79 | 80 | it("should send 2, 3, 4 actions to output channel") { 81 | val items = mutableListOf() 82 | repeat(3) { 83 | testScope.advanceTimeBy(DEFAULT_DELAY_MS) 84 | testScope.runBlockingTest { items.add(outputChannel.receive()) } 85 | } 86 | assertEquals(listOf(2, 3, 4), items.toList()) 87 | } 88 | 89 | context("and on second immediate 1 action") { 90 | beforeEachTest { testScope.launch { inputChannel.send(1) } } 91 | 92 | it("should send 2, 3, 4 actions to output channel") { 93 | val items = mutableListOf() 94 | repeat(3) { 95 | testScope.advanceTimeBy(DEFAULT_DELAY_MS) 96 | testScope.runBlockingTest { items.add(outputChannel.receive()) } 97 | } 98 | assertEquals(listOf(2, 3, 4), items.toList()) 99 | } 100 | 101 | it("should cancel previous call to handler function") { 102 | val items = mutableListOf() 103 | repeat(5) { 104 | testScope.advanceTimeBy(DEFAULT_DELAY_MS) 105 | outputChannel.poll()?.let { items.add(it) } 106 | } 107 | assertEquals(3, items.size) 108 | assertTrue(outputChannel.isEmpty) 109 | } 110 | } 111 | } 112 | } 113 | }) 114 | -------------------------------------------------------------------------------- /library/log/common/src/main/kotlin/com/freeletics/coredux/log/common/LoggerLogSink.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux.log.common 2 | 3 | import com.freeletics.coredux.LogEntry 4 | import com.freeletics.coredux.LogEvent 5 | import com.freeletics.coredux.LogSink 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.ObsoleteCoroutinesApi 8 | import kotlinx.coroutines.channels.SendChannel 9 | import kotlinx.coroutines.channels.actor 10 | 11 | /** 12 | * Provides base [LogSink] implementation, that can be used to add specific logger library implementation. 13 | * 14 | * @param scope to receive incoming log messages 15 | */ 16 | abstract class LoggerLogSink( 17 | scope: CoroutineScope 18 | ) : LogSink { 19 | @UseExperimental(ObsoleteCoroutinesApi::class) 20 | override val sink: SendChannel = scope.actor { 21 | val startTimes = mutableMapOf() 22 | for (logEntry in channel) { 23 | if (logEntry.event is LogEvent.StoreCreated) startTimes[logEntry.storeName] = logEntry.time 24 | val timeDiff = logEntry.time - (startTimes[logEntry.storeName] 25 | ?: throw IllegalStateException("No start event received for ${logEntry.storeName}")) 26 | 27 | log(logEntry.storeName, timeDiff, logEntry.event) 28 | 29 | if (logEntry.event is LogEvent.StoreFinished) startTimes.remove(logEntry.storeName) 30 | } 31 | } 32 | 33 | private fun log( 34 | storeName: String, 35 | timeDiff: Long, 36 | event: LogEvent 37 | ) = when (event) { 38 | is LogEvent.StoreCreated -> storeName.logWithTimeDiff(timeDiff, "Created", logger = ::info) 39 | is LogEvent.StoreFinished -> storeName.logWithTimeDiff(timeDiff, "Finished", logger = ::info) 40 | is LogEvent.ReducerEvent.Start -> storeName.logWithTimeDiff(timeDiff, "[Reducer] Started", logger = ::debug) 41 | is LogEvent.ReducerEvent.DispatchState -> storeName.logWithTimeDiff( 42 | timeDiff, 43 | "[Reducer] Dispatching state: ${event.state}", 44 | logger = ::debug 45 | ) 46 | is LogEvent.ReducerEvent.InputAction -> storeName.logWithTimeDiff( 47 | timeDiff, 48 | "[Reducer] Input action: ${event.action}, current state: ${event.state}", 49 | logger = ::debug 50 | ) 51 | is LogEvent.ReducerEvent.Exception -> storeName.logWithTimeDiff( 52 | timeDiff, 53 | "[Reducer] Exception: ${event.reason}", 54 | event.reason, 55 | ::warning 56 | ) 57 | is LogEvent.ReducerEvent.DispatchToSideEffects -> storeName.logWithTimeDiff( 58 | timeDiff, 59 | "[Reducer] Dispatching to side effects: ${event.action}", 60 | logger = ::debug 61 | ) 62 | is LogEvent.SideEffectEvent.Start -> storeName.logWithTimeDiff( 63 | timeDiff, 64 | "[(SE) ${event.name}] Started", 65 | logger = ::debug 66 | ) 67 | is LogEvent.SideEffectEvent.InputAction -> storeName.logWithTimeDiff( 68 | timeDiff, 69 | "[(SE) ${event.name}] Input action: ${event.action}", 70 | logger = ::debug 71 | ) 72 | is LogEvent.SideEffectEvent.DispatchingToReducer -> storeName.logWithTimeDiff( 73 | timeDiff, 74 | "[(SE) ${event.name}] Dispatching action to reducer: ${event.action}", 75 | logger = ::debug 76 | ) 77 | is LogEvent.SideEffectEvent.Custom -> storeName.logWithTimeDiff( 78 | timeDiff, 79 | "[(SE) ${event.name}] ${event.event}", 80 | logger = ::debug 81 | ) 82 | } 83 | 84 | private inline fun String.logWithTimeDiff( 85 | timeDiff: Long, 86 | log: String, 87 | throwable: Throwable? = null, 88 | logger: (tag: String, message: String, throwable: Throwable?) -> Any 89 | ) { 90 | logger(this.replace(' ', '_'), "+${timeDiff}ms: $log", throwable) 91 | } 92 | 93 | /** 94 | * Print debug level log message. 95 | * 96 | * @param tag message unique tag that is created from store name 97 | * @param message log message 98 | * @param throwable optional throwable 99 | */ 100 | abstract fun debug(tag: String, message: String, throwable: Throwable?) 101 | 102 | /** 103 | * Print info level log message. 104 | * 105 | * @param tag message unique tag that is created from store name 106 | * @param message log message 107 | * @param throwable optional throwable 108 | */ 109 | abstract fun info(tag: String, message: String, throwable: Throwable?) 110 | 111 | /** 112 | * Print warning level log message. 113 | * 114 | * @param tag message unique tag that is created from store name 115 | * @param message log message 116 | * @param throwable optional throwable 117 | */ 118 | abstract fun warning(tag: String, message: String, throwable: Throwable?) 119 | } 120 | -------------------------------------------------------------------------------- /dependencies.gradle: -------------------------------------------------------------------------------- 1 | ext.versions = [ 2 | agp : '3.5.2', 3 | compileSdk : 28, 4 | targetSdk : 28, 5 | minSdk : 21, 6 | 7 | gradle : "5.4.1", 8 | 9 | shot : '3.0.2', 10 | mavenPublish : '0.8.0', 11 | dokka : '0.9.17', 12 | 13 | dagger : '2.24', 14 | kotlin : '1.3.60', 15 | coroutines : '1.3.2', 16 | rxJava : '2.2.14', 17 | rxBinding : '2.2.0', 18 | okhttp : '4.2.2', 19 | 20 | retrofit : '2.6.2', 21 | retrofitCoroutines : '0.9.2', 22 | moshi : '1.8.0', 23 | viewModel : '1.1.1', 24 | timber : '4.7.1', 25 | 26 | support : '28.0.0', 27 | supportConstraintLayout: '1.1.3', 28 | 29 | junit : '4.12', 30 | junit5 : '1.5.2', 31 | spek2 : '2.0.8', 32 | kotlinMockito : '1.5.0', 33 | assertJ : '3.8.0', 34 | espresso : '3.0.2', 35 | testRunner : '1.0.2', 36 | screengrab : '1.2.0', 37 | deviceAnimationRule : '0.0.2', 38 | ] 39 | 40 | ext.gradlePlugins = [ 41 | androidGradle : "com.android.tools.build:gradle:$versions.agp", 42 | mavenPublish : "com.vanniktech:gradle-maven-publish-plugin:$versions.mavenPublish", 43 | dokka : "org.jetbrains.dokka:dokka-gradle-plugin:$versions.dokka", 44 | shot : "com.karumi:shot:$versions.shot", 45 | kotlin : "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin", 46 | ] 47 | 48 | ext.libraries = [ 49 | dagger : "com.google.dagger:dagger:$versions.dagger", 50 | daggerCompiler : "com.google.dagger:dagger-compiler:$versions.dagger", 51 | kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin", 52 | kotlinReflect : "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin", 53 | coroutinesCore : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines", 54 | coroutinesAndroid : "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines", 55 | moshi : "com.squareup.moshi:moshi:$versions.moshi", 56 | moshiKotlin : "com.squareup.moshi:moshi-kotlin:$versions.moshi", 57 | moshiCodeGen : "com.squareup.moshi:moshi-kotlin-codegen:$versions.moshi", 58 | timber : "com.jakewharton.timber:timber:$versions.timber", 59 | 60 | rxJava : "io.reactivex.rxjava2:rxjava:$versions.rxJava", 61 | rxBinding : "com.jakewharton.rxbinding2:rxbinding-kotlin:$versions.rxBinding", 62 | retrofit : "com.squareup.retrofit2:retrofit:$versions.retrofit", 63 | retrofitMoshi : "com.squareup.retrofit2:converter-moshi:$versions.retrofit", 64 | retrofitCoroutines : "com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:$versions.retrofitCoroutines", 65 | okhttp : "com.squareup.okhttp3:okhttp:$versions.okhttp" 66 | ] 67 | 68 | ext.supportLibraries = [ 69 | annotations : "com.android.support:support-annotations:$versions.support", 70 | appCompat : "com.android.support:appcompat-v7:$versions.support", 71 | recyclerView : "com.android.support:recyclerview-v7:$versions.support", 72 | constraintLayout: "com.android.support.constraint:constraint-layout:$versions.supportConstraintLayout", 73 | design : "com.android.support:design:$versions.support", 74 | viewModel : "android.arch.lifecycle:extensions:$versions.viewModel" 75 | ] 76 | 77 | ext.testLibraries = [ 78 | junit : "junit:junit:$versions.junit", 79 | junit5Engine : "org.junit.platform:junit-platform-engine:$versions.junit5", 80 | spek2Dsl : "org.spekframework.spek2:spek-dsl-jvm:$versions.spek2", 81 | spek2Junit5Runner : "org.spekframework.spek2:spek-runner-junit5:$versions.spek2", 82 | mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp", 83 | okhttpTls : "com.squareup.okhttp3:okhttp-tls:$versions.okhttp", 84 | archCoreTest : "android.arch.core:core-testing:$versions.viewModel", 85 | testRunner : "com.android.support.test:runner:$versions.testRunner", 86 | espressoCore : "com.android.support.test.espresso:espresso-core:$versions.espresso", 87 | espressoContrib : "com.android.support.test.espresso:espresso-contrib:$versions.espresso", 88 | testRules : "com.android.support.test:rules:$versions.testRunner", 89 | screengrab : "tools.fastlane:screengrab:$versions.screengrab", 90 | deviceAnimationRule : "com.github.VictorAlbertos:DeviceAnimationTestRule:$versions.deviceAnimationRule", 91 | coroutinesTest : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$versions.coroutines" 92 | ] 93 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /library/core/src/main/kotlin/com/freeletics/coredux/Logging.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.GlobalScope 7 | import kotlinx.coroutines.ObsoleteCoroutinesApi 8 | import kotlinx.coroutines.channels.Channel 9 | import kotlinx.coroutines.channels.SendChannel 10 | import kotlinx.coroutines.launch 11 | 12 | /** 13 | * Provides a logging interface to [SideEffect] implementations. 14 | * 15 | * At least, every [SideEffect] implementation should add logging for [LogEvent.SideEffectEvent.Start], 16 | * [LogEvent.SideEffectEvent.InputAction] and [LogEvent.SideEffectEvent.DispatchingToReducer] events. 17 | */ 18 | interface SideEffectLogger { 19 | /** 20 | * Log [LogEvent.SideEffectEvent]. 21 | */ 22 | fun logSideEffectEvent(event: () -> LogEvent.SideEffectEvent) 23 | } 24 | 25 | /** 26 | * Receiver of log events from store instance. 27 | * 28 | * Receiving should be fast. Sender has __small__ internal buffer 29 | * and on buffer overflow new [LogEntry]s will be dropped 30 | * until [LogEntry]s from buffer will be consumed by all [LogSink] instances. 31 | * 32 | * May be shared between different store instances. 33 | */ 34 | interface LogSink { 35 | /** 36 | * [SendChannel] sink to receive log events. 37 | */ 38 | val sink: SendChannel 39 | } 40 | 41 | /** 42 | * Single log entity. 43 | * 44 | * @param storeName "unique" store name that was passed as `name` param to [createStore] 45 | * @param time [unixtime](https://en.wikipedia.org/wiki/Unix_time) in milliseconds when log event happened 46 | * @param event actual event 47 | */ 48 | data class LogEntry( 49 | val storeName: String, 50 | val time: Long, 51 | val event: LogEvent 52 | ) 53 | 54 | /** 55 | * Hierarchy of all possible log events. 56 | */ 57 | sealed class LogEvent { 58 | /** 59 | * Store instance was created. 60 | */ 61 | object StoreCreated : LogEvent() 62 | 63 | /** 64 | * Store instance was finished (cancelled). 65 | */ 66 | object StoreFinished : LogEvent() 67 | 68 | /** 69 | * All events related to reducer coroutine. 70 | */ 71 | sealed class ReducerEvent : LogEvent() { 72 | /** 73 | * Reducer coroutine starts. 74 | */ 75 | object Start : ReducerEvent() 76 | 77 | /** 78 | * Dispatching new state to [Store] [StateReceiver]s. 79 | */ 80 | data class DispatchState(val state: Any) : ReducerEvent() 81 | 82 | /** 83 | * Receiving new input action from [Store]. 84 | */ 85 | data class InputAction( 86 | val action: Any, 87 | val state: Any 88 | ) : ReducerEvent() 89 | 90 | /** 91 | * Exception in [Reducer] function. 92 | */ 93 | data class Exception(val reason: Throwable) : ReducerEvent() 94 | 95 | /** 96 | * Dispatching [action] to all side effects. 97 | */ 98 | data class DispatchToSideEffects(val action: Any) : ReducerEvent() 99 | } 100 | 101 | /** 102 | * All events related to side effects coroutines. 103 | */ 104 | sealed class SideEffectEvent : LogEvent() { 105 | abstract val name: String 106 | 107 | /** 108 | * Started side effect coroutine. 109 | */ 110 | data class Start( 111 | override val name: String 112 | ) : SideEffectEvent() 113 | 114 | /** 115 | * Received new input [action] from reducer coroutine. 116 | */ 117 | data class InputAction( 118 | override val name: String, 119 | val action: Any 120 | ) : SideEffectEvent() 121 | 122 | /** 123 | * Dispatching new [action] to reducer. 124 | */ 125 | data class DispatchingToReducer( 126 | override val name: String, 127 | val action: Any 128 | ) : SideEffectEvent() 129 | 130 | /** 131 | * Custom log event. 132 | * 133 | * May be used by [SideEffect] implementation to provide custom log events 134 | * with [events] payload. 135 | */ 136 | class Custom( 137 | override val name: String, 138 | val event: Any 139 | ) : SideEffectEvent() 140 | } 141 | } 142 | 143 | @UseExperimental(ExperimentalCoroutinesApi::class, ObsoleteCoroutinesApi::class) 144 | internal class Logger( 145 | private val storeName: String, 146 | scope: CoroutineScope, 147 | private val logSinks: List>, 148 | toLogSinksDispatcher: CoroutineDispatcher 149 | ) : SideEffectLogger { 150 | private val inputLogEventsChannel = Channel(40) 151 | private val loggingEnabled = logSinks.isNotEmpty() 152 | 153 | init { 154 | if (loggingEnabled) { 155 | scope.launch(context = toLogSinksDispatcher) { 156 | for (event in inputLogEventsChannel) { 157 | logSinks.forEach { it.send(event) } 158 | } 159 | } 160 | } 161 | } 162 | 163 | internal fun logEvent(event: () -> LogEvent) { 164 | if (loggingEnabled) { 165 | inputLogEventsChannel.offer(LogEntry( 166 | storeName, 167 | System.currentTimeMillis(), 168 | event() 169 | )) 170 | } 171 | } 172 | 173 | internal fun logAfterCancel(event: () -> LogEvent) { 174 | if (loggingEnabled) { 175 | // scope is cancelled - using GlobalScope to deliver event 176 | GlobalScope.launch { 177 | val logEntry = LogEntry( 178 | storeName, 179 | System.currentTimeMillis(), 180 | event() 181 | ) 182 | logSinks.forEach { 183 | it.send(logEntry) 184 | } 185 | } 186 | } 187 | } 188 | 189 | override fun logSideEffectEvent(event: () -> LogEvent.SideEffectEvent) { 190 | if (loggingEnabled) { 191 | inputLogEventsChannel.offer( 192 | LogEntry( 193 | storeName, 194 | System.currentTimeMillis(), 195 | event() 196 | ) 197 | ) 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /library/core/src/test/kotlin/com/freeletics/coredux/SimpleStoreTest.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.ObsoleteCoroutinesApi 7 | import kotlinx.coroutines.cancel 8 | import kotlinx.coroutines.delay 9 | import kotlinx.coroutines.isActive 10 | import kotlinx.coroutines.runBlocking 11 | import kotlinx.coroutines.test.TestCoroutineScope 12 | import kotlinx.coroutines.test.runBlockingTest 13 | import org.junit.Assert.assertEquals 14 | import org.junit.Assert.assertFalse 15 | import org.junit.Assert.fail 16 | import org.spekframework.spek2.Spek 17 | import org.spekframework.spek2.style.specification.describe 18 | 19 | @ObsoleteCoroutinesApi 20 | @UseExperimental(ExperimentalCoroutinesApi::class) 21 | object SimpleStoreTest : Spek({ 22 | describe("A redux store without any side effects") { 23 | val testScope by memoized { TestCoroutineScope(Job()) } 24 | val stateReceiver by memoized { TestStateReceiver() } 25 | val testLogger by memoized { TestLogger(testScope) } 26 | val store by memoized { 27 | testScope.createStoreInternal( 28 | name = "SimpleStore", 29 | initialState = "", 30 | logSinks = listOf(testLogger), 31 | logsDispatcher = Dispatchers.Unconfined 32 | ) { currentState, newAction -> 33 | when { 34 | newAction >= 0 -> newAction.toString() 35 | else -> currentState 36 | } 37 | } 38 | } 39 | 40 | afterEachTest { 41 | testScope.cancel() 42 | testScope.cleanupTestCoroutines() 43 | } 44 | 45 | context("that has been subscribed immediately") { 46 | beforeEachTest { 47 | store.subscribe(stateReceiver) 48 | testScope.advanceUntilIdle() 49 | } 50 | 51 | afterEachTest { 52 | store.unsubscribe(stateReceiver) 53 | } 54 | 55 | it("should emit initial state") { 56 | stateReceiver.assertStates("") 57 | } 58 | 59 | it("should emit three log events") { 60 | testLogger.assertLogEvents( 61 | LogEvent.StoreCreated, 62 | LogEvent.ReducerEvent.Start, 63 | LogEvent.ReducerEvent.DispatchState("") 64 | ) 65 | } 66 | 67 | context("On new action 1") { 68 | beforeEachTest { 69 | store.dispatch(1) 70 | } 71 | 72 | it("should first emit input action log event") { 73 | assertEquals( 74 | LogEvent.ReducerEvent.InputAction(1, ""), 75 | testLogger.logEvents[3].event 76 | ) 77 | } 78 | 79 | it("should emit \"1\" state") { 80 | assertEquals("1", stateReceiver.stateUpdates.last()) 81 | } 82 | 83 | it("should last emit dispatch state log event") { 84 | assertEquals( 85 | LogEvent.ReducerEvent.DispatchState("1"), 86 | testLogger.logEvents[4].event 87 | ) 88 | } 89 | } 90 | 91 | context("when scope is cancelled") { 92 | beforeEachTest { 93 | testScope.cancel() 94 | // Cancel is wait scope child jobs to cancel itself, so adding small delay to remove test flakiness 95 | runBlocking { delay(10) } 96 | } 97 | 98 | it("should not accept new actions") { 99 | assertFalse(testScope.isActive) 100 | try { 101 | store.dispatch(2) 102 | fail("No exception was thrown") 103 | } catch (e: IllegalStateException) {} 104 | } 105 | } 106 | 107 | context("on actions from 0 to 100 send immediately") { 108 | val actions = (0..50) 109 | beforeEachTest { actions.forEach { store.dispatch(it) } } 110 | 111 | it("store should process them in send order") { 112 | stateReceiver.assertStates("", *actions.map { it.toString() }.toTypedArray()) 113 | } 114 | } 115 | } 116 | 117 | context("that is not subscribed") { 118 | context("on new action 1") { 119 | beforeEachTest { 120 | store.dispatch(1) 121 | } 122 | 123 | it("should emit store create as a first log event") { 124 | testLogger.assertLogEvents(LogEvent.StoreCreated) 125 | } 126 | 127 | context("and when new subscriber subscribes") { 128 | beforeEachTest { store.subscribe(stateReceiver) } 129 | 130 | it("subscriber should receive initial and 1 state") { 131 | stateReceiver.assertStates("", "1") 132 | } 133 | 134 | val secondStateReceiver by memoized { TestStateReceiver() } 135 | 136 | context("and when second subscriber subscribes after 1 ms") { 137 | beforeEachTest { 138 | testScope.runBlockingTest { 139 | delay(10) 140 | store.subscribe(secondStateReceiver) 141 | } 142 | } 143 | 144 | it("second state receiver should receive no states") { 145 | assertEquals(0, secondStateReceiver.stateUpdates.size) 146 | } 147 | 148 | context("On new action 2") { 149 | beforeEachTest { 150 | store.dispatch(2) 151 | } 152 | 153 | it("both subscribers should receive \"2\" state") { 154 | assertEquals("2", stateReceiver.stateUpdates.last()) 155 | assertEquals("2", secondStateReceiver.stateUpdates.last()) 156 | } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | } 163 | }) 164 | -------------------------------------------------------------------------------- /library/core/src/test/kotlin/com/freeletics/coredux/StoreWithSideEffectsTest.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.ObsoleteCoroutinesApi 7 | import kotlinx.coroutines.cancel 8 | import kotlinx.coroutines.test.TestCoroutineScope 9 | import org.junit.Assert.assertEquals 10 | import org.junit.Assert.assertTrue 11 | import org.spekframework.spek2.Spek 12 | import org.spekframework.spek2.style.specification.describe 13 | 14 | @ObsoleteCoroutinesApi 15 | @ExperimentalCoroutinesApi 16 | internal object StoreWithSideEffectsTest : Spek({ 17 | describe("A redux store with only ${::stateLengthSE.name} side effect") { 18 | val loadDelay = 100L 19 | val stateReceiver by memoized { TestStateReceiver() } 20 | val testScope by memoized( 21 | factory = { TestCoroutineScope(Job()) }, 22 | destructor = { 23 | it.cancel() 24 | it.cleanupTestCoroutines() 25 | } 26 | ) 27 | val store by memoized { 28 | testScope.createStore( 29 | name = "Store with one side effect", 30 | initialState = "", 31 | sideEffects = listOf(stateLengthSE(loadDelay = loadDelay, lengthLimit = 2)) 32 | ) { currentState, newAction -> 33 | currentState + newAction 34 | } 35 | } 36 | 37 | beforeEachTest { store.subscribe(stateReceiver) } 38 | 39 | it("should emit initial state") { 40 | stateReceiver.assertStates("") 41 | } 42 | 43 | context("on 1 action") { 44 | beforeEachTest { store.dispatch(1) } 45 | 46 | it("reducer should react first with \"1\" state") { 47 | stateReceiver.assertStates( 48 | "", 49 | "1" 50 | ) 51 | } 52 | 53 | context("and if $loadDelay ms passed") { 54 | beforeEachTest { testScope.advanceTimeBy(loadDelay) } 55 | 56 | it("should emit \"11\" as a third state") { 57 | stateReceiver.assertStates( 58 | "", 59 | "1", 60 | "11" 61 | ) 62 | } 63 | } 64 | 65 | context("then immediately on 5 action and after $loadDelay ms passed") { 66 | beforeEachTest { 67 | store.dispatch(5) 68 | testScope.advanceTimeBy(loadDelay) 69 | } 70 | 71 | it("should emit only 4 states") { 72 | assertEquals(4, stateReceiver.stateUpdates.size) 73 | } 74 | 75 | it("should emit states in order") { 76 | stateReceiver.assertStates( 77 | "", 78 | "1", 79 | "15", 80 | "152" 81 | ) 82 | } 83 | } 84 | } 85 | } 86 | 87 | describe("A redux store with many side effects") { 88 | val updateDelay = 100L 89 | val stateReceiver by memoized { TestStateReceiver() } 90 | val testScope by memoized( 91 | factory = { TestCoroutineScope(Job()) }, 92 | destructor = { 93 | it.cancel() 94 | it.cleanupTestCoroutines() 95 | } 96 | ) 97 | val loggerSE by memoized { LoggerSE() } 98 | val stateLengthSE by memoized { stateLengthSE(lengthLimit = 4, loadDelay = 0L) } 99 | val multiplyActionSE by memoized { multiplyActionSE(updateDelay) } 100 | val logger by memoized { TestLogger(testScope) } 101 | val store by memoized { 102 | testScope.createStoreInternal( 103 | name = "Store with many side effects", 104 | initialState = "", 105 | logSinks = listOf(logger), 106 | logsDispatcher = Dispatchers.Unconfined, 107 | sideEffects = listOf( 108 | stateLengthSE, 109 | loggerSE, 110 | multiplyActionSE 111 | ) 112 | ) { currentState, newAction -> 113 | currentState + newAction 114 | } 115 | } 116 | 117 | beforeEachTest { 118 | store.subscribe(stateReceiver) 119 | } 120 | 121 | context("On 1 action") { 122 | beforeEachTest { 123 | store.dispatch(1) 124 | } 125 | 126 | val expectedStates = listOf( 127 | "", 128 | "1", 129 | "11", 130 | "112", 131 | "1123", 132 | "11234" 133 | ) 134 | 135 | it("Should emit states $expectedStates in order") { 136 | stateReceiver.assertStates(*expectedStates.toTypedArray()) 137 | } 138 | 139 | it("Should not receive more then ${expectedStates.size + 1} states") { 140 | assertTrue(stateReceiver.stateUpdates.size <= expectedStates.size) 141 | } 142 | 143 | it("${LoggerSE::class.simpleName} should receive all action in order") { 144 | assertEquals( 145 | listOf(1, 1, 2, 3, 4), 146 | loggerSE.receivedActions 147 | ) 148 | } 149 | 150 | val expectedLogEvents = listOf( 151 | LogEvent.StoreCreated, 152 | LogEvent.ReducerEvent.Start, 153 | LogEvent.ReducerEvent.DispatchState(""), 154 | LogEvent.SideEffectEvent.Start(STATE_LENGTH_SE_NAME), 155 | LogEvent.SideEffectEvent.Start(LOGGER_SE_NAME), 156 | LogEvent.SideEffectEvent.Start(MULTIPLY_ACTION_SE_NAME), 157 | LogEvent.ReducerEvent.InputAction(1, ""), 158 | LogEvent.ReducerEvent.DispatchState("1"), 159 | LogEvent.ReducerEvent.DispatchToSideEffects(1) 160 | ) 161 | 162 | it("Should emit log events $expectedLogEvents") { 163 | logger.assertLogEvents(*expectedLogEvents.toTypedArray()) 164 | } 165 | 166 | context("And after ${updateDelay + 1} delay on second 100 action") { 167 | beforeEachTest { 168 | store.dispatch(100) 169 | testScope.advanceTimeBy(updateDelay + 1) 170 | } 171 | 172 | val expectedStatesAfterDelay = listOf( 173 | "", 174 | "1", 175 | "11", 176 | "112", 177 | "1123", 178 | "11234", 179 | "11234100", 180 | "112341002000" 181 | ) 182 | 183 | it("Should not emit more then ${expectedStatesAfterDelay.size + 1} states") { 184 | assertTrue(stateReceiver.stateUpdates.size <= expectedStatesAfterDelay.size) 185 | } 186 | 187 | it("Should emit $expectedStatesAfterDelay states in order") { 188 | stateReceiver.assertStates(*expectedStatesAfterDelay.toTypedArray()) 189 | } 190 | 191 | } 192 | } 193 | } 194 | }) 195 | -------------------------------------------------------------------------------- /sample/src/testSpec/java/com/freeletics/coredux/PopularRepositoriesSpec.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import com.freeletics.coredux.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(connectionErrorMessage) 162 | } 163 | } 164 | 165 | given("device is online (was offline before)") { 166 | 167 | server.enqueue200(FIRST_PAGE) 168 | server.start(MOCK_WEB_SERVER_PORT) 169 | 170 | Thread.sleep(5000) 171 | 172 | on("user clicks retry loading first page") { 173 | 174 | screen.retryLoadingFirstPage() 175 | 176 | "shows loading" byRendering PaginationStateMachine.State.LoadingFirstPageState 177 | 178 | "shows first page" byRendering PaginationStateMachine.State.ShowContentState( 179 | items = FIRST_PAGE, 180 | page = 1 181 | ) 182 | } 183 | 184 | server.enqueue200(SECOND_PAGE) 185 | 186 | on("scrolling to the end of the first page") { 187 | 188 | screen.scrollToEndOfList() 189 | 190 | "shows loading next page" byRendering 191 | PaginationStateMachine.State.ShowContentAndLoadNextPageState( 192 | items = FIRST_PAGE, 193 | page = 1 194 | ) 195 | 196 | "shows next page content" byRendering 197 | PaginationStateMachine.State.ShowContentState( 198 | items = FIRST_PAGE + SECOND_PAGE, 199 | page = 2 200 | ) 201 | } 202 | 203 | } 204 | 205 | given("device is offline again (was online before)") { 206 | 207 | server.shutdown() 208 | 209 | on("scrolling to end of second page") { 210 | 211 | screen.scrollToEndOfList() 212 | 213 | "shows loading next page" byRendering 214 | PaginationStateMachine.State.ShowContentAndLoadNextPageState( 215 | items = FIRST_PAGE + SECOND_PAGE, 216 | page = 2 217 | ) 218 | 219 | "shows error info for few seconds on top of the list of items" byRendering 220 | PaginationStateMachine.State.ShowContentAndLoadNextPageErrorState( 221 | items = FIRST_PAGE + SECOND_PAGE, 222 | page = 2, 223 | errorMessage = connectionErrorMessage 224 | ) 225 | 226 | "hides error information and shows items only" byRendering 227 | PaginationStateMachine.State.ShowContentState( 228 | items = FIRST_PAGE + SECOND_PAGE, 229 | page = 2 230 | 231 | ) 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /library/core/src/main/kotlin/com/freeletics/coredux/ReduxStore.kt: -------------------------------------------------------------------------------- 1 | package com.freeletics.coredux 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.CoroutineName 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.CoroutineStart 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.cancel 11 | import kotlinx.coroutines.channels.BroadcastChannel 12 | import kotlinx.coroutines.channels.Channel 13 | import kotlinx.coroutines.channels.SendChannel 14 | import kotlinx.coroutines.isActive 15 | import kotlinx.coroutines.launch 16 | import java.util.concurrent.locks.ReentrantReadWriteLock 17 | import kotlin.concurrent.read 18 | import kotlin.concurrent.write 19 | 20 | /** 21 | * A [createStore] is a Kotlin coroutine based implementation of Redux and redux.js.org. 22 | * 23 | * @param name preferably unique name associated with this [Store] instance. 24 | * It will be used as a `name` param for [LogEntry] to distinguish log events from different store 25 | * instances. 26 | * @param initialState The initial state. This one will be emitted directly in onSubscribe() 27 | * @param sideEffects The sideEffects. See [SideEffect]. 28 | * @param launchMode store launch mode. Default is [CoroutineStart.LAZY] - when first [StateReceiver] 29 | * will added to [Store], it will start processing input actions. 30 | * @param logSinks list of [LogSink] implementations, that will receive log events. 31 | * To disable logging, use [emptyList] (default). 32 | * @param reducer The reducer. See [Reducer]. 33 | * @param S The type of the State 34 | * @param A The type of the Actions 35 | * 36 | * @return instance of [Store] object 37 | */ 38 | @UseExperimental(ExperimentalCoroutinesApi::class) 39 | fun CoroutineScope.createStore( 40 | name: String, 41 | initialState: S, 42 | sideEffects: List> = emptyList(), 43 | launchMode: CoroutineStart = CoroutineStart.LAZY, 44 | logSinks: List = emptyList(), 45 | reducer: Reducer 46 | ): Store = createStoreInternal( 47 | name, 48 | initialState, 49 | sideEffects, 50 | launchMode, 51 | logSinks, 52 | Dispatchers.Default, 53 | reducer 54 | ) 55 | 56 | /** 57 | * Allows to override any store configuration. 58 | */ 59 | @UseExperimental(ExperimentalCoroutinesApi::class) 60 | internal fun CoroutineScope.createStoreInternal( 61 | name: String, 62 | initialState: S, 63 | sideEffects: List> = emptyList(), 64 | launchMode: CoroutineStart = CoroutineStart.LAZY, 65 | logSinks: List = emptyList(), 66 | logsDispatcher: CoroutineDispatcher = Dispatchers.Default, 67 | reducer: Reducer 68 | ): Store { 69 | val logger = Logger(name, this, logSinks.map { it.sink }, logsDispatcher) 70 | val actionsReducerChannel = Channel(Channel.UNLIMITED) 71 | val actionsSideEffectsChannel = BroadcastChannel(sideEffects.size + 1) 72 | 73 | coroutineContext[Job]?.invokeOnCompletion { 74 | logger.logAfterCancel { LogEvent.StoreFinished } 75 | actionsReducerChannel.close(it) 76 | actionsSideEffectsChannel.close(it) 77 | } 78 | 79 | logger.logEvent { LogEvent.StoreCreated } 80 | return CoreduxStore(actionsReducerChannel) { stateDispatcher -> 81 | // Creating reducer coroutine 82 | launch( 83 | start = launchMode, 84 | context = CoroutineName("$name reducer") 85 | ) { 86 | logger.logEvent { LogEvent.ReducerEvent.Start } 87 | var currentState = initialState 88 | 89 | // Sending initial state 90 | logger.logEvent { LogEvent.ReducerEvent.DispatchState(currentState) } 91 | stateDispatcher(currentState) 92 | 93 | // Starting side-effects coroutines 94 | sideEffects.forEach { sideEffect -> 95 | logger.logEvent { LogEvent.SideEffectEvent.Start(sideEffect.name) } 96 | with (sideEffect) { 97 | start( 98 | actionsSideEffectsChannel.openSubscription(), 99 | { currentState }, 100 | actionsReducerChannel, 101 | logger 102 | ) 103 | } 104 | } 105 | 106 | try { 107 | for (action in actionsReducerChannel) { 108 | logger.logEvent { LogEvent.ReducerEvent.InputAction(action, currentState) } 109 | currentState = try { 110 | reducer(currentState, action) 111 | } catch (e: Throwable) { 112 | logger.logEvent { LogEvent.ReducerEvent.Exception(e) } 113 | throw ReducerException(currentState, action, e) 114 | } 115 | logger.logEvent { LogEvent.ReducerEvent.DispatchState(currentState) } 116 | stateDispatcher(currentState) 117 | 118 | logger.logEvent { LogEvent.ReducerEvent.DispatchToSideEffects(action) } 119 | actionsSideEffectsChannel.send(action) 120 | } 121 | } finally { 122 | if (isActive) cancel() 123 | } 124 | } 125 | } 126 | } 127 | 128 | /** 129 | * Provides methods to interact with [createStore] instance. 130 | */ 131 | interface Store { 132 | /** 133 | * Dispatches new actions to given [createStore] instance. 134 | * 135 | * It is safe to call this method from different threads, 136 | * action will consumed on [createStore] [CoroutineScope] context. 137 | * 138 | * If `launchMode` for [createStore] is [CoroutineStart.LAZY] dispatched actions will be collected and passed 139 | * to reducer on first [subscribe] call. 140 | */ 141 | fun dispatch(action: A) 142 | 143 | /** 144 | * Add new [StateReceiver] to the store. 145 | * 146 | * It is ok to call this method multiple times - each call will add a new [StateReceiver]. 147 | */ 148 | fun subscribe(subscriber: StateReceiver) 149 | 150 | /** 151 | * Remove previously added via [subscriber] [StateReceiver]. 152 | */ 153 | fun unsubscribe(subscriber: StateReceiver) 154 | } 155 | 156 | private class CoreduxStore( 157 | private val actionsDispatchChannel: SendChannel, 158 | reducerCoroutineBuilder: ((S) -> Unit) -> Job 159 | ) : Store { 160 | private var stateReceiversList = emptyList>() 161 | 162 | private val lock = ReentrantReadWriteLock() 163 | 164 | private val reducerCoroutine = reducerCoroutineBuilder { newState -> 165 | lock.read { 166 | stateReceiversList.forEach { 167 | it(newState) 168 | } 169 | } 170 | }.also { 171 | it.invokeOnCompletion { 172 | lock.write { 173 | stateReceiversList = emptyList() 174 | } 175 | } 176 | } 177 | 178 | @UseExperimental(ExperimentalCoroutinesApi::class) 179 | override fun dispatch(action: A) { 180 | if (actionsDispatchChannel.isClosedForSend) throw IllegalStateException("CoroutineScope is cancelled") 181 | 182 | if (!actionsDispatchChannel.offer(action)) { 183 | throw IllegalStateException("Input actions overflow - buffer is full") 184 | } 185 | } 186 | 187 | 188 | override fun subscribe(subscriber: StateReceiver) { 189 | if (reducerCoroutine.isCompleted) throw IllegalStateException("CoroutineScope is cancelled") 190 | 191 | lock.write { 192 | val receiversIsEmpty = stateReceiversList.isEmpty() 193 | stateReceiversList += subscriber 194 | if (receiversIsEmpty && 195 | !reducerCoroutine.isActive) { 196 | reducerCoroutine.start() 197 | } 198 | } 199 | } 200 | 201 | override fun unsubscribe(subscriber: StateReceiver) { 202 | lock.write { 203 | stateReceiversList -= subscriber 204 | } 205 | } 206 | } 207 | 208 | /** 209 | * A simple type alias for a reducer function. 210 | * A Reducer takes a State and an Action as input and produces a state as output. 211 | * 212 | * **Note**: Implementations of [Reducer] must be fast and _lock-free_. 213 | * 214 | * @param S The type of the state 215 | * @param A The type of the Actions 216 | */ 217 | typealias Reducer = (currentState: S, newAction: A) -> S 218 | 219 | /** 220 | * Wraps [Reducer] call exception. 221 | */ 222 | class ReducerException( 223 | state: Any, 224 | action: Any, 225 | cause: Throwable 226 | ) : RuntimeException("Exception was thrown by reducer, state = '$state', action = '$action'", cause) 227 | 228 | /** 229 | * Type alias for a updated state receiver function. 230 | * 231 | * State update will always be received on [createStore] [CoroutineScope] thread. 232 | */ 233 | typealias StateReceiver = (newState: S) -> Unit 234 | -------------------------------------------------------------------------------- /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 `.createStore()`: 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 | -------------------------------------------------------------------------------- /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 | This library was an experiment from the early days of coroutines when `Flow` did not exist. Take a look at [FlowRedux](https://github.com/freeletics/flowredux) for our maintained coroutines based redux implementation. 4 | 5 | 6 | # CoRedux 7 | 8 | Opinionated [Redux](https://redux.js.org/) implementation using [Kotlin](https://kotlinlang.org/) [coroutines](https://github.com/Kotlin/kotlinx.coroutines) inspired by [RxRedux](https://github.com/freeletics/rxredux). 9 | 10 | ## Table of content 11 | - [Getting started](#getting-started) 12 | - [Gradle](#gradle) 13 | - [Additional artifacts](#additional-artifacts) 14 | - [What is CoRedux](#what-is-coredux) 15 | - [Basic concept](#basic-concept) 16 | - [Side effects](#side-effects) 17 | - [Implementation details](#implementation-details) 18 | - [Logging](#logging) 19 | - [Sample app](#sample-app) 20 | 21 | ## Getting started 22 | 23 | ### Gradle 24 | 25 | All release artifacts are hosted on [Maven Central](https://mvnrepository.com/repos/central): 26 | ```gradle 27 | repositories { 28 | mavenCentral() 29 | } 30 | 31 | implementation "com.freeletics.coredux:core:1.1.1" 32 | ``` 33 | 34 | If you want to use latest snapshot release from `master` branch: 35 | ```gradle 36 | repositories { 37 | // CoRedux snapshot repository 38 | maven { 39 | url "https://oss.sonatype.org/content/repositories/snapshots/" 40 | } 41 | } 42 | 43 | implementation "com.freeletics.coredux:core:1.2.0-SNAPSHOT" 44 | ``` 45 | 46 | ### Additional artifacts 47 | 48 | Following additional artifacts are also available: 49 | - `com.freeletics.coredux:log-common:1.1.1` - provides abstract logger `LogSink` implementation 50 | - `com.freeletics.coredux:log-android:1.1.1` - provides `LogSink` implementation, that utilizes `android.util.Log` class to print log messages 51 | t- `com.freeletics.coredux:log-timber:1.1.1` - provides `LogSink` implementation, that utilizes [Timber](https://github.com/JakeWharton/timber) logger 52 | 53 | ## What is CoRedux 54 | 55 | CoRedux is a predictable state container, that is using same approach as [Redux](https://redux.js.org/) with 56 | ability to have additional side effects. 57 | 58 | Implementation is based on [Kotlin](https://kotlinlang.org/) [coroutines](https://github.com/Kotlin/kotlinx.coroutines) 59 | and inspired by both [RxRedux](https://github.com/freeletics/rxredux) library 60 | and [Coroutines in practice by Roman Elizarov](https://www.youtube.com/watch?v=a3agLJQ6vt8) KotlinConf2018 talk. 61 | 62 | ### Basic concept 63 | 64 | Imagine - we need to develop a calculator app that has on new instance of app start initial value (state) `0`. 65 | Calculator only allows addition, deduction, multiplication and division operations. 66 | Operations describe what should happen with the calculator current state and can be represented as following input actions: 67 | ```kotlin 68 | sealed class CalculatorAction { 69 | data class Add(val value: Int) : CalculatorAction() 70 | data class Deduct(val value: Int) : CalculatorAction() 71 | data class Multiply(val value: Int) : CalculatorAction() 72 | data class Divide(val value: Int) : CalculatorAction() 73 | } 74 | ``` 75 | 76 | With CoRedux you can create a store that will be a single source of current Calculator state 77 | and will update it on each new incoming `CalculatorAction`: 78 | ```kotlin 79 | val store = coroutineScope.createStore( 80 | name = "Calculator", 81 | initialState = 0, 82 | reducer = { currentState, newAction -> 83 | when (newAction) { 84 | is CalculatorAction.Add -> currentState + newAction.value 85 | is CalculatorAction.Deduct -> currentState - newAction.value 86 | is CalculatorAction.Multiply -> currentState * newAction.value 87 | is CalculatorAction.Divide -> currentState / newAction.value 88 | } 89 | } 90 | ) 91 | ``` 92 | 93 | Where `reducer` is a special function responsible for managing state. A `reducer` specify 94 | how the state changes in response to actions sent to the store. 95 | Remember that actions only describe what happened, but don't describe how the application's state changes. 96 | That is the job of the `reducer`. 97 | 98 | Finally you have to subscribe `StateReceiver` to the `store` instance to get the state updates over time: 99 | ```kotlin 100 | store.subscribe { state -> updateUI(state) } 101 | ``` 102 | 103 | `StateReceiver` is a function that is called whenever the state of the store has been changed. You can think of it as a listener of store's state. 104 | More than one `StateReceiver` can subscribe to the same `store` instance. 105 | 106 | On each new UI _intention_, UI implementation just need to send (_dispatch_) it as an action to `store` instance: 107 | ```kotlin 108 | store.dispatch(CalculatorAction.Add(10)) 109 | // Current state will become 10 110 | store.dispatch(CalculatorAction.Deduct(1)) 111 | // Current state will become 9 112 | store.dispatch(CalculatorAction.Add(1)) 113 | // Current state will become 10 114 | store.dispatch(CalculatorAction.Divide(10)) 115 | // Current state will become 1 116 | ``` 117 | 118 | All actions will be processed in serialized order 119 | and on each incoming action `reducer` function is called to compute current state. 120 | 121 | `createStore` has two start modes: 122 | - with `launchMode = CoroutineStart.LAZY` (default), `Store` will wait for first `StateReceiver` subscription, 123 | emit `initialState` to this `StateReceiver` and start processing incoming actions. 124 | - with `launchMode = CoroutineStart.DEFAULT`, `Store` will start processing incoming actions immediately. When any `StateReceiver` subscribe to such store instance, 125 | it will receive first state update only on next incoming action. 126 | 127 | ### Side effects 128 | 129 | Side effect is an interface with exactly one function - it receives incoming 130 | actions, can get current store state at any time and may emit outgoing actions. 131 | **So basically it is actions in and actions out.** 132 | 133 | **Actions in** to side effects are emitted by store after calling `reducer` function. 134 | 135 | **Actions out** are consumed back again by store and trigger calling `reducer` function. 136 | 137 | Side effects are used to perform additional `Job` as a reaction on a certain action, for example, 138 | making an HTTP request, I/O operations, writing to database and so on. Since they run in a `Job` they can run async. 139 | Each `Job` is scoped to the context of the `store`. 140 | 141 | Let's add an side effect that makes a network request each time when action is `CalculatorAction.Add` 142 | and current state is `9`. If server responds with http code `200` - side effect should emit `CalculatorAction.Deduct(1)`. 143 | ```kotlin 144 | val sideEffect = object : SideEffect { 145 | override val name: String = "network logger" 146 | 147 | override fun CoroutineScope.start( 148 | input: ReceiveChannel, 149 | stateAccessor: StateAccessor, 150 | output: SendChannel, 151 | logger: SideEffectLogger 152 | ): Job = launch(context = CoroutineName(name)) { 153 | for (inputAction in input) { 154 | logger.logSideEffectEvent { LogEvent.SideEffectEvent.InputAction(name, inputAction) } 155 | if (inputAction is CalculatorAction.Add && 156 | stateAccessor() >= 0) { 157 | launch { 158 | val response = makeNetworkCall() 159 | logger.logSideEffectEvent { 160 | LogEvent.SideEffectEvent.Custom(name, "Received network response: $response") 161 | } 162 | if (response == 200) { 163 | val outputAction = CalculatorAction.Deduct(1) 164 | logger.logSideEffectEvent { LogEvent.SideEffectEvent.DispatchingToReducer(name, outputAction) } 165 | output.send(outputAction) 166 | } 167 | } 168 | } 169 | } 170 | } 171 | } 172 | ``` 173 | 174 | You must register your `sideEffect` inside `createStore(sideEffects = listOf(sideEffect))`: 175 | ```kotlin 176 | val storeWithSideEffect = coroutineScope.createStore( 177 | name = "Calculator", 178 | initialState = 0, 179 | sideEffects = listOf(sideEffect), 180 | reducer = { currentState, newAction -> 181 | when (newAction) { 182 | is CalculatorAction.Add -> currentState + newAction.value 183 | is CalculatorAction.Deduct -> currentState - newAction.value 184 | is CalculatorAction.Multiply -> currentState * newAction.value 185 | is CalculatorAction.Divide -> currentState / newAction.value 186 | } 187 | } 188 | ) 189 | ``` 190 | 191 | When we subscribe to `storeWithSideEffect` and trigger side effect by dispatching right actions - 192 | network request from side effect will be performed: 193 | ```kotlin 194 | storeWithSideEffect.subscribe { state -> updateUI(state) } 195 | storeWithSideEffect.dispatch(CalculatorAction.Deduct(1)) 196 | // Current state will become -1 197 | storeWithSideEffect.dispatch(CalculatorAction.Add(11)) 198 | // Current state will become 10 and network request will happen 199 | ``` 200 | 201 | `CoRedux` also provides two simplified implementations of `SideEffect` that you might find useful: 202 | - `SimpleSideEffect` that produces on one input action either one output action or no action: 203 | 204 | ```kotlin 205 | val sideEffect = SimpleSideEffect("Update on server") { 206 | state, action, logger, handler -> 207 | when (action) { 208 | is Action.Update -> handler { 209 | val httpCode = sendToServer(action.value) 210 | if (httpCode == 200) Action.Updated else null 211 | } 212 | else -> null 213 | } 214 | } 215 | ``` 216 | 217 | - `CancellableSideEffect` that cancels previously running `Job` and starts a new one if the same type of action is dispatched to the store: 218 | 219 | ```kotlin 220 | val sideEffect = CancellableSideEffect("poll server") { 221 | state, action, logger, handler -> 222 | when (action) { 223 | StartPollingServer -> handler { name, output -> 224 | openServerPollConnection { update -> 225 | output.send(Action.PollUpdate(update)) 226 | } 227 | } 228 | else -> null 229 | } 230 | } 231 | ``` 232 | 233 | ### Implementation details 234 | 235 | Internally CoRedux starts main coroutine ("manager"), that is a single source of current state, 236 | which is defined in local scope of coroutine - this allows to prevent any concurrency problems on updating the state. 237 | Furthermore "manager" coroutine itself is sequentially: 238 | - starts all side effects coroutines ("workers"), that listens for incoming actions 239 | - sets current state to initial state 240 | - immediately pushes the initial state to the `StateReciever` (depends on `launchMode` parameter) 241 | - starts listening for new actions emitted through `store.dispatch(action)` and from `SideEffect`s 242 | 243 | On each new action "manager" coroutine is sequentially: 244 | - triggers `reducer()` to update current state inside "manager" coroutine local scope 245 | - sends updated current state to all `StateReceiver`s 246 | - broadcast input action to all side effects "workers" coroutines 247 | 248 | If a side effect emtis a new action this action is added at the end of the internal queue 249 | of actions waiting to be dispatched to reducer and to other side effects. 250 | 251 | ### Logging 252 | 253 | To log events you have to add a `LogSink` to your store. 254 | Actually, you can add multiple `LogSink`s if you want to add multiple type of logging. 255 | You do that by passing a list of `LogSink`s in `createStore( logSinks = listOf(logSink1, logSink2, ... )`. 256 | 257 | Types of log events are limited and defined by `LogEvent` sealed class hierarchy. 258 | 259 | `Store` instance will automatically send log events, while `SideEffect` implementations 260 | should send log events by themselfs, using provided `logger`. 261 | 262 | # Sample app 263 | 264 | Сheck `sample/` directory for a sample android app that uses CoRedux to load and 265 | display most popular java-language repositories and amount of starts they have. 266 | --------------------------------------------------------------------------------