├── 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 | 
9 | 
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 | 
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 | 
102 |
103 | Confused? Here is a (pseudo) sequence diagram that illustrates how action flows from SideEffect to other SideEffects and the Reducer:
104 |
105 | 
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 |
--------------------------------------------------------------------------------