├── .github └── workflows │ └── release.yml ├── .gitignore ├── .idea └── vcs.xml ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── mini-android ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── minikorp │ └── mini │ └── android │ ├── FluxActivity.kt │ ├── FluxFragment.kt │ ├── FluxViewModel.kt │ └── ViewUtil.kt ├── mini-common ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ └── java │ │ └── com │ │ └── minikorp │ │ └── mini │ │ ├── AndroidInterop.kt │ │ ├── Annotations.kt │ │ ├── CloseableTracker.kt │ │ ├── CompositeCloseable.kt │ │ ├── Dispatcher.kt │ │ ├── LoggerMiddleware.kt │ │ ├── Middleware.kt │ │ ├── Mini.kt │ │ ├── NestedStateContainer.kt │ │ ├── Resource.kt │ │ ├── StateContainer.kt │ │ ├── Store.kt │ │ ├── StoreFlow.kt │ │ └── Threading.kt │ └── test │ └── kotlin │ └── com │ └── minikorp │ └── mini │ ├── CompositeCloseableTest.kt │ ├── DispatcherTest.kt │ ├── LoggerMiddlewareTest.kt │ ├── ResourceTest.kt │ ├── SampleStore.kt │ ├── StoreFlowTest.kt │ ├── StoreTest.kt │ ├── TestAction.kt │ └── TestDispatcher.kt ├── mini-processor-test ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ └── java │ │ └── com │ │ └── minikorp │ │ └── mini │ │ └── test │ │ ├── AnyAction.kt │ │ ├── BasicState.kt │ │ └── ReducersStore.kt │ └── test │ └── java │ └── com │ └── minikorp │ └── mini │ └── test │ └── ReducersStoreTest.kt ├── mini-processor ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── java │ └── com │ │ └── minikorp │ │ └── mini │ │ ├── ActionTypesGenerator.kt │ │ ├── MiniProcessor.java │ │ ├── Processor.kt │ │ ├── ProcessorUtils.kt │ │ └── ReducersGenerator.kt │ └── resources │ └── META-INF │ ├── gradle │ └── incremental.annotation.processors │ └── services │ └── javax.annotation.processing.Processor ├── mini.codestyle.json ├── sample ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── example │ │ └── androidsample │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── androidsample │ │ │ └── MainActivity.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── example │ └── androidsample │ └── ExampleUnitTest.kt ├── scripts ├── bump-tag.sh ├── latest-version.sh ├── release.sh └── semver.sh └── settings.gradle /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: release 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | push: 9 | branches: [ master ] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | # This workflow contains a single job called "build" 14 | build: 15 | # The type of runner that the job will run on 16 | runs-on: ubuntu-latest 17 | 18 | # Steps represent a sequence of tasks that will be executed as part of the job 19 | steps: 20 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 21 | - uses: actions/checkout@v2 22 | 23 | # Runs a set of commands using the runners shell 24 | - name: build project 25 | run: | 26 | cd $GITHUB_WORKSPACE 27 | sh scripts/release.sh 28 | - name: maven publish 29 | run: | 30 | cd $GITHUB_WORKSPACE 31 | chmod +x gradlew 32 | ./gradlew publish 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .idea/ 4 | /local.properties 5 | /.idea/workspace.xml 6 | /.idea/libraries 7 | .DS_Store 8 | /build 9 | /captures 10 | .externalNativeBuild 11 | 12 | 13 | # Created by https://www.gitignore.io/api/java,gradle,intellij,osx,windows,linux 14 | 15 | ### Intellij ### 16 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 17 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 18 | 19 | # User-specific stuff: 20 | .idea/**/workspace.xml 21 | .idea/**/tasks.xml 22 | 23 | # Sensitive or high-churn files: 24 | .idea/**/dataSources/ 25 | .idea/**/dataSources.ids 26 | .idea/**/dataSources.xml 27 | .idea/**/dataSources.local.xml 28 | .idea/**/sqlDataSources.xml 29 | .idea/**/dynamic.xml 30 | .idea/**/uiDesigner.xml 31 | 32 | # Gradle: 33 | .idea/**/gradle.xml 34 | .idea/**/libraries 35 | 36 | # Mongo Explorer plugin: 37 | .idea/**/mongoSettings.xml 38 | 39 | ## File-based project format: 40 | *.iws 41 | 42 | ## Plugin-specific files: 43 | 44 | # IntelliJ 45 | /out/ 46 | 47 | # mpeltonen/sbt-idea plugin 48 | .idea_modules/ 49 | 50 | # JIRA plugin 51 | atlassian-ide-plugin.xml 52 | 53 | # Crashlytics plugin (for Android Studio and IntelliJ) 54 | com_crashlytics_export_strings.xml 55 | crashlytics.properties 56 | crashlytics-build.properties 57 | fabric.properties 58 | 59 | ### Intellij Patch ### 60 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 61 | 62 | # *.iml 63 | # modules.xml 64 | # .idea/misc.xml 65 | # *.ipr 66 | 67 | ### Java ### 68 | # Compiled class file 69 | *.class 70 | 71 | # Log file 72 | *.message 73 | 74 | # BlueJ files 75 | *.ctxt 76 | 77 | # Mobile Tools for Java (J2ME) 78 | .mtj.tmp/ 79 | 80 | # Package Files # 81 | *.jar 82 | *.war 83 | *.ear 84 | *.zip 85 | *.tar.gz 86 | *.rar 87 | 88 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 89 | hs_err_pid* 90 | 91 | ### Linux ### 92 | *~ 93 | 94 | # temporary files which can be created if a process still has a handle open of a deleted file 95 | .fuse_hidden* 96 | 97 | # KDE directory preferences 98 | .directory 99 | 100 | # Linux trash folder which might appear on any partition or disk 101 | .Trash-* 102 | 103 | # .nfs files are created when an open file is removed but is still being accessed 104 | .nfs* 105 | 106 | ### OSX ### 107 | *.DS_Store 108 | .AppleDouble 109 | .LSOverride 110 | 111 | # Icon must end with two \r 112 | Icon 113 | 114 | 115 | # Thumbnails 116 | ._* 117 | 118 | # Files that might appear in the root of a volume 119 | .DocumentRevisions-V100 120 | .fseventsd 121 | .Spotlight-V100 122 | .TemporaryItems 123 | .Trashes 124 | .VolumeIcon.icns 125 | .com.apple.timemachine.donotpresent 126 | 127 | # Directories potentially created on remote AFP share 128 | .AppleDB 129 | .AppleDesktop 130 | Network Trash Folder 131 | Temporary Items 132 | .apdisk 133 | 134 | ### Windows ### 135 | # Windows thumbnail cache files 136 | Thumbs.db 137 | ehthumbs.db 138 | ehthumbs_vista.db 139 | 140 | # Folder config file 141 | Desktop.ini 142 | 143 | # Recycle Bin used on file shares 144 | $RECYCLE.BIN/ 145 | 146 | # Windows Installer files 147 | *.cab 148 | *.msi 149 | *.msm 150 | *.msp 151 | 152 | # Windows shortcuts 153 | *.lnk 154 | 155 | ### Gradle ### 156 | .gradle 157 | /build/ 158 | 159 | # Ignore Gradle GUI config 160 | gradle-app.setting 161 | 162 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 163 | !gradle-wrapper.jar 164 | 165 | # Cache of project 166 | .gradletasknamecache 167 | 168 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 169 | # gradle/wrapper/gradle-wrapper.properties 170 | 171 | # End of https://www.gitignore.io/api/java,gradle,intellij,osx,windows,linux -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Pablo Orgaz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Latest version: [![Release](https://jitpack.io/v/minikorp/mini.svg)](https://jitpack.io/#minikorp/mini) 2 | 3 | 4 | # Mini 5 | Mini is a minimal Flux architecture written in Kotlin that also adds a mix of useful features to build UIs fast. 6 | 7 | ### Purpose 8 | You should use this library if you aim to develop a reactive application with good performance (no reflection using code-gen). 9 | Feature development using Mini is fast compared to traditional architectures (like CLEAN or MVP), low boilerplate and state based models make feature integration and bugfixing easy as well as removing several families of problems like concurrency or view consistency across screens. 10 | 11 | ## How to Use 12 | ### Actions 13 | 14 | Annotate action classes with `@Action` or extend `BaseAction`. 15 | 16 | Marking a class as action will make all the action and all supertypes available for listening from `@Reducer` functions. 17 | 18 | ### Dispatcher 19 | 20 | ```kotlin 21 | //Dispatch an action on the main thread synchronously 22 | dispatcher.dispatch(LoginAction(username = "user", password = "123")) 23 | 24 | //Post an event that will dispatch the action on the UI thread and return immediately. 25 | dispatcher.dispatchAsync(LoginAction(username = "user", password = "123")) 26 | ``` 27 | 28 | ### Store 29 | The Stores are holders for application state and state mutation logic. In order to do so they expose pure reducer functions that are later invoked by the dispatcher. 30 | 31 | The state is plain object (usually a data class) that holds all information needed to display the view. State should always be inmutable. State classes should avoid using framework elements (View, Camera, Cursor...) in order to facilitate testing. 32 | 33 | Stores subscribe to actions to change the application state after a dispatch. Mini generates the code that links dispatcher actions and stores using the `@Reducer` annotation over a **non-private function that receives an `@Action` as parameter**. 34 | 35 | ### Generated code 36 | 37 | Mini generates `mini.MiniGen` class at compilation time to use as factory for `Dispatcher` and automatic `@Reducer` subscription calls. MiniGen is not required to use Mini, but encouraged to reduce boilerplate. 38 | 39 | ```kotlin 40 | val dispatcher = MiniGen.newDispatcher() 41 | val stores = listOf(your stores...) 42 | //Bind @Reducer functions with dispatcher. 43 | MiniGen.subscribe(dispatcher, stores) 44 | ``` 45 | 46 | ### View changes 47 | Each ``Store`` exposes a custom `StoreCallback` though the method `subscribe` or a `Flowable` if you wanna make use of RxJava. Both of them emits changes produced on their states, allowing the view to listen reactive the state changes. Being able to update the UI according to the new `Store` state. 48 | 49 | ```kotlin 50 | //Using Flow 51 | userStore 52 | .flow() 53 | .onEach { updateUserName(it.name) } 54 | .launchIn(lifecycleScope) 55 | 56 | // Default callback 57 | userStore 58 | .subscribe { state -> updateUserName(state.name) } 59 | ``` 60 | 61 | ### Logging 62 | Mini includes a custom `LoggerInterceptor` to log any change in your `Store` states produced from an `Action`. This will allow you to keep track of your actions, changes and side-effects more easily. 63 | 64 | ## Gradle 65 | 66 | [![Release](https://jitpack.io/v/minikorp/mini.svg)](https://jitpack.io/#minikorp/mini) 67 | 68 | ```groovy 69 | apply plugin: kotlin 70 | apply plugin: kotlin-kapt 71 | 72 | dependencies { 73 | def mini_version = "4.2.0" //See latest version tag at top 74 | implementation "com.github.minikorp.mini:mini-common:$mini_version" 75 | kapt "com.github.minikorp.mini:mini-processor:$mini_version" 76 | //Optional dependencies 77 | implementation "com.github.minikorp.mini:mini-rx:$mini_version" //Rx bindings 78 | implementation "com.github.minikorp.mini:mini-flow:$mini_version" //Flow bindings 79 | implementation "com.github.minikorp.mini:mini-android:$mini_version" //Android utilities 80 | } 81 | ``` 82 | 83 | ## Issues 84 | 85 | Jetifier might crash your build without reason, 86 | add this line to gradle.properties to exclude the compiler or fully disable it. 87 | 88 | ```properties 89 | android.jetifier.blacklist=mini-processor.*\\.jar #Blacklist 90 | android.enableJetifier=false #Or disable 91 | ``` 92 | 93 | Compiling JDK >8 might fail, make sure you set compatibility to java 8 94 | both for Android and kapt. 95 | 96 | ```groovy 97 | android { 98 | compileOptions { 99 | sourceCompatibility "1.8" 100 | targetCompatibility "1.8" 101 | } 102 | } 103 | 104 | kapt { 105 | javacOptions { 106 | option("-source", "8") 107 | option("-target", "8") 108 | } 109 | } 110 | ``` 111 | 112 | ## Build performance 113 | 114 | Make sure to enable incremental apt and worker api for faster builds with kapt. 115 | 116 | ```properties 117 | # Some performance improvements 118 | org.gradle.parallel=true 119 | org.gradle.configureondemand=true 120 | org.gradle.caching=true 121 | org.gradle.daemon=true 122 | kapt.incremental.apt=true 123 | kapt.use.worker.api=true 124 | ``` 125 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | google() 5 | jcenter() 6 | } 7 | dependencies { 8 | classpath(kotlin("gradle-plugin", version = "1.4.21")) 9 | classpath("com.android.tools.build:gradle:3.6.4") 10 | } 11 | } 12 | 13 | plugins { 14 | kotlin("jvm") version "1.4.21" 15 | `maven-publish` 16 | } 17 | 18 | fun runCommand(command: String): String { 19 | val runtime = Runtime.getRuntime() 20 | val process = if (System.getProperty("os.name").toLowerCase().contains("windows")) { 21 | runtime.exec("bash $command") 22 | } else { 23 | runtime.exec(command) 24 | } 25 | val stream = process.apply { waitFor() }.inputStream 26 | return stream.reader().readText().trim() 27 | } 28 | 29 | 30 | subprojects { 31 | 32 | apply(plugin = "maven-publish") 33 | version = runCommand("$rootDir/scripts/latest-version.sh") 34 | group = "com.minikorp" 35 | 36 | afterEvaluate { 37 | val modules = arrayOf("mini-common", "mini-processor", "mini-android") 38 | if (this.name !in modules) return@afterEvaluate 39 | 40 | if (tasks.findByName("sourcesJar") == null) { 41 | tasks.register("sourcesJar", Jar::class) { 42 | archiveClassifier.set("sources") 43 | from(sourceSets["main"].allSource) 44 | } 45 | } 46 | 47 | this.publishing { 48 | repositories { 49 | maven { 50 | name = "GitHubPackages" 51 | url = uri("https://maven.pkg.github.com/minikorp/mini") 52 | credentials { 53 | username = "minikorp" 54 | password = System.getenv("GITHUB_TOKEN") 55 | } 56 | } 57 | } 58 | 59 | publications { 60 | if (publications.findByName("gpr") == null) { 61 | register("gpr", MavenPublication::class) { 62 | from(components["java"]) 63 | artifact(tasks.findByName("sourcesJar")) 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | 72 | allprojects { 73 | repositories { 74 | jcenter() 75 | mavenCentral() 76 | maven { url = uri("https://jitpack.io") } 77 | google() 78 | } 79 | } 80 | 81 | repositories { 82 | jcenter() 83 | mavenCentral() 84 | //maven { url = URI.parse("https://jitpack.io") } 85 | google() 86 | } 87 | 88 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will take* 4 | # any settings specified in this file. 5 | # For more details on how to fn 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 | android.useAndroidX=true 11 | android.enableJetifier=false 12 | # When configured, Gradle will run in incubating parallel mode. 13 | # This option should only be used with decoupled projects. More details, visit 14 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 15 | # org.gradle.parallel=true 16 | 17 | # Some performance improvements 18 | org.gradle.parallel=true 19 | org.gradle.configureondemand=true 20 | org.gradle.caching=true 21 | org.gradle.daemon=true 22 | kapt.incremental.apt=true 23 | kapt.use.worker.api=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Jan 20 09:55:33 CET 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # Attempt to set APP_HOME 46 | # Resolve links: $0 may be a link 47 | PRG="$0" 48 | # Need this for relative symlinks. 49 | while [ -h "$PRG" ] ; do 50 | ls=`ls -ld "$PRG"` 51 | link=`expr "$ls" : '.*-> \(.*\)$'` 52 | if expr "$link" : '/.*' > /dev/null; then 53 | PRG="$link" 54 | else 55 | PRG=`dirname "$PRG"`"/$link" 56 | fi 57 | done 58 | SAVED="`pwd`" 59 | cd "`dirname \"$PRG\"`/" >/dev/null 60 | APP_HOME="`pwd -P`" 61 | cd "$SAVED" >/dev/null 62 | 63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 64 | 65 | # Determine the Java command to use to start the JVM. 66 | if [ -n "$JAVA_HOME" ] ; then 67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 68 | # IBM's JDK on AIX uses strange locations for the executables 69 | JAVACMD="$JAVA_HOME/jre/sh/java" 70 | else 71 | JAVACMD="$JAVA_HOME/bin/java" 72 | fi 73 | if [ ! -x "$JAVACMD" ] ; then 74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 75 | 76 | Please set the JAVA_HOME variable in your environment to match the 77 | location of your Java installation." 78 | fi 79 | else 80 | JAVACMD="java" 81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 82 | 83 | Please set the JAVA_HOME variable in your environment to match the 84 | location of your Java installation." 85 | fi 86 | 87 | # Increase the maximum file descriptors if we can. 88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 89 | MAX_FD_LIMIT=`ulimit -H -n` 90 | if [ $? -eq 0 ] ; then 91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 92 | MAX_FD="$MAX_FD_LIMIT" 93 | fi 94 | ulimit -n $MAX_FD 95 | if [ $? -ne 0 ] ; then 96 | warn "Could not set maximum file descriptor limit: $MAX_FD" 97 | fi 98 | else 99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 100 | fi 101 | fi 102 | 103 | # For Darwin, add options to specify how the application appears in the dock 104 | if $darwin; then 105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 106 | fi 107 | 108 | # For Cygwin, switch paths to Windows format before running java 109 | if $cygwin ; then 110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 112 | JAVACMD=`cygpath --unix "$JAVACMD"` 113 | 114 | # We build the pattern for arguments to be converted via cygpath 115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 116 | SEP="" 117 | for dir in $ROOTDIRSRAW ; do 118 | ROOTDIRS="$ROOTDIRS$SEP$dir" 119 | SEP="|" 120 | done 121 | OURCYGPATTERN="(^($ROOTDIRS))" 122 | # Add a user-defined pattern to the cygpath arguments 123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 125 | fi 126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 127 | i=0 128 | for arg in "$@" ; do 129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 131 | 132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 134 | else 135 | eval `echo args$i`="\"$arg\"" 136 | fi 137 | i=$((i+1)) 138 | done 139 | case $i in 140 | (0) set -- ;; 141 | (1) set -- "$args0" ;; 142 | (2) set -- "$args0" "$args1" ;; 143 | (3) set -- "$args0" "$args1" "$args2" ;; 144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 150 | esac 151 | fi 152 | 153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 154 | function splitJvmOpts() { 155 | JVM_OPTS=("$@") 156 | } 157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 159 | 160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 161 | -------------------------------------------------------------------------------- /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 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 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 Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /mini-android/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /mini-android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | kotlin("android") 4 | } 5 | 6 | android { 7 | compileSdkVersion(30) 8 | buildToolsVersion("29.0.3") 9 | 10 | defaultConfig { 11 | minSdkVersion(14) 12 | targetSdkVersion(30) 13 | versionCode = 1 14 | versionName = "1.0" 15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 16 | } 17 | buildTypes { 18 | getByName("release") { 19 | isMinifyEnabled = false 20 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 21 | } 22 | } 23 | } 24 | 25 | dependencies { 26 | api(project(":mini-common")) 27 | 28 | api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1") 29 | api("androidx.appcompat:appcompat:1.2.0") 30 | 31 | val lifecycleVersion = "2.3.0-rc01" 32 | api("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion") 33 | api("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion") 34 | api("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion") 35 | 36 | 37 | testImplementation("junit:junit:4.13.1") 38 | androidTestImplementation("androidx.test:runner:1.3.0") 39 | androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0") 40 | } 41 | 42 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java) { 43 | kotlinOptions.freeCompilerArgs += listOf( 44 | "-Xuse-experimental=kotlin.Experimental", 45 | "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", 46 | "-Xuse-experimental=kotlinx.coroutines.FlowPreview" 47 | ) 48 | } 49 | 50 | val sourcesJar by tasks.registering(Jar::class) { 51 | @Suppress("UnstableApiUsage") 52 | archiveClassifier.set("sources") 53 | from(android.sourceSets["main"].java.sourceFiles) 54 | } 55 | 56 | publishing { 57 | publications { 58 | create("gpr") { 59 | artifact(tasks.findByName("sourcesJar")) 60 | afterEvaluate { 61 | from(components["release"]) 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /mini-android/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.kts.kts. 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 | 23 | 24 | #Generated code will go on this package and is accessed using reflection 25 | #so keep it as is 26 | #noinspection ShrinkerUnresolvedReference 27 | -keep class com.minikorp.mini.codegen.** { *; } 28 | -------------------------------------------------------------------------------- /mini-android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /mini-android/src/main/java/com/minikorp/mini/android/FluxActivity.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini.android 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.lifecycle.lifecycleScope 6 | import com.minikorp.mini.CloseableTracker 7 | import com.minikorp.mini.DefaultCloseableTracker 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.launchIn 11 | import kotlinx.coroutines.launch 12 | import kotlin.coroutines.CoroutineContext 13 | 14 | abstract class FluxActivity : AppCompatActivity(), 15 | CloseableTracker by DefaultCloseableTracker(), 16 | CoroutineScope { 17 | 18 | override val coroutineContext: CoroutineContext 19 | get() = lifecycleScope.coroutineContext 20 | 21 | 22 | override fun onCreate(savedInstanceState: Bundle?) { 23 | super.onCreate(savedInstanceState) 24 | lifecycleScope.launch { whenCreated(savedInstanceState) } 25 | } 26 | 27 | override fun onResume() { 28 | super.onResume() 29 | lifecycleScope.launch { whenResumed() } 30 | } 31 | 32 | override fun onPause() { 33 | super.onPause() 34 | lifecycleScope.launch { whenPaused() } 35 | } 36 | 37 | override fun onDestroy() { 38 | lifecycleScope.launch { whenDestroyed() } 39 | close() 40 | super.onDestroy() 41 | } 42 | 43 | fun Flow.launchInLifecycleScope() { 44 | launchIn(lifecycleScope) 45 | } 46 | 47 | protected open suspend fun whenCreated(savedInstanceState: Bundle?) = Unit 48 | protected open suspend fun whenResumed() = Unit 49 | protected open suspend fun whenPaused() = Unit 50 | protected open suspend fun whenDestroyed() = Unit 51 | 52 | } -------------------------------------------------------------------------------- /mini-android/src/main/java/com/minikorp/mini/android/FluxFragment.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini.android 2 | 3 | import android.os.Bundle 4 | import androidx.fragment.app.Fragment 5 | import androidx.lifecycle.lifecycleScope 6 | import com.minikorp.mini.CloseableTracker 7 | import com.minikorp.mini.DefaultCloseableTracker 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.launchIn 11 | import kotlinx.coroutines.launch 12 | import kotlin.coroutines.CoroutineContext 13 | 14 | abstract class FluxFragment : Fragment(), 15 | CloseableTracker by DefaultCloseableTracker(), 16 | CoroutineScope { 17 | 18 | override val coroutineContext: CoroutineContext 19 | get() = lifecycleScope.coroutineContext 20 | 21 | override fun onActivityCreated(savedInstanceState: Bundle?) { 22 | super.onActivityCreated(savedInstanceState) 23 | lifecycleScope.launch { whenCreated(savedInstanceState) } 24 | } 25 | 26 | override fun onResume() { 27 | super.onResume() 28 | lifecycleScope.launch { whenResumed() } 29 | } 30 | 31 | override fun onPause() { 32 | super.onPause() 33 | lifecycleScope.launch { whenPaused() } 34 | } 35 | 36 | override fun onDestroy() { 37 | lifecycleScope.launch { whenDestroyed() } 38 | close() 39 | super.onDestroy() 40 | } 41 | 42 | fun Flow.launchOnUi() { 43 | launchIn(lifecycleScope) 44 | } 45 | 46 | protected open suspend fun whenCreated(savedInstanceState: Bundle?) = Unit 47 | protected open suspend fun whenResumed() = Unit 48 | protected open suspend fun whenPaused() = Unit 49 | protected open suspend fun whenDestroyed() = Unit 50 | } -------------------------------------------------------------------------------- /mini-android/src/main/java/com/minikorp/mini/android/FluxViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini.android 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import com.minikorp.mini.CloseableTracker 6 | import com.minikorp.mini.DefaultCloseableTracker 7 | import com.minikorp.mini.StateContainer 8 | import com.minikorp.mini.assertOnUiThread 9 | import java.io.Closeable 10 | import java.util.concurrent.CopyOnWriteArrayList 11 | 12 | abstract class FluxViewModel( 13 | val savedStateHandle: SavedStateHandle) : 14 | ViewModel(), 15 | StateContainer, 16 | CloseableTracker by DefaultCloseableTracker() { 17 | 18 | 19 | class ViewModelSubscription internal constructor(private val vm: FluxViewModel<*>, 20 | private val fn: Any) : Closeable { 21 | override fun close() { 22 | vm.listeners.remove(fn) 23 | } 24 | } 25 | 26 | private var _state: Any? = StateContainer.Companion.NoState 27 | private val listeners = CopyOnWriteArrayList<(S) -> Unit>() 28 | 29 | override val state: S 30 | get() { 31 | if (_state === StateContainer.Companion.NoState) { 32 | synchronized(this) { 33 | if (_state === StateContainer.Companion.NoState) { 34 | _state = restoreState(savedStateHandle) ?: initialState() 35 | } 36 | } 37 | } 38 | @Suppress("UNCHECKED_CAST") 39 | return _state as S 40 | } 41 | 42 | 43 | override fun setState(newState: S) { 44 | assertOnUiThread() 45 | performStateChange(newState) 46 | } 47 | 48 | private fun performStateChange(newState: S) { 49 | if (_state != newState) { 50 | _state = newState 51 | saveState(newState, savedStateHandle) 52 | listeners.forEach { 53 | it(newState) 54 | } 55 | } 56 | } 57 | 58 | /** 59 | * Persist the state, no-op by default. 60 | * 61 | * ```handle.set("state", state)``` 62 | */ 63 | open fun saveState(state: S, handle: SavedStateHandle) { 64 | //No-op 65 | } 66 | 67 | /** 68 | * Restore the state from the [SavedStateHandle] or null if nothing was saved. 69 | * 70 | * ```handle.get("state")``` 71 | */ 72 | open fun restoreState(handle: SavedStateHandle): S? { 73 | return null 74 | } 75 | 76 | override fun subscribe(hotStart: Boolean, fn: (S) -> Unit): Closeable { 77 | listeners.add(fn) 78 | if (hotStart) fn(state) 79 | return ViewModelSubscription(this, fn) 80 | } 81 | 82 | override fun onCleared() { 83 | super.onCleared() 84 | close() 85 | } 86 | } -------------------------------------------------------------------------------- /mini-android/src/main/java/com/minikorp/mini/android/ViewUtil.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini.android 2 | 3 | import android.content.res.Resources 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.annotation.LayoutRes 8 | import com.minikorp.mini.Resource 9 | import kotlin.math.roundToInt 10 | 11 | /** 12 | * Toggle two views between content / loading / error based on [Resource] state. 13 | * 14 | * Has no effect when resource is idle. 15 | */ 16 | fun toggleViewsVisibility( 17 | resource: Resource<*>, 18 | contentView: View? = null, 19 | loadingView: View? = null, 20 | errorView: View? = null, 21 | idleView: View? = null, 22 | invisibilityType: Int = View.INVISIBLE 23 | ) { 24 | val newVisibilities = arrayOf(invisibilityType, invisibilityType, invisibilityType, invisibilityType) 25 | val indexToMakeVisible = 26 | when { 27 | resource.isSuccess -> 0 28 | resource.isLoading -> 1 29 | resource.isFailure -> 2 30 | resource.isEmpty -> 3 31 | else -> throw UnsupportedOperationException() 32 | } 33 | newVisibilities[indexToMakeVisible] = View.VISIBLE 34 | contentView?.visibility = newVisibilities[0] 35 | loadingView?.visibility = newVisibilities[1] 36 | errorView?.visibility = newVisibilities[2] 37 | idleView?.visibility = newVisibilities[3] 38 | } 39 | 40 | fun ViewGroup.inflateNoAttach(@LayoutRes layout: Int): View { 41 | return LayoutInflater.from(this.context).inflate(layout, this, false) 42 | } 43 | 44 | fun View.makeVisible() = run { visibility = View.VISIBLE } 45 | fun View.makeInvisible() = run { visibility = View.INVISIBLE } 46 | fun View.makeGone() = run { visibility = View.GONE } 47 | 48 | /** 49 | * 8.dp -> 8dp in value in pixels 50 | */ 51 | val Number.dp: Int get() = (this.toFloat() * Resources.getSystem().displayMetrics.density).roundToInt() 52 | -------------------------------------------------------------------------------- /mini-common/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /mini-common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | } 4 | 5 | dependencies { 6 | implementation(kotlin("stdlib-jdk8")) 7 | api(kotlin("reflect")) 8 | compileOnly("com.google.android:android:4.1.1.4") 9 | 10 | val coroutines = "1.3.9" 11 | api("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines") 12 | 13 | testImplementation("junit:junit:4.13.1") 14 | testImplementation("org.amshove.kluent:kluent:1.44") 15 | } 16 | 17 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java) { 18 | kotlinOptions.freeCompilerArgs += listOf( 19 | "-Xuse-experimental=kotlin.Experimental", 20 | "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", 21 | "-Xuse-experimental=kotlinx.coroutines.FlowPreview" 22 | ) 23 | } -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/AndroidInterop.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | /** 4 | * Check if running on android device / emulator or jvm 5 | */ 6 | internal val isAndroid by lazy { 7 | try { 8 | android.os.Build.VERSION.SDK_INT != 0 9 | } catch (e: Throwable) { 10 | false 11 | } 12 | } 13 | 14 | fun requireAndroid() { 15 | if (!isAndroid) { 16 | throw UnsupportedOperationException("This method can only be called from android environment") 17 | } 18 | } -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/Annotations.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import java.lang.annotation.Inherited 4 | 5 | const val DEFAULT_PRIORITY = 100 6 | 7 | /** 8 | * Mark a type as action for code generation. All actions must include this annotation 9 | * or dispatcher won't work properly. 10 | */ 11 | @Target(AnnotationTarget.TYPE, AnnotationTarget.CLASS) 12 | @Retention(AnnotationRetention.RUNTIME) 13 | @Inherited 14 | annotation class Action 15 | 16 | 17 | /** 18 | * Mark a function declared in a [StateContainer] as a reducer function. 19 | * 20 | * Reducers function must have two parameters, the state that must have same time 21 | * as the [StateContainer] state, and the action being handled. 22 | * 23 | * If the reducer function is not pure, only the action parameter is allowed 24 | * and function should have no return. 25 | */ 26 | @Target(AnnotationTarget.FUNCTION) 27 | @Retention(AnnotationRetention.RUNTIME) 28 | annotation class Reducer(val priority: Int = DEFAULT_PRIORITY) 29 | 30 | -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/CloseableTracker.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import java.io.Closeable 4 | 5 | interface CloseableTracker : Closeable { 6 | /** 7 | * Start tracking a disposable. 8 | */ 9 | fun T.track(): T 10 | } 11 | 12 | class DefaultCloseableTracker : CloseableTracker { 13 | private val closeables = CompositeCloseable() 14 | override fun close() = closeables.close() 15 | override fun T.track(): T { 16 | closeables.add(this) 17 | return this 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/CompositeCloseable.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import java.io.Closeable 4 | 5 | /** 6 | * A collection of closeables. 7 | */ 8 | class CompositeCloseable : Closeable { 9 | private val items = ArrayList() 10 | 11 | override fun close() { 12 | synchronized(this) { 13 | items.forEach { it.close() } 14 | items.clear() 15 | } 16 | } 17 | 18 | fun add(closeable: T): T { 19 | synchronized(this) { 20 | items.add(closeable) 21 | } 22 | return closeable 23 | } 24 | } -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/Dispatcher.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import kotlinx.coroutines.* 4 | import java.io.Closeable 5 | import java.util.* 6 | import java.util.concurrent.atomic.AtomicInteger 7 | import kotlin.coroutines.CoroutineContext 8 | import kotlin.coroutines.EmptyCoroutineContext 9 | import kotlin.reflect.KClass 10 | 11 | private typealias DispatchCallback = suspend (Any) -> Unit 12 | 13 | 14 | /** 15 | * Hub for actions. Use code generation with [Mini] 16 | * or provide action type map information and manually handle subscriptions. 17 | * 18 | * @param strictMode Verify calling thread, only disable in production! 19 | * 20 | */ 21 | class Dispatcher(private val strictMode: Boolean = false) { 22 | 23 | /** 24 | * All types an action can be observed as. 25 | * If map is empty, the runtime type itself will be used. If using code generation, 26 | * [Mini.actionTypes] will contain a map with all super types of @[Action] annotated classes. 27 | */ 28 | var actionTypeMap: Map, List>> = emptyMap() 29 | 30 | /** 31 | * Action at the top of the dispatch stack. 32 | */ 33 | val lastAction: Any? get() = actionStack.firstOrNull() 34 | 35 | private val subscriptionCaller: Chain = object : Chain { 36 | override suspend fun proceed(action: Any): Any { 37 | val types = actionTypeMap[action::class] 38 | ?: error("${action::class.simpleName} is not action") 39 | //Ensure reducer is called on Main dispatcher 40 | types.forEach { type -> 41 | subscriptions[type]?.forEach { it.fn(action) } 42 | } 43 | return action 44 | } 45 | } 46 | 47 | private val middlewares: MutableList = ArrayList() 48 | private var middlewareChain: Chain = buildChain() 49 | private val actionStack: Stack = Stack() 50 | 51 | internal val subscriptions: MutableMap, MutableSet> = HashMap() 52 | 53 | private fun buildChain(): Chain { 54 | return middlewares.fold(subscriptionCaller) { chain, middleware -> 55 | object : Chain { 56 | override suspend fun proceed(action: Any): Any { 57 | return middleware.intercept(action, chain) 58 | } 59 | } 60 | } 61 | } 62 | 63 | fun addMiddleware(middleware: Middleware) { 64 | synchronized(this) { 65 | middlewares += middleware 66 | middlewareChain = buildChain() 67 | } 68 | } 69 | 70 | fun removeInterceptor(middleware: Middleware) { 71 | synchronized(this) { 72 | middlewares -= middleware 73 | middlewareChain = buildChain() 74 | } 75 | } 76 | 77 | inline fun subscribe(priority: Int = 100, 78 | noinline callback: suspend (A) -> Unit): Closeable { 79 | return subscribe(A::class, priority, callback) 80 | } 81 | 82 | @Suppress("UNCHECKED_CAST") 83 | fun subscribe(clazz: KClass, 84 | priority: Int = 100, 85 | callback: suspend (T) -> Unit): Closeable { 86 | synchronized(subscriptions) { 87 | val reg = DispatcherSubscription(this, clazz, priority, callback as DispatchCallback) 88 | val set = subscriptions.getOrPut(clazz) { 89 | TreeSet { a, b -> 90 | //Sort by priority, then by id for equal priority 91 | val p = a.priority.compareTo(b.priority) 92 | if (p == 0) a.id.compareTo(b.id) 93 | else p 94 | } 95 | } 96 | set.add(reg) 97 | return reg 98 | } 99 | } 100 | 101 | fun unregister(registration: DispatcherSubscription) { 102 | synchronized(subscriptions) { 103 | subscriptions[registration.type]?.remove(registration) 104 | } 105 | } 106 | 107 | 108 | /** 109 | * Dispatch an action on the main thread using an unconfined dispatcher 110 | * so it's safe for even loops. 111 | */ 112 | suspend fun dispatch(action: Any) { 113 | if (isAndroid) { 114 | withContext(Dispatchers.Main.immediate) { doDispatch(action) } 115 | } else { 116 | doDispatch(action) 117 | } 118 | } 119 | 120 | /** 121 | * Dispatch an action, blocking the thread until it's complete. 122 | * 123 | * Calling from UI thread will throw an exception since it can potentially result 124 | * in ANR error. 125 | */ 126 | fun dispatchBlocking(action: Any) { 127 | if (strictMode) assertOnBgThread() 128 | runBlocking { 129 | dispatch(action) 130 | } 131 | } 132 | 133 | private suspend fun doDispatch(action: Any) { 134 | actionStack.push(action) 135 | middlewareChain.proceed(action) 136 | actionStack.pop() 137 | } 138 | 139 | /** 140 | * Handle for a dispatcher subscription. 141 | */ 142 | data class DispatcherSubscription internal constructor(val dispatcher: Dispatcher, 143 | val type: KClass<*>, 144 | val priority: Int, 145 | val fn: DispatchCallback) : Closeable { 146 | companion object { 147 | private val idCounter = AtomicInteger() 148 | } 149 | 150 | override fun close() { 151 | if (disposed) return 152 | dispatcher.unregister(this) 153 | disposed = true 154 | } 155 | 156 | //Alias for close 157 | fun dispose() = close() 158 | 159 | val id = idCounter.getAndIncrement() 160 | var disposed = false 161 | } 162 | } -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/LoggerMiddleware.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import android.util.Log 4 | import java.util.concurrent.atomic.AtomicInteger 5 | 6 | /** Actions implementing this interface won't log anything, including nested calls */ 7 | interface SilentAction 8 | 9 | /** 10 | * Actions implementing this interface will log nested actions visually since they will 11 | * most likely dispatch other actions. 12 | */ 13 | interface SuspendingAction 14 | 15 | internal fun extractClassName(clazz: Class<*>): String { 16 | return clazz.name.substringAfterLast(".") 17 | } 18 | 19 | /** 20 | * Action logging for stores. 21 | */ 22 | class LoggerMiddleware(stores: Collection>, 23 | private val tag: String = "MiniLog", 24 | private val diffFunction: ((a: Any?, b: Any?) -> String)? = null, 25 | private val logger: (priority: Int, tag: String, msg: String) -> Unit) : Middleware { 26 | 27 | private var actionCounter = AtomicInteger(0) 28 | 29 | private val stores = stores.toList() 30 | 31 | override suspend fun intercept(action: Any, chain: Chain): Any { 32 | if (action is SilentAction) chain.proceed(action) //Do nothing 33 | 34 | val isSuspending = action is SuspendingAction 35 | val beforeStates: Array = Array(stores.size) { } 36 | val afterStates: Array = Array(stores.size) { } 37 | val actionName = extractClassName(action.javaClass) 38 | 39 | stores.forEachIndexed { idx, store -> beforeStates[idx] = store.state } 40 | 41 | val (upCorner, downCorner) = if (isSuspending) { 42 | "╔═════ " to "╚════> " 43 | } else { 44 | "┌── " to "└─> " 45 | } 46 | 47 | val prelude = "[${"${actionCounter.getAndIncrement() % 100}".padStart(2, '0')}] " 48 | 49 | logger(Log.DEBUG, tag, "$prelude$upCorner$actionName") 50 | logger(Log.DEBUG, tag, "$prelude$action") 51 | 52 | //Pass it down 53 | val start = System.nanoTime() 54 | val outAction = chain.proceed(action) 55 | val processTime = (System.nanoTime() - start) / 1000000 56 | 57 | stores.forEachIndexed { idx, store -> afterStates[idx] = store.state } 58 | 59 | if (!isSuspending) { 60 | for (i in beforeStates.indices) { 61 | val oldState = beforeStates[i] 62 | val newState = afterStates[i] 63 | if (oldState !== newState) { 64 | val line = "$prelude│ ${stores[i].javaClass.name}" 65 | logger(Log.VERBOSE, tag, "$line: $newState") 66 | diffFunction?.invoke(oldState, newState)?.let { diff -> 67 | logger(Log.DEBUG, tag, "$line: $diff") 68 | } 69 | } 70 | } 71 | } 72 | 73 | logger(Log.DEBUG, tag, "$prelude$downCorner$actionName ${processTime}ms") 74 | 75 | return outAction 76 | } 77 | } -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/Middleware.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | /** 4 | * Middleware that will be called for every dispatch to modify the 5 | * action or perform side effects like logging. 6 | * 7 | * Call chain.proceed(action) with the new action or dispatcher chain will be broken. 8 | */ 9 | interface Middleware { 10 | suspend fun intercept(action: Any, chain: Chain): Any 11 | } 12 | 13 | /** 14 | * A chain of interceptors. Call [proceed] with 15 | * the intercepted action or directly handle it. 16 | */ 17 | interface Chain { 18 | suspend fun proceed(action: Any): Any 19 | } 20 | -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/Mini.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import java.io.Closeable 4 | import java.lang.IllegalStateException 5 | import kotlin.reflect.KClass 6 | 7 | const val DISPATCHER_FACTORY_CLASS_NAME = "com.minikorp.mini.codegen.Mini_Generated" 8 | 9 | abstract class Mini { 10 | 11 | companion object { 12 | 13 | private val miniInstance: Mini by lazy { 14 | try { 15 | Class.forName(DISPATCHER_FACTORY_CLASS_NAME).getField("INSTANCE").get(null) as Mini 16 | } catch (ex: Throwable) { 17 | throw ClassNotFoundException("Failed to load generated class $DISPATCHER_FACTORY_CLASS_NAME, " + 18 | "most likely kapt did not run, add it as dependency to the project", ex) 19 | } 20 | } 21 | 22 | /** 23 | * Generate all subscriptions from @[Reducer] annotated methods and bundle 24 | * into a single Closeable. 25 | */ 26 | fun link(dispatcher: Dispatcher, container: StateContainer<*>): Closeable { 27 | ensureDispatcherInitialized(dispatcher) 28 | return miniInstance.subscribe(dispatcher, container) 29 | } 30 | 31 | /** 32 | * Generate all subscriptions from @[Reducer] annotated methods and bundle 33 | * into a single Closeable. 34 | */ 35 | fun link(dispatcher: Dispatcher, containers: Iterable>): Closeable { 36 | ensureDispatcherInitialized(dispatcher) 37 | return miniInstance.subscribe(dispatcher, containers) 38 | } 39 | 40 | private fun ensureDispatcherInitialized(dispatcher: Dispatcher) { 41 | if (dispatcher.actionTypeMap.isEmpty()) { 42 | dispatcher.actionTypeMap = miniInstance.actionTypes 43 | } 44 | } 45 | 46 | } 47 | 48 | /** 49 | * All the types an action can be subscribed as. 50 | */ 51 | abstract val actionTypes: Map, List>> 52 | 53 | /** 54 | * Link all [Reducer] functions present in the store to the dispatcher. 55 | */ 56 | protected abstract fun subscribe(dispatcher: Dispatcher, container: StateContainer): Closeable 57 | 58 | /** 59 | * Link all [Reducer] functions present in the store to the dispatcher. 60 | */ 61 | protected fun subscribe(dispatcher: Dispatcher, containers: Iterable>): Closeable { 62 | val c = CompositeCloseable() 63 | containers.forEach { container -> 64 | c.add(subscribe(dispatcher, container)) 65 | } 66 | return c 67 | } 68 | } -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/NestedStateContainer.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import java.io.Closeable 4 | 5 | /** 6 | * Utility class to allow splitting [StateContainer] into chunks so not all reducers live in the same 7 | * file. 8 | * 9 | * From a state container 10 | * 11 | * ``` 12 | * class Reducer : NestedStateContainer() { 13 | * @Reducer 14 | * fun reduceOneAction(...) 15 | * } 16 | * 17 | * class MyStore { 18 | * val reducer = Reducer(this) 19 | * 20 | * init { 21 | * Mini.link(dispatcher, listOf(this, reducer)) 22 | * } 23 | * 24 | * @Reducer 25 | * fun globalReduceFn(...) 26 | * } 27 | * ``` 28 | */ 29 | abstract class NestedStateContainer(var parent: StateContainer? = null) : StateContainer { 30 | override val state: S 31 | get() = parent!!.state 32 | 33 | override fun setState(newState: S) { 34 | parent!!.setState(newState) 35 | } 36 | 37 | override fun subscribe(hotStart: Boolean, fn: (S) -> Unit): Closeable { 38 | return parent!!.subscribe(hotStart, fn) 39 | } 40 | } -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/Resource.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("UNCHECKED_CAST") 2 | 3 | package com.minikorp.mini 4 | 5 | /** 6 | * Simple wrapper to map ongoing tasks (network / database) for view implementation. 7 | * 8 | * Similar to kotlin [Result] but with loading and empty state. 9 | */ 10 | open class Resource @PublishedApi internal constructor(val value: Any?) { 11 | 12 | val isSuccess: Boolean get() = !isLoading && !isFailure && !isEmpty 13 | val isEmpty: Boolean get() = value is Empty 14 | val isFailure: Boolean get() = value is Failure 15 | val isLoading: Boolean get() = value is Loading<*> 16 | 17 | internal class Empty { 18 | override fun toString(): String = "Empty()" 19 | } 20 | 21 | @PublishedApi 22 | internal data class Failure(val exception: Throwable?) { 23 | override fun toString(): String = "Failure($exception)" 24 | } 25 | 26 | @PublishedApi 27 | internal data class Loading(val value: U? = null) { 28 | override fun toString(): String = "Loading($value)" 29 | } 30 | 31 | /** 32 | * Get the current value if successful, or null for other cases. 33 | */ 34 | fun getOrNull(): T? = 35 | when { 36 | isSuccess -> value as T? 37 | else -> null 38 | } 39 | 40 | fun exceptionOrNull(): Throwable? = 41 | when (value) { 42 | is Failure -> value.exception 43 | else -> null 44 | } 45 | 46 | companion object { 47 | fun success(value: T): Resource = Resource(value) 48 | fun failure(exception: Throwable? = null): Resource = Resource(Failure(exception)) 49 | fun loading(value: T? = null): Resource = Resource(Loading(value)) 50 | fun empty(): Resource = Resource(Empty()) 51 | 52 | /** Alias for loading */ 53 | fun idle(value: T? = null): Resource = Resource(Loading(value)) 54 | } 55 | 56 | override fun toString(): String { 57 | return value.toString() 58 | } 59 | } 60 | 61 | /** 62 | * An alias for a empty resource. 63 | */ 64 | typealias Task = Resource 65 | 66 | 67 | inline fun Resource.onSuccess(crossinline action: (data: T) -> Unit): Resource { 68 | if (isSuccess) action(value as T) 69 | return this 70 | } 71 | 72 | inline fun Resource.onFailure(crossinline action: (error: Throwable?) -> Unit): Resource { 73 | if (isFailure) action((value as Resource.Failure).exception) 74 | return this 75 | } 76 | 77 | inline fun Resource.onLoading(crossinline action: (data: T?) -> Unit): Resource { 78 | if (isLoading) action((value as Resource.Loading).value) 79 | return this 80 | } 81 | 82 | inline fun Resource.onEmpty(crossinline action: () -> Unit): Resource { 83 | if (isEmpty) action() 84 | return this 85 | } 86 | 87 | /** Alias of [onEmpty] for Task */ 88 | inline fun Task.onIdle(crossinline action: () -> Unit) = onEmpty(action) 89 | 90 | inline fun Resource.map(crossinline transform: (data: T) -> R): Resource { 91 | if (isSuccess) return Resource.success(transform(value as T)) 92 | return Resource(value) 93 | } 94 | 95 | /** All tasks succeeded. */ 96 | fun Iterable>.allSuccessful(): Boolean { 97 | return this.all { it.isSuccess } 98 | } 99 | 100 | /** Any tasks failed. */ 101 | fun Iterable>.anyFailure(): Boolean { 102 | return this.any { it.isFailure } 103 | } 104 | 105 | /** Any task is running. */ 106 | fun Iterable>.anyLoading(): Boolean { 107 | return this.any { it.isLoading } 108 | } -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/StateContainer.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import org.jetbrains.annotations.TestOnly 4 | import java.io.Closeable 5 | import java.lang.reflect.ParameterizedType 6 | 7 | /** 8 | * Common interface for state containers. 9 | */ 10 | interface StateContainer { 11 | 12 | companion object { 13 | /** 14 | * Token to mark a state as not initialized. 15 | */ 16 | object NoState 17 | } 18 | 19 | val state: S 20 | 21 | fun setState(newState: S) 22 | 23 | /** 24 | * Register a observer to state changes. 25 | * 26 | * @return [Closeable] to cancel the subscription. 27 | */ 28 | fun subscribe(hotStart: Boolean = true, fn: (S) -> Unit): Closeable 29 | 30 | /** 31 | * The initial state of the container. By default it will invoke the primary constructor 32 | * of the State type parameter. If this constructor is not accessible provide your own 33 | * implementation of this method. 34 | */ 35 | @Suppress("UNCHECKED_CAST") 36 | fun initialState(): S { 37 | val type = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] 38 | as Class 39 | try { 40 | val constructor = type.getDeclaredConstructor() 41 | constructor.isAccessible = true 42 | return constructor.newInstance() 43 | } catch (e: Exception) { 44 | throw RuntimeException("Missing default no-args constructor for the state $type", e) 45 | } 46 | } 47 | 48 | /** 49 | * Test only method, don't use in app code. 50 | * Will force state change on UI so it can be called from 51 | * espresso thread. 52 | */ 53 | @TestOnly 54 | fun setTestState(s: S) { 55 | if (isAndroid) { 56 | onUiSync { 57 | setState(s) 58 | } 59 | } else { 60 | setState(s) 61 | } 62 | } 63 | } 64 | 65 | -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/Store.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import java.io.Closeable 4 | import java.util.concurrent.CopyOnWriteArrayList 5 | 6 | /** 7 | * Basic state holder. 8 | */ 9 | abstract class Store : Closeable, 10 | StateContainer, 11 | CloseableTracker by DefaultCloseableTracker() { 12 | 13 | class StoreSubscription internal constructor(private val store: Store<*>, 14 | private val fn: Any) : Closeable { 15 | override fun close() { 16 | store.listeners.remove(fn) 17 | } 18 | } 19 | 20 | private var _state: Any? = StateContainer.Companion.NoState 21 | private val listeners = CopyOnWriteArrayList<(S) -> Unit>() 22 | 23 | /** 24 | * Initialize the store after dependency injection is complete. 25 | */ 26 | open fun initialize() { 27 | //No-op 28 | } 29 | 30 | /** 31 | * Set new state and notify listeners, only callable from the main thread. 32 | */ 33 | override fun setState(newState: S) { 34 | assertOnUiThread() 35 | performStateChange(newState) 36 | } 37 | 38 | override fun subscribe(hotStart: Boolean, fn: (S) -> Unit): Closeable { 39 | listeners.add(fn) 40 | if (hotStart) fn(state) 41 | return StoreSubscription(this, fn) 42 | } 43 | 44 | override val state: S 45 | get() { 46 | if (_state === StateContainer.Companion.NoState) { 47 | synchronized(this) { 48 | if (_state === StateContainer.Companion.NoState) { 49 | _state = initialState() 50 | } 51 | } 52 | } 53 | @Suppress("UNCHECKED_CAST") 54 | return _state as S 55 | } 56 | 57 | private fun performStateChange(newState: S) { 58 | //State mutation should to happen on UI thread 59 | if (_state != newState) { 60 | _state = newState 61 | listeners.forEach { 62 | it(newState) 63 | } 64 | } 65 | } 66 | 67 | override fun close() { 68 | listeners.clear() //Remove all listeners 69 | close() 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/StoreFlow.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import kotlinx.coroutines.channels.Channel 4 | import kotlinx.coroutines.flow.* 5 | 6 | 7 | /** 8 | * Map multiple objects into a list to run an effect on any change. 9 | */ 10 | fun Flow.selectMany(vararg mappers: suspend (T) -> R): Flow> { 11 | return this.map { state -> 12 | mappers.map { fn -> fn(state) } 13 | }.distinctUntilChanged() 14 | } 15 | 16 | /** 17 | * Combination of [Flow.map] and [Flow.distinctUntilChanged]. 18 | */ 19 | fun Flow.select(mapper: suspend (T) -> R): Flow { 20 | return this.map { mapper(it) } 21 | .distinctUntilChanged() 22 | } 23 | 24 | /** 25 | * Combination of [Flow.map] and [Flow.distinctUntilChanged] ignoring null values. 26 | */ 27 | fun Flow.selectNotNull(mapper: suspend (T) -> R?): Flow { 28 | return this.map { mapper(it) } 29 | .filterNotNull() 30 | .distinctUntilChanged() 31 | } 32 | 33 | /** 34 | * Emit a value when the filter passes comparing the last emited value and current value. 35 | */ 36 | fun Flow.onEachChange(filter: (prev: T, next: T) -> Boolean, fn: (T) -> Unit): Flow { 37 | return distinctUntilChanged().runningReduce { prev, next -> 38 | if (filter(prev, next)) { 39 | fn(next) 40 | } 41 | next 42 | } 43 | } 44 | 45 | /** 46 | * Emit a value when the value goes `from` from to `to`. 47 | */ 48 | fun Flow.onEachChange(from: T, to: T, fn: (T) -> Unit): Flow { 49 | return onEachChange({ prev, next -> prev == from && next == to }, fn) 50 | } 51 | 52 | /** 53 | * Emit when the value goes `true` from to `false` (it disables). 54 | */ 55 | fun Flow.onEachDisable(fn: (Boolean) -> Unit): Flow { 56 | return onEachChange(from = true, to = false, fn) 57 | } 58 | 59 | /** 60 | * Emit when the value goes `false` from to `true` (it enables). 61 | */ 62 | fun Flow.onEachEnable(fn: (Boolean) -> Unit): Flow { 63 | return onEachChange(from = false, to = true, fn) 64 | } 65 | 66 | 67 | /** 68 | * Return the channel that will emit state changes. 69 | * 70 | * @param hotStart emit current state when starting. 71 | */ 72 | fun StateContainer.channel(hotStart: Boolean = true, 73 | capacity: Int = Channel.BUFFERED): Channel { 74 | val channel = Channel(capacity) 75 | val subscription = subscribe(hotStart) { 76 | channel.offer(it) 77 | } 78 | @Suppress("EXPERIMENTAL_API_USAGE") 79 | channel.invokeOnClose { 80 | subscription.close() 81 | } 82 | return channel 83 | } 84 | 85 | /** 86 | * Return the flow that will emit state changes. 87 | * 88 | * @param hotStart emit current state when starting. 89 | */ 90 | fun StateContainer.flow(hotStart: Boolean = true, capacity: Int = Channel.BUFFERED): Flow { 91 | return channel(hotStart = hotStart, capacity = capacity).receiveAsFlow() 92 | } 93 | -------------------------------------------------------------------------------- /mini-common/src/main/java/com/minikorp/mini/Threading.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import android.os.Handler 4 | import android.os.Looper 5 | import java.util.concurrent.Semaphore 6 | 7 | val uiHandler by lazy { 8 | requireAndroid() 9 | Handler(Looper.getMainLooper()) 10 | } 11 | 12 | fun assertOnUiThread() { 13 | if (!isAndroid) return 14 | if (Looper.myLooper() != Looper.getMainLooper()) { 15 | error("This method can only be called from the main application thread") 16 | } 17 | } 18 | 19 | fun assertOnBgThread() { 20 | if (!isAndroid) return 21 | if (Looper.myLooper() == Looper.getMainLooper()) { 22 | error("This method can only be called from non UI threads") 23 | } 24 | } 25 | 26 | @JvmOverloads 27 | inline fun onUi(delayMs: Long = 0, crossinline block: () -> Unit) { 28 | requireAndroid() 29 | if (delayMs > 0) uiHandler.postDelayed({ block() }, delayMs) 30 | else uiHandler.post { block() } 31 | } 32 | 33 | inline fun onUiSync(crossinline block: () -> T) { 34 | uiHandler.postSync(block) 35 | } 36 | 37 | inline fun Handler.postSync(crossinline block: () -> T) { 38 | requireAndroid() 39 | if (Looper.myLooper() == this.looper) { 40 | block() 41 | } else { 42 | val sem = Semaphore(0) 43 | post { 44 | block() 45 | sem.release() 46 | } 47 | sem.acquireUninterruptibly() 48 | } 49 | } -------------------------------------------------------------------------------- /mini-common/src/test/kotlin/com/minikorp/mini/CompositeCloseableTest.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import org.amshove.kluent.`should be equal to` 4 | import org.junit.Test 5 | import java.io.Closeable 6 | 7 | class CompositeCloseableTest { 8 | 9 | @Test 10 | fun itemsAreClosed() { 11 | val c = CompositeCloseable() 12 | val dummyCloseable = DummyCloseable() 13 | c.add(dummyCloseable) 14 | c.close() 15 | c.close() 16 | 17 | dummyCloseable.closed.`should be equal to`(1) 18 | } 19 | 20 | class DummyCloseable : Closeable { 21 | var closed = 0 22 | override fun close() { 23 | closed++ 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /mini-common/src/test/kotlin/com/minikorp/mini/DispatcherTest.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import org.amshove.kluent.`should be equal to` 4 | import org.junit.Test 5 | 6 | class DispatcherTest { 7 | 8 | @Test 9 | fun `subscriptions are added`() { 10 | val dispatcher = newTestDispatcher() 11 | var called = 0 12 | dispatcher.subscribe { 13 | called++ 14 | } 15 | dispatcher.dispatchBlocking(TestAction()) 16 | called `should be equal to` 1 17 | } 18 | 19 | @Test 20 | fun `order is respected for same priority`() { 21 | val dispatcher = newTestDispatcher() 22 | val calls = ArrayList() 23 | dispatcher.subscribe { 24 | calls.add(0) 25 | } 26 | dispatcher.subscribe { 27 | calls.add(1) 28 | } 29 | dispatcher.dispatchBlocking(TestAction()) 30 | calls[0] `should be equal to` 0 31 | calls[1] `should be equal to` 1 32 | } 33 | 34 | @Test 35 | fun `order is respected for different priority`() { 36 | val dispatcher = newTestDispatcher() 37 | val calls = ArrayList() 38 | dispatcher.subscribe(priority = 10) { 39 | calls.add(0) 40 | } 41 | dispatcher.subscribe(priority = 0) { 42 | calls.add(1) 43 | } 44 | dispatcher.dispatchBlocking(TestAction()) 45 | calls[0] `should be equal to` 1 46 | calls[1] `should be equal to` 0 47 | } 48 | 49 | @Test 50 | fun `disposing registration removes subscription`() { 51 | val dispatcher = newTestDispatcher() 52 | var called = 0 53 | dispatcher.subscribe { 54 | called++ 55 | }.close() 56 | dispatcher.dispatchBlocking(TestAction()) 57 | called `should be equal to` 0 58 | } 59 | 60 | @Test 61 | fun `interceptors are called`() { 62 | val dispatcher = newTestDispatcher() 63 | var called = 0 64 | val interceptor = object : Middleware { 65 | override suspend fun intercept(action: Any, chain: Chain): Any { 66 | called++ 67 | return chain.proceed(action) 68 | } 69 | } 70 | dispatcher.addMiddleware(interceptor) 71 | dispatcher.dispatchBlocking(TestAction()) 72 | called `should be equal to` 1 73 | } 74 | } -------------------------------------------------------------------------------- /mini-common/src/test/kotlin/com/minikorp/mini/LoggerMiddlewareTest.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import org.amshove.kluent.`should not be empty` 4 | import org.junit.Test 5 | 6 | class LoggerMiddlewareTest { 7 | 8 | @Test 9 | fun `logs are printed`() { 10 | val store = SampleStore() 11 | val dispatcher = newTestDispatcher() 12 | dispatcher.subscribe { 13 | store.setState("Action sent") 14 | } 15 | 16 | val out = StringBuilder() 17 | dispatcher.addMiddleware(LoggerMiddleware(listOf(store), 18 | logger = { priority, tag, msg -> 19 | println("[$priority][$tag] $msg") 20 | out.append(priority).append(tag).append(msg) 21 | })) 22 | dispatcher.dispatchBlocking(TestAction()) 23 | out.toString().`should not be empty`() 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /mini-common/src/test/kotlin/com/minikorp/mini/ResourceTest.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import org.amshove.kluent.`should be equal to` 4 | import org.amshove.kluent.`should be null` 5 | import org.amshove.kluent.`should equal` 6 | import org.amshove.kluent.`should not be null` 7 | import org.junit.Before 8 | import org.junit.Test 9 | 10 | class ResourceTest { 11 | 12 | private var successValue: Any? = null 13 | private var errorValue: Any? = null 14 | private var loadingValue: Any? = null 15 | private var emptyValue: Any? = null 16 | 17 | @Before 18 | fun before() { 19 | successValue = null 20 | errorValue = null 21 | loadingValue = null 22 | emptyValue = null 23 | } 24 | 25 | private fun check(resource: Resource) { 26 | var called = 0 27 | resource 28 | .onSuccess { 29 | called++ 30 | successValue = it 31 | }.onFailure { 32 | called++ 33 | errorValue = it 34 | }.onLoading { 35 | called++ 36 | loadingValue = it 37 | }.onEmpty { 38 | called++ 39 | emptyValue = true 40 | } 41 | called `should be equal to` 1 42 | } 43 | 44 | @Test 45 | fun `success calls`() { 46 | check(Resource.success("abc")) 47 | successValue `should equal` "abc" 48 | } 49 | 50 | @Test 51 | fun isEmpty() { 52 | check(Resource.empty()) 53 | emptyValue `should equal` true 54 | } 55 | 56 | @Test 57 | fun isFailure() { 58 | val ex = RuntimeException("ABC") 59 | check(Resource.failure(ex)) 60 | errorValue `should equal` ex 61 | } 62 | 63 | @Test 64 | fun isLoading() { 65 | check(Resource.loading("abc")) 66 | loadingValue `should equal` "abc" 67 | } 68 | 69 | @Test 70 | fun getOrNull() { 71 | Resource.empty().getOrNull().`should be null`() 72 | Resource.success("abc").getOrNull().`should not be null`() 73 | } 74 | 75 | @Test 76 | fun exceptionOrNull() { 77 | Resource.failure(RuntimeException()).exceptionOrNull().`should not be null`() 78 | Resource.success("abc").exceptionOrNull().`should be null`() 79 | } 80 | 81 | @Test 82 | fun map() { 83 | Resource.success("abc") 84 | .map { 0 } 85 | .getOrNull()?.`should be equal to`(0) 86 | 87 | Resource.failure() 88 | .map { 0 } 89 | .getOrNull()?.`should be null`() 90 | } 91 | } -------------------------------------------------------------------------------- /mini-common/src/test/kotlin/com/minikorp/mini/SampleStore.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | 4 | class SampleStore : Store() { 5 | 6 | companion object { 7 | const val INITIAL_STATE = "initial" 8 | } 9 | 10 | override fun initialState(): String = INITIAL_STATE 11 | } -------------------------------------------------------------------------------- /mini-common/src/test/kotlin/com/minikorp/mini/StoreFlowTest.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.flow.* 5 | import org.amshove.kluent.`should be equal to` 6 | import org.amshove.kluent.`should equal` 7 | import org.junit.Test 8 | import java.util.concurrent.Executors 9 | 10 | class StoreFlowTest { 11 | 12 | private val testScope = 13 | CoroutineScope(Executors.newScheduledThreadPool(1).asCoroutineDispatcher()) 14 | 15 | @Test(timeout = 1000) 16 | fun `flow sends initial state on collection`(): Unit = runBlocking { 17 | val store = SampleStore() 18 | var observedState = SampleStore.INITIAL_STATE 19 | 20 | val job = store.flow(hotStart = false) 21 | .onEach { observedState = it } 22 | .take(1) 23 | .launchIn(testScope) 24 | 25 | store.setState("abc") //Set before collect 26 | 27 | job.join() 28 | observedState `should be equal to` "abc" 29 | Unit 30 | } 31 | 32 | @Test(timeout = 1000) 33 | fun `flow sends updates to all`(): Unit = runBlocking { 34 | val store = SampleStore() 35 | val called = intArrayOf(0, 0) 36 | 37 | val job1 = store.flow() 38 | .onEach { called[0]++ } 39 | .take(2) 40 | .launchIn(testScope) 41 | 42 | val job2 = store.flow() 43 | .onEach { called[1]++ } 44 | .take(2) 45 | .launchIn(testScope) 46 | 47 | store.setState("abc") 48 | 49 | job1.join() 50 | job2.join() 51 | 52 | //Called two times, on for initial state, one for updated stated 53 | called.`should equal`(intArrayOf(2, 2)) 54 | Unit 55 | } 56 | 57 | @Test(timeout = 1000) 58 | fun `channel sends updates`(): Unit = runBlocking { 59 | val store = SampleStore() 60 | var observedState = "" 61 | val scope = CoroutineScope(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) 62 | val job = scope.launch { 63 | observedState = store.channel().receive() 64 | } 65 | store.setState("abc") 66 | job.join() 67 | observedState `should be equal to` "abc" 68 | Unit 69 | } 70 | 71 | @Test(timeout = 1000) 72 | fun `flow closes`(): Unit = runBlocking { 73 | val store = SampleStore() 74 | var observedState = store.state 75 | 76 | val scope = CoroutineScope(Job()) 77 | store.flow() 78 | .onEach { 79 | observedState = it 80 | } 81 | .launchIn(scope) 82 | 83 | scope.cancel() //Cancel the scope 84 | store.setState("abc") 85 | 86 | observedState `should be equal to` SampleStore.INITIAL_STATE 87 | Unit 88 | } 89 | } -------------------------------------------------------------------------------- /mini-common/src/test/kotlin/com/minikorp/mini/StoreTest.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import org.amshove.kluent.`should be equal to` 4 | import org.junit.Test 5 | 6 | class StoreTest { 7 | 8 | @Test 9 | fun `state is updated`() { 10 | val store = SampleStore() 11 | store.setState("abc") 12 | store.state `should be equal to` "abc" 13 | } 14 | 15 | @Test 16 | fun `observers are called`() { 17 | val store = SampleStore() 18 | var state = "" 19 | store.subscribe { 20 | state = it 21 | } 22 | store.setState("abc") 23 | state `should be equal to` "abc" 24 | } 25 | 26 | @Test 27 | fun `initial state is sent on subscribe`() { 28 | val store = SampleStore() 29 | var state = "" 30 | store.subscribe { 31 | state = it 32 | } 33 | state `should be equal to` "initial" 34 | } 35 | 36 | @Test 37 | fun `observers are removed on close`() { 38 | val store = SampleStore() 39 | var state = "" 40 | val closeable = store.subscribe(hotStart = false) { 41 | state = it 42 | } 43 | closeable.close() 44 | store.setState("abc") 45 | state `should be equal to` "" 46 | } 47 | } -------------------------------------------------------------------------------- /mini-common/src/test/kotlin/com/minikorp/mini/TestAction.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | 4 | @Action 5 | data class TestAction(val value: String = "dummy") -------------------------------------------------------------------------------- /mini-common/src/test/kotlin/com/minikorp/mini/TestDispatcher.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import com.minikorp.mini.Dispatcher 4 | import kotlin.coroutines.EmptyCoroutineContext 5 | import kotlin.reflect.KClass 6 | import kotlin.reflect.jvm.jvmErasure 7 | 8 | fun newTestDispatcher(): Dispatcher { 9 | return Dispatcher().apply { 10 | actionTypeMap = newReflectiveMap() 11 | } 12 | } 13 | 14 | private fun reflectActionTypes(type: KClass<*>, depth: Int = 0): List { 15 | return type.supertypes 16 | .asSequence() 17 | .map { (it.jvmErasure.java as Class<*>).kotlin } 18 | .map { reflectActionTypes(it, depth + 1) } 19 | .flatten() 20 | .plus(ReflectedType(type, depth)) 21 | .toList() 22 | } 23 | 24 | private class ReflectedType(val clazz: KClass<*>, val depth: Int) 25 | 26 | private fun newReflectiveMap(): Map, List>> { 27 | return object : Map, List>> { 28 | private val genericTypes = listOf(Object::class) 29 | private val map = HashMap, List>>() 30 | override val entries: Set, List>>> = map.entries 31 | override val keys: Set> = map.keys 32 | override val size: Int = map.size 33 | override val values: Collection>> = map.values 34 | override fun containsKey(key: KClass<*>): Boolean = map.containsKey(key) 35 | override fun containsValue(value: List>): Boolean = map.containsValue(value) 36 | override fun isEmpty(): Boolean = map.isEmpty() 37 | override fun get(key: KClass<*>): List> { 38 | return map.getOrPut(key) { 39 | reflectActionTypes(key) 40 | .asSequence() 41 | .sortedBy { it.depth } 42 | .map { it.clazz } 43 | .filter { it !in genericTypes } 44 | .toList() 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /mini-processor-test/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /mini-processor-test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | kotlin("kapt") 4 | } 5 | 6 | 7 | repositories { 8 | mavenCentral() 9 | } 10 | 11 | dependencies { 12 | implementation(project(":mini-common")) 13 | kapt(project(":mini-processor")) 14 | 15 | testImplementation("junit:junit:4.12") 16 | testImplementation("org.amshove.kluent:kluent:1.44") 17 | } 18 | 19 | tasks { 20 | compileKotlin { 21 | kotlinOptions.jvmTarget = "1.8" 22 | } 23 | compileTestKotlin { 24 | kotlinOptions.jvmTarget = "1.8" 25 | } 26 | } -------------------------------------------------------------------------------- /mini-processor-test/src/main/java/com/minikorp/mini/test/AnyAction.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini.test 2 | 3 | import com.minikorp.mini.Action 4 | 5 | @Action 6 | data class AnyAction(val value: String) -------------------------------------------------------------------------------- /mini-processor-test/src/main/java/com/minikorp/mini/test/BasicState.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini.test 2 | 3 | data class BasicState(val value: String = "initial") -------------------------------------------------------------------------------- /mini-processor-test/src/main/java/com/minikorp/mini/test/ReducersStore.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini.test 2 | 3 | import com.minikorp.mini.Reducer 4 | import com.minikorp.mini.Store 5 | import kotlinx.coroutines.yield 6 | 7 | 8 | class ReducersStore : Store() { 9 | 10 | companion object { 11 | @Reducer 12 | fun staticImpureReducer(action: AnyAction) { 13 | 14 | } 15 | 16 | @Reducer 17 | suspend fun staticSuspendingImpureReducer(action: AnyAction) { 18 | yield() 19 | } 20 | 21 | @Reducer 22 | fun staticPureReducer(state: BasicState, action: AnyAction): BasicState { 23 | return state.copy(value = action.value) 24 | } 25 | 26 | @Reducer 27 | suspend fun staticSuspendingPureReducer(state: BasicState, action: AnyAction): BasicState { 28 | yield() 29 | return state.copy(value = action.value) 30 | } 31 | } 32 | 33 | @Reducer 34 | fun impureReducer(action: AnyAction) { 35 | 36 | } 37 | 38 | @Reducer 39 | suspend fun impureSuspendingReducer(action: AnyAction) { 40 | yield() 41 | } 42 | 43 | @Reducer 44 | fun pureReducer(state: BasicState, action: AnyAction): BasicState { 45 | return state.copy(value = action.value) 46 | } 47 | 48 | @Reducer 49 | suspend fun pureSuspendingReducer(state: BasicState, action: AnyAction): BasicState { 50 | yield() 51 | return state.copy(value = action.value) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /mini-processor-test/src/test/java/com/minikorp/mini/test/ReducersStoreTest.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini.test 2 | 3 | import com.minikorp.mini.Dispatcher 4 | import com.minikorp.mini.Mini 5 | import kotlinx.coroutines.runBlocking 6 | import org.amshove.kluent.`should equal` 7 | import org.junit.Test 8 | 9 | 10 | internal class ReducersStoreTest { 11 | 12 | private val store = ReducersStore() 13 | private val dispatcher = Dispatcher().apply { 14 | Mini.link(this, listOf(store)) 15 | } 16 | 17 | @Test 18 | fun `pure reducers are called`() { 19 | runBlocking { 20 | dispatcher.dispatch(AnyAction("changed")) 21 | store.state.value.`should equal`("changed") 22 | } 23 | } 24 | 25 | @Test 26 | fun `pure static reducers are called`() { 27 | runBlocking { 28 | dispatcher.dispatch(AnyAction("changed")) 29 | store.state.value.`should equal`("changed") 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /mini-processor/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /mini-processor/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | } 4 | 5 | dependencies { 6 | implementation(project(":mini-common")) 7 | implementation("com.google.auto:auto-common:0.10") 8 | implementation("com.squareup:kotlinpoet:1.7.2") 9 | implementation("net.ltgt.gradle.incap:incap-processor:0.2") 10 | implementation("net.ltgt.gradle.incap:incap:0.2") 11 | testImplementation("junit:junit:4.13.1") 12 | testImplementation("com.google.testing.compile:compile-testing:0.15") 13 | } -------------------------------------------------------------------------------- /mini-processor/src/main/java/com/minikorp/mini/ActionTypesGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import com.squareup.kotlinpoet.* 4 | import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy 5 | import javax.lang.model.element.Element 6 | import javax.lang.model.element.Modifier 7 | import javax.lang.model.type.TypeMirror 8 | import kotlin.reflect.KClass 9 | 10 | object ActionTypesGenerator { 11 | fun generate(container: TypeSpec.Builder, elements: Set) { 12 | 13 | val actionModels = elements.filter { Modifier.ABSTRACT !in it.modifiers } 14 | .map { ActionModel(it) } 15 | 16 | container.apply { 17 | val anyClassTypeName = KClass::class.asTypeName().parameterizedBy(STAR) 18 | val listTypeName = List::class.asTypeName().parameterizedBy(anyClassTypeName) 19 | val mapType = Map::class 20 | .asClassName() 21 | .parameterizedBy(anyClassTypeName, listTypeName) 22 | 23 | val prop = PropertySpec.builder(Mini::actionTypes.name, mapType) 24 | .addModifiers(KModifier.OVERRIDE) 25 | .initializer(CodeBlock.builder() 26 | .add("mapOf(\n⇥") 27 | .apply { 28 | actionModels.forEach { actionModel -> 29 | val comma = if (actionModel != actionModels.last()) "," else "" 30 | add("«") 31 | add("%T::class to ", actionModel.typeName) 32 | add(actionModel.listOfSupertypesCodeBlock()) 33 | add(comma) 34 | add("\n»") 35 | } 36 | } 37 | .add("⇤)") 38 | .build()) 39 | addProperty(prop.build()) 40 | }.build() 41 | } 42 | } 43 | 44 | class ActionModel(element: Element) { 45 | private val type = element.asType() 46 | private val javaObject = ClassName.bestGuess("java.lang.Object") 47 | 48 | val typeName = type.asTypeName() 49 | private val superTypes = collectTypes(type) 50 | .sortedBy { it.depth } 51 | .filter { it.typeName != javaObject } 52 | .map { it.typeName } 53 | .plus(ANY) 54 | 55 | fun listOfSupertypesCodeBlock(): CodeBlock { 56 | val format = superTypes.joinToString(",\n") { "%T::class" } 57 | val args = superTypes.toTypedArray() 58 | return CodeBlock.of("listOf($format)", *args) 59 | } 60 | 61 | private fun collectTypes(mirror: TypeMirror, depth: Int = 0): Set { 62 | //We want to add by depth 63 | val superTypes = typeUtils.directSupertypes(mirror).toSet() 64 | .map { collectTypes(it, depth + 1) } 65 | .flatten() 66 | return setOf(ActionSuperType(mirror.asTypeName(), depth)) + superTypes 67 | } 68 | 69 | class ActionSuperType(val typeName: TypeName, val depth: Int) { 70 | override fun equals(other: Any?): Boolean { 71 | if (this === other) return true 72 | if (javaClass != other?.javaClass) return false 73 | other as ActionSuperType 74 | if (typeName != other.typeName) return false 75 | return true 76 | } 77 | 78 | override fun hashCode(): Int { 79 | return typeName.hashCode() 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /mini-processor/src/main/java/com/minikorp/mini/MiniProcessor.java: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini; 2 | 3 | import net.ltgt.gradle.incap.IncrementalAnnotationProcessor; 4 | import net.ltgt.gradle.incap.IncrementalAnnotationProcessorType; 5 | 6 | import java.util.Set; 7 | 8 | import javax.annotation.processing.AbstractProcessor; 9 | import javax.annotation.processing.ProcessingEnvironment; 10 | import javax.annotation.processing.RoundEnvironment; 11 | import javax.annotation.processing.SupportedOptions; 12 | import javax.lang.model.SourceVersion; 13 | import javax.lang.model.element.TypeElement; 14 | 15 | /** 16 | * Dummy Java wrapper that delegates to Kotlin one 17 | */ 18 | @IncrementalAnnotationProcessor(IncrementalAnnotationProcessorType.AGGREGATING) 19 | @SupportedOptions("kapt.kotlin.generated") 20 | public class MiniProcessor extends AbstractProcessor { 21 | 22 | private final Processor processor = new Processor(); 23 | 24 | @Override 25 | public synchronized void init(ProcessingEnvironment processingEnvironment) { 26 | super.init(processingEnvironment); 27 | processor.init(processingEnvironment); 28 | } 29 | 30 | @Override 31 | public Set getSupportedAnnotationTypes() { 32 | return processor.getSupportedAnnotationTypes(); 33 | } 34 | 35 | @Override 36 | public SourceVersion getSupportedSourceVersion() { 37 | return processor.getSupportedSourceVersion(); 38 | } 39 | 40 | @Override 41 | public boolean process(Set set, RoundEnvironment roundEnvironment) { 42 | return processor.process(roundEnvironment); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /mini-processor/src/main/java/com/minikorp/mini/Processor.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import com.squareup.kotlinpoet.ClassName 4 | import com.squareup.kotlinpoet.FileSpec 5 | import com.squareup.kotlinpoet.TypeSpec 6 | import javax.annotation.processing.ProcessingEnvironment 7 | import javax.annotation.processing.RoundEnvironment 8 | import javax.lang.model.SourceVersion 9 | 10 | class Processor { 11 | 12 | val supportedAnnotationTypes: MutableSet = mutableSetOf( 13 | Reducer::class.java, Action::class.java) 14 | .map { it.canonicalName }.toMutableSet() 15 | val supportedSourceVersion: SourceVersion = SourceVersion.RELEASE_8 16 | 17 | fun init(environment: ProcessingEnvironment) { 18 | env = environment 19 | typeUtils = env.typeUtils 20 | elementUtils = env.elementUtils 21 | } 22 | 23 | fun process(roundEnv: RoundEnvironment): Boolean { 24 | 25 | val roundActions = roundEnv.getElementsAnnotatedWith(Action::class.java) 26 | val roundReducers = roundEnv.getElementsAnnotatedWith(Reducer::class.java) 27 | 28 | if (roundActions.isEmpty()) return false 29 | 30 | val className = ClassName.bestGuess(DISPATCHER_FACTORY_CLASS_NAME) 31 | val file = FileSpec.builder(className.packageName, className.simpleName) 32 | val container = TypeSpec.objectBuilder(className) 33 | .addKdoc("Automatically generated, do not edit.\n") 34 | .superclass(Mini::class) 35 | 36 | //Get non-abstract actions 37 | try { 38 | ActionTypesGenerator.generate(container, roundActions) 39 | ReducersGenerator.generate(container, roundReducers) 40 | } catch (e: Throwable) { 41 | if (e !is ProcessorException) { 42 | logError( 43 | "Compiler crashed, open an issue please!\n" + 44 | " ${e.stackTraceString()}" 45 | ) 46 | } 47 | } 48 | 49 | file.addType(container.build()) 50 | file.build().writeToFile(sourceElements = ((roundActions + roundReducers).toTypedArray())) 51 | 52 | return true 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /mini-processor/src/main/java/com/minikorp/mini/ProcessorUtils.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import com.squareup.kotlinpoet.ClassName 4 | import com.squareup.kotlinpoet.FileSpec 5 | import com.squareup.kotlinpoet.TypeName 6 | import com.squareup.kotlinpoet.asTypeName 7 | import java.io.ByteArrayOutputStream 8 | import java.io.PrintStream 9 | import javax.annotation.processing.ProcessingEnvironment 10 | import javax.lang.model.element.Element 11 | import javax.lang.model.element.ElementKind 12 | import javax.lang.model.element.ExecutableElement 13 | import javax.lang.model.element.TypeElement 14 | import javax.lang.model.type.DeclaredType 15 | import javax.lang.model.type.TypeMirror 16 | import javax.lang.model.util.Elements 17 | import javax.lang.model.util.Types 18 | import javax.tools.Diagnostic 19 | import javax.tools.StandardLocation 20 | 21 | lateinit var env: ProcessingEnvironment 22 | lateinit var elementUtils: Elements 23 | lateinit var typeUtils: Types 24 | 25 | fun Throwable.stackTraceString(): String { 26 | val out = ByteArrayOutputStream() 27 | printStackTrace(PrintStream(out)) 28 | return out.toString() 29 | } 30 | 31 | val Element.isMethod: Boolean get() = this.kind == ElementKind.METHOD 32 | val Element.isClass: Boolean get() = this.kind == ElementKind.CLASS 33 | 34 | fun ExecutableElement.isSuspending(): Boolean { 35 | return parameters.last().asType().toString().startsWith("kotlin.coroutines.Continuation") 36 | } 37 | 38 | fun TypeMirror.asElement(): Element = asElementOrNull()!! 39 | fun TypeMirror.asElementOrNull(): Element? = typeUtils.asElement(this) 40 | 41 | fun TypeMirror.getSupertypes(): MutableList = typeUtils.directSupertypes(this) 42 | fun TypeMirror.getAllSuperTypes(depth: Int = 0): Set { 43 | //We want to add by depth 44 | val superTypes = typeUtils.directSupertypes(this).toSet() 45 | .map { it.getAllSuperTypes(depth + 1) } 46 | .flatten() 47 | return setOf(this) + superTypes 48 | } 49 | 50 | fun TypeMirror.qualifiedName(): String = toString() 51 | fun Element.qualifiedName(): String = toString() 52 | fun Element.getPackageName(): String { 53 | //xxx.xxx.simpleName 54 | //xxx.xxx 55 | val fullName = toString() 56 | val simpleNameLength = simpleName.toString().count() + 1 //Remove the last dot 57 | return fullName.dropLast(simpleNameLength) 58 | } 59 | 60 | infix fun TypeMirror.assignableTo(base: TypeMirror): Boolean = typeUtils.isAssignable(base, this) 61 | infix fun TypeMirror.isSubtypeOf(base: TypeMirror): Boolean = typeUtils.isSubtype(this, base) 62 | 63 | fun Element.asTypeElement(): TypeElement = asTypeElementOrNull()!! 64 | fun Element.asTypeElementOrNull(): TypeElement? = this as? TypeElement 65 | 66 | fun TypeMirror.asTypeElement(): TypeElement = asTypeElementOrNull()!! 67 | fun TypeMirror.asTypeElementOrNull(): TypeElement? = asElementOrNull()?.asTypeElementOrNull() 68 | 69 | fun TypeMirror.asDeclaredType(): DeclaredType = asDeclaredTypeOrNull()!! 70 | fun TypeMirror.asDeclaredTypeOrNull(): DeclaredType? = this as? DeclaredType 71 | 72 | infix fun TypeMirror.isSameType(other: TypeMirror?): Boolean { 73 | if (other == null) return false 74 | return typeUtils.isSameType(this, other) 75 | } 76 | 77 | fun Element.getSuperClass() = asTypeElement().superclass.asElement() 78 | fun Element.getSuperClassTypeParameter(position: Int) = asTypeElement() 79 | .superclass.asDeclaredType().typeArguments[position].asElement() 80 | 81 | 82 | class ProcessorException : IllegalStateException() 83 | 84 | fun compilePrecondition(check: Boolean, 85 | message: String, 86 | element: Element? = null) { 87 | if (!check) { 88 | logError(message, element) 89 | throw ProcessorException() 90 | } 91 | } 92 | 93 | fun logError(message: String, element: Element? = null) { 94 | logMessage(Diagnostic.Kind.ERROR, message, element) 95 | } 96 | 97 | fun logWarning(message: String, element: Element? = null) { 98 | logMessage(Diagnostic.Kind.MANDATORY_WARNING, message, element) 99 | } 100 | 101 | fun logMessage(kind: Diagnostic.Kind, message: String, element: Element? = null) { 102 | env.messager.printMessage(kind, "\n" + message, element) 103 | } 104 | 105 | //KotlinPoet utils 106 | 107 | fun FileSpec.writeToFile(vararg sourceElements: Element) { 108 | val kotlinFileObject = env.filer 109 | .createResource(StandardLocation.SOURCE_OUTPUT, packageName, "$name.kt", *sourceElements) 110 | val openWriter = kotlinFileObject.openWriter() 111 | writeTo(openWriter) 112 | openWriter.close() 113 | } 114 | 115 | /** 116 | * Map [java.lang.Object] to [Any] 117 | */ 118 | fun TypeName.safeAnyTypeName(): TypeName { 119 | return if (this is ClassName && this == ClassName("java.lang", "Object")) { 120 | Any::class.asTypeName() 121 | } else { 122 | this 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /mini-processor/src/main/java/com/minikorp/mini/ReducersGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.minikorp.mini 2 | 3 | import com.squareup.kotlinpoet.* 4 | import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy 5 | import java.io.Closeable 6 | import javax.lang.model.element.Element 7 | import javax.lang.model.element.ElementKind 8 | import javax.lang.model.element.ExecutableElement 9 | import javax.lang.model.element.Modifier 10 | import javax.lang.model.type.DeclaredType 11 | 12 | object ReducersGenerator { 13 | 14 | fun generate(container: TypeSpec.Builder, elements: Set) { 15 | val reducers = elements.map { ReducerModel(it) } 16 | .groupBy { it.container.typeName } 17 | 18 | val whenBlock = CodeBlock.builder() 19 | .addStatement("val c = %T()", CompositeCloseable::class) 20 | .addStatement("when (container) {").indent() 21 | .apply { 22 | reducers.forEach { (containerName, reducerFunctions) -> 23 | addStatement("is %T -> {", containerName).indent() 24 | reducerFunctions.forEach { function -> 25 | add("c.add(dispatcher.subscribe<%T>(priority=%L) { action -> ", 26 | function.actionTypeName, 27 | function.priority 28 | ) 29 | add(function.generateCallBlock("container", "action")) 30 | addStatement("})") 31 | } 32 | unindent().addStatement("}") 33 | } 34 | } 35 | .unindent() 36 | .addStatement("}") //Close when 37 | .addStatement("return c") 38 | .build() 39 | 40 | val typeParam = TypeVariableName("T") 41 | val oneParam = StateContainer::class.asTypeName().parameterizedBy(typeParam) 42 | 43 | val registerOneFn = FunSpec.builder("subscribe") 44 | .addModifiers(KModifier.OVERRIDE) 45 | .addTypeVariable(typeParam) 46 | .addParameter("dispatcher", Dispatcher::class) 47 | .addParameter("container", oneParam) 48 | .returns(Closeable::class) 49 | .addCode(whenBlock) 50 | .build() 51 | 52 | container.addFunction(registerOneFn) 53 | } 54 | } 55 | 56 | class ReducerModel(element: Element) { 57 | private val function = element as ExecutableElement 58 | 59 | private val isPure: Boolean 60 | private val isSuspending: Boolean 61 | 62 | val container: ContainerModel 63 | val priority = element.getAnnotation(Reducer::class.java).priority 64 | 65 | val actionTypeName: TypeName 66 | val returnTypeName: TypeName 67 | 68 | init { 69 | compilePrecondition( 70 | check = function.modifiers.contains(Modifier.PUBLIC), 71 | message = "Reducer functions must be public.", 72 | element = element 73 | ) 74 | 75 | isSuspending = function.isSuspending() 76 | container = ContainerModel(element.enclosingElement) 77 | val parameters: List 78 | 79 | if (isSuspending) { 80 | //Hacky check to get return type of a kotlin continuation 81 | val continuationTypeParameter = (function.parameters.last().asType() as DeclaredType) 82 | .typeArguments[0].asTypeName() as WildcardTypeName 83 | returnTypeName = continuationTypeParameter.inTypes.first() 84 | parameters = function.parameters.dropLast(1).map { it.asType().asTypeName() } 85 | } else { 86 | returnTypeName = function.returnType.asTypeName() 87 | parameters = function.parameters.map { it.asType().asTypeName() } 88 | } 89 | 90 | if (returnTypeName == UNIT) { 91 | isPure = false 92 | actionTypeName = function.parameters[0].asType().asTypeName().safeAnyTypeName() 93 | compilePrecondition( 94 | check = parameters.size == 1, 95 | message = "Expected exactly one action parameter", 96 | element = element 97 | ) 98 | } else { 99 | isPure = true 100 | compilePrecondition( 101 | check = parameters.size == 2, 102 | message = "Expected exactly two parameters, ${container.stateTypeName} and action", 103 | element = element 104 | ) 105 | val stateTypeName = parameters[0] 106 | actionTypeName = parameters[1].safeAnyTypeName() 107 | 108 | compilePrecondition( 109 | check = stateTypeName == container.stateTypeName, 110 | message = "Expected ${container.stateTypeName} as first state parameter", 111 | element = element 112 | ) 113 | 114 | compilePrecondition( 115 | check = returnTypeName == container.stateTypeName, 116 | message = "Expected ${container.stateTypeName} as return value", 117 | element = element 118 | ) 119 | } 120 | 121 | } 122 | 123 | fun generateCallBlock(containerParam: String, actionParam: String): CodeBlock { 124 | 125 | val receiver = if (container.isStatic) { 126 | CodeBlock.of("%T.${function.simpleName}", container.typeName) 127 | } else { 128 | CodeBlock.of("${containerParam}.${function.simpleName}") 129 | } 130 | 131 | val call = if (isPure) { 132 | CodeBlock.of("(${containerParam}.state, $actionParam)") 133 | } else { 134 | CodeBlock.of("($actionParam)") 135 | } 136 | 137 | return if (isPure) { 138 | CodeBlock.builder() 139 | .add("${containerParam}.setState(") 140 | .add(receiver) 141 | .add(call) 142 | .add(")") 143 | .build() 144 | } else { 145 | CodeBlock.builder() 146 | .add(receiver) 147 | .add(call) 148 | .build() 149 | } 150 | } 151 | } 152 | 153 | class ContainerModel(element: Element) { 154 | val typeName: TypeName 155 | val stateTypeName: TypeName 156 | val isStatic: Boolean 157 | 158 | init { 159 | compilePrecondition( 160 | check = element.kind == ElementKind.CLASS, 161 | message = "Reducers must be declared inside StoreContainer classes", 162 | element = element 163 | ) 164 | 165 | val mainTypeName = element.asType().asTypeName() 166 | val parent = element.enclosingElement 167 | 168 | isStatic = if (parent != null && parent.kind == ElementKind.CLASS) { 169 | val parentTypeName = parent.asType().asTypeName() 170 | "$parentTypeName.Companion" == mainTypeName.toString() 171 | } else { 172 | false 173 | } 174 | 175 | val realContainer = if (isStatic) element.enclosingElement else element 176 | typeName = realContainer.asType().asTypeName() 177 | 178 | val superTypes = realContainer.asType().getAllSuperTypes().map { it.asTypeName() } 179 | val stateContainerType = superTypes 180 | .find { it is ParameterizedTypeName && it.rawType == StateContainer::class.asTypeName() } 181 | compilePrecondition( 182 | check = stateContainerType != null, 183 | message = "Reducers must be declared in a StateContainer", 184 | element = element 185 | ) 186 | 187 | stateTypeName = (stateContainerType!! as ParameterizedTypeName).typeArguments[0] 188 | } 189 | 190 | override fun toString(): String { 191 | return stateTypeName.toString() 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /mini-processor/src/main/resources/META-INF/gradle/incremental.annotation.processors: -------------------------------------------------------------------------------- 1 | com.minikorp.mini.MiniProcessor,aggregating -------------------------------------------------------------------------------- /mini-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor: -------------------------------------------------------------------------------- 1 | com.minikorp.mini.MiniProcessor -------------------------------------------------------------------------------- /mini.codestyle.json: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 10 | 11 | 12 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | xmlns:android 21 | 22 | ^$ 23 | 24 | 25 | 26 |
27 |
28 | 29 | 30 | 31 | xmlns:.* 32 | 33 | ^$ 34 | 35 | 36 | BY_NAME 37 | 38 |
39 |
40 | 41 | 42 | 43 | .*:id 44 | 45 | http://schemas.android.com/apk/res/android 46 | 47 | 48 | 49 |
50 |
51 | 52 | 53 | 54 | .*:name 55 | 56 | http://schemas.android.com/apk/res/android 57 | 58 | 59 | 60 |
61 |
62 | 63 | 64 | 65 | name 66 | 67 | ^$ 68 | 69 | 70 | 71 |
72 |
73 | 74 | 75 | 76 | style 77 | 78 | ^$ 79 | 80 | 81 | 82 |
83 |
84 | 85 | 86 | 87 | .* 88 | 89 | ^$ 90 | 91 | 92 | BY_NAME 93 | 94 |
95 |
96 | 97 | 98 | 99 | .* 100 | 101 | http://schemas.android.com/apk/res/android 102 | 103 | 104 | ANDROID_ATTRIBUTE_ORDER 105 | 106 |
107 |
108 | 109 | 110 | 111 | .* 112 | 113 | .* 114 | 115 | 116 | BY_NAME 117 | 118 |
119 |
120 |
121 |
122 | 123 | 135 |
-------------------------------------------------------------------------------- /sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /sample/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-kapt' 5 | } 6 | 7 | android { 8 | compileSdkVersion 30 9 | buildToolsVersion "30.0.3" 10 | 11 | defaultConfig { 12 | applicationId "com.example.androidsample" 13 | minSdkVersion 16 14 | targetSdkVersion 30 15 | versionCode 1 16 | versionName "1.0" 17 | 18 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 25 | } 26 | } 27 | compileOptions { 28 | sourceCompatibility JavaVersion.VERSION_1_8 29 | targetCompatibility JavaVersion.VERSION_1_8 30 | } 31 | kotlinOptions { 32 | jvmTarget = '1.8' 33 | } 34 | } 35 | 36 | dependencies { 37 | 38 | implementation "org.jetbrains.kotlin:kotlin-stdlib:1.4.21" 39 | implementation 'androidx.core:core-ktx:1.3.2' 40 | implementation 'androidx.appcompat:appcompat:1.2.0' 41 | implementation 'com.google.android.material:material:1.2.1' 42 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 43 | implementation "androidx.activity:activity-ktx:1.1.0" 44 | implementation "androidx.fragment:fragment-ktx:1.2.5" 45 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" 46 | implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' 47 | kapt project(":mini-processor") 48 | implementation project(":mini-android") 49 | 50 | testImplementation 'junit:junit:4.13.1' 51 | implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-rc01' 52 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 53 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 54 | } -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/example/androidsample/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.androidsample 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.example.androidsample", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /sample/src/main/java/com/example/androidsample/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.androidsample 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import android.widget.ProgressBar 6 | import android.widget.TextView 7 | import android.widget.Toast 8 | import androidx.activity.viewModels 9 | import androidx.lifecycle.SavedStateHandle 10 | import com.minikorp.mini.* 11 | import com.minikorp.mini.android.FluxActivity 12 | import com.minikorp.mini.android.FluxViewModel 13 | import kotlinx.coroutines.delay 14 | import kotlinx.coroutines.flow.onEach 15 | import kotlinx.coroutines.launch 16 | import java.io.Closeable 17 | import java.io.Serializable 18 | 19 | val dispatcher = Dispatcher() 20 | 21 | data class State( 22 | val text: String = "0", 23 | val loading: Boolean = false 24 | ) : Serializable 25 | 26 | @Action 27 | data class SetLoadingAction(val loading: Boolean) 28 | 29 | @Action 30 | data class SetTextAction(val text: String) 31 | 32 | @Action 33 | interface AnalyticsAction 34 | 35 | @Action 36 | class LongUseCaseAction(val userName: String) : AnalyticsAction, SuspendingAction 37 | 38 | 39 | /** 40 | * Use any name you like for suspending actions, or use reducer 41 | */ 42 | typealias UseCase = Reducer 43 | 44 | 45 | class MainStore : Store() { 46 | 47 | init { 48 | Mini.link(dispatcher, this).track() 49 | } 50 | 51 | @Reducer 52 | fun handleLoading(state: State, action: SetLoadingAction): State { 53 | return state.copy(loading = action.loading) 54 | } 55 | 56 | 57 | @Reducer 58 | fun handleSetTextAction(state: State, action: SetTextAction): State { 59 | return state.copy(text = action.text) 60 | } 61 | 62 | @Reducer 63 | fun handleAnalyticsAction(action: AnalyticsAction) { 64 | //Log to analytics 65 | } 66 | 67 | @Reducer 68 | fun handleAnyAction(action: Any) { 69 | //Log to analytics 70 | } 71 | 72 | @UseCase 73 | suspend fun useCase(s: LongUseCaseAction) { 74 | if (state.loading) return 75 | dispatcher.dispatch(SetLoadingAction(true)) 76 | dispatcher.dispatch(SetTextAction("Loading from network...")) 77 | delay(5000) 78 | dispatcher.dispatch(SetTextAction("Hello From UseCase")) 79 | dispatcher.dispatch(SetLoadingAction(false)) 80 | } 81 | } 82 | 83 | class MainViewModelReducer : NestedStateContainer() { 84 | 85 | @Reducer 86 | fun handleLoading(state: State, action: SetLoadingAction): State { 87 | return state.copy(loading = action.loading) 88 | } 89 | 90 | @Reducer 91 | fun handleSetTextAction(state: State, action: SetTextAction): State { 92 | return state.copy(text = action.text) 93 | } 94 | } 95 | 96 | class MainViewModel(savedStateHandle: SavedStateHandle) : FluxViewModel(savedStateHandle) { 97 | private val reducerSlice = MainViewModelReducer().apply { parent = this } 98 | 99 | init { 100 | Mini.link(dispatcher, listOf(this, reducerSlice)).track() 101 | } 102 | 103 | override fun saveState(state: State, handle: SavedStateHandle) { 104 | println("State saved") 105 | handle.set("state", state) 106 | } 107 | 108 | override fun restoreState(handle: SavedStateHandle): State? { 109 | val restored = handle.get("state") 110 | println("State restored $restored") 111 | return restored 112 | } 113 | 114 | @UseCase 115 | suspend fun useCase(action: LongUseCaseAction) { 116 | if (state.loading) return 117 | dispatcher.dispatch(SetLoadingAction(true)) 118 | delay(2000) 119 | dispatcher.dispatch(SetTextAction("${state.text.toInt() + 1}")) 120 | dispatcher.dispatch(SetLoadingAction(false)) 121 | } 122 | } 123 | 124 | class MainActivity : FluxActivity() { 125 | 126 | lateinit var textView: TextView 127 | lateinit var progressBar: ProgressBar 128 | private val vm: MainViewModel by viewModels() 129 | 130 | override suspend fun whenCreated(savedInstanceState: Bundle?) { 131 | setContentView(R.layout.activity_main) 132 | textView = findViewById(R.id.textView) 133 | progressBar = findViewById(R.id.progressBar) 134 | 135 | textView.setOnClickListener { 136 | launch { 137 | dispatcher.dispatch(LongUseCaseAction("Pablo")) 138 | //Decide on the state after usecase is done 139 | //I won't run until use case is done 140 | } 141 | } 142 | 143 | vm.flow().onEach { 144 | textView.text = it.toString() 145 | progressBar.visibility = if (it.loading) View.VISIBLE else View.INVISIBLE 146 | }.launchInLifecycleScope() 147 | 148 | vm.flow() 149 | .select { it.loading } 150 | .onEachDisable { 151 | Toast.makeText(this@MainActivity, "Finished loading", Toast.LENGTH_SHORT).show() 152 | }.launchInLifecycleScope() 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | 27 | 28 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/sample/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/sample/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/sample/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minikorp/mini/0ad3eb7c261050562ed049d92c9e42477b28ed7d/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /sample/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /sample/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | AndroidSample 3 | -------------------------------------------------------------------------------- /sample/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /sample/src/test/java/com/example/androidsample/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.androidsample 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /scripts/bump-tag.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "$0")" || exit 4 | ls 5 | pwd 6 | latest_tag=$(./latest-version.sh) 7 | new_tag=$(./semver.sh bump "$1" "$latest_tag") 8 | echo "$latest_tag -> $new_tag" 9 | git tag "$new_tag" 10 | -------------------------------------------------------------------------------- /scripts/latest-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | tags=$(git tag --sort=-version:refname) 4 | latest_tag=$(echo "$tags" | head -1) 5 | echo "$latest_tag" 6 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | require_clean_work_tree() { 4 | # Update the index 5 | git update-index -q --ignore-submodules --refresh 6 | err=0 7 | 8 | # Disallow unstaged changes in the working tree 9 | if ! git diff-files --quiet --ignore-submodules --; then 10 | echo >&2 "cannot $1: you have unstaged changes." 11 | git diff-files --name-status -r --ignore-submodules -- >&2 12 | err=1 13 | fi 14 | 15 | # Disallow uncommitted changes in the index 16 | if ! git diff-index --cached --quiet HEAD --ignore-submodules --; then 17 | echo >&2 "cannot $1: your index contains uncommitted changes." 18 | git diff-index --cached --name-status -r --ignore-submodules HEAD -- >&2 19 | err=1 20 | fi 21 | 22 | if [ "$err" -eq "1" ]; then 23 | echo >&2 "Please commit or stash them." 24 | exit 1 25 | fi 26 | } 27 | 28 | cd "$(dirname "$0")" || exit 29 | 30 | # Ensure tags are up to date 31 | require_clean_work_tree "Generate release" 32 | git pull 33 | git pull --tags 34 | require_clean_work_tree "Generate release" 35 | 36 | cd .. # move to project root 37 | ./gradlew clean build test || exit 1 38 | 39 | # bump version and push 40 | cd "$(dirname "$0")" || exit 41 | ./bump-tag.sh patch 42 | git push --tags 43 | git push 44 | -------------------------------------------------------------------------------- /scripts/semver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Orignal author fsaintjacques: https://github.com/fsaintjacques/semver-tool 4 | 5 | set -o errexit -o nounset -o pipefail 6 | 7 | NAT='0|[1-9][0-9]*' 8 | ALPHANUM='[0-9]*[A-Za-z-][0-9A-Za-z-]*' 9 | IDENT="$NAT|$ALPHANUM" 10 | FIELD='[0-9A-Za-z-]+' 11 | 12 | SEMVER_REGEX="\ 13 | ^[vV]?\ 14 | ($NAT)\\.($NAT)\\.($NAT)\ 15 | (\\-(${IDENT})(\\.(${IDENT}))*)?\ 16 | (\\+${FIELD}(\\.${FIELD})*)?$" 17 | 18 | PROG=semver 19 | PROG_VERSION="3.0.0" 20 | 21 | USAGE="\ 22 | Usage: 23 | $PROG bump (major|minor|patch|release|prerel |build ) 24 | $PROG compare 25 | $PROG get (major|minor|patch|release|prerel|build) 26 | $PROG --help 27 | $PROG --version 28 | 29 | Arguments: 30 | A version must match the following regular expression: 31 | \"${SEMVER_REGEX}\" 32 | In English: 33 | -- The version must match X.Y.Z[-PRERELEASE][+BUILD] 34 | where X, Y and Z are non-negative integers. 35 | -- PRERELEASE is a dot separated sequence of non-negative integers and/or 36 | identifiers composed of alphanumeric characters and hyphens (with 37 | at least one non-digit). Numeric identifiers must not have leading 38 | zeros. A hyphen (\"-\") introduces this optional part. 39 | -- BUILD is a dot separated sequence of identifiers composed of alphanumeric 40 | characters and hyphens. A plus (\"+\") introduces this optional part. 41 | 42 | See definition. 43 | 44 | A string as defined by PRERELEASE above. 45 | 46 | A string as defined by BUILD above. 47 | 48 | Options: 49 | -v, --version Print the version of this tool. 50 | -h, --help Print this help message. 51 | 52 | Commands: 53 | bump Bump by one of major, minor, patch; zeroing or removing 54 | subsequent parts. \"bump prerel\" sets the PRERELEASE part and 55 | removes any BUILD part. \"bump build\" sets the BUILD part. 56 | \"bump release\" removes any PRERELEASE or BUILD parts. 57 | The bumped version is written to stdout. 58 | 59 | compare Compare with , output to stdout the 60 | following values: -1 if is newer, 0 if equal, 1 if 61 | older. The BUILD part is not used in comparisons. 62 | 63 | get Extract given part of , where part is one of major, minor, 64 | patch, prerel, build, or release. 65 | 66 | See also: 67 | https://semver.org -- Semantic Versioning 2.0.0" 68 | 69 | function error() { 70 | echo -e "$1" >&2 71 | exit 1 72 | } 73 | 74 | function usage-help() { 75 | error "$USAGE" 76 | } 77 | 78 | function usage-version() { 79 | echo -e "${PROG}: $PROG_VERSION" 80 | exit 0 81 | } 82 | 83 | function validate-version() { 84 | local version=$1 85 | if [[ "$version" =~ $SEMVER_REGEX ]]; then 86 | # if a second argument is passed, store the result in var named by $2 87 | if [ "$#" -eq "2" ]; then 88 | local major=${BASH_REMATCH[1]} 89 | local minor=${BASH_REMATCH[2]} 90 | local patch=${BASH_REMATCH[3]} 91 | local prere=${BASH_REMATCH[4]} 92 | local build=${BASH_REMATCH[8]} 93 | eval "$2=(\"$major\" \"$minor\" \"$patch\" \"$prere\" \"$build\")" 94 | else 95 | echo "$version" 96 | fi 97 | else 98 | error "version $version does not match the semver scheme 'X.Y.Z(-PRERELEASE)(+BUILD)'. See help for more information." 99 | fi 100 | } 101 | 102 | function is-nat() { 103 | [[ "$1" =~ ^($NAT)$ ]] 104 | } 105 | 106 | function is-null() { 107 | [ -z "$1" ] 108 | } 109 | 110 | function order-nat() { 111 | [ "$1" -lt "$2" ] && { 112 | echo -1 113 | return 114 | } 115 | [ "$1" -gt "$2" ] && { 116 | echo 1 117 | return 118 | } 119 | echo 0 120 | } 121 | 122 | function order-string() { 123 | [[ $1 < $2 ]] && { 124 | echo -1 125 | return 126 | } 127 | [[ $1 > $2 ]] && { 128 | echo 1 129 | return 130 | } 131 | echo 0 132 | } 133 | 134 | # given two (named) arrays containing NAT and/or ALPHANUM fields, compare them 135 | # one by one according to semver 2.0.0 spec. Return -1, 0, 1 if left array ($1) 136 | # is less-than, equal, or greater-than the right array ($2). The longer array 137 | # is considered greater-than the shorter if the shorter is a prefix of the longer. 138 | # 139 | function compare-fields() { 140 | local l="$1[@]" 141 | local r="$2[@]" 142 | local leftfield=("${!l}") 143 | local rightfield=("${!r}") 144 | local left 145 | local right 146 | 147 | local i=$((-1)) 148 | local order=$((0)) 149 | 150 | while true; do 151 | [ $order -ne 0 ] && { 152 | echo $order 153 | return 154 | } 155 | 156 | : $((i++)) 157 | left="${leftfield[$i]}" 158 | right="${rightfield[$i]}" 159 | 160 | is-null "$left" && is-null "$right" && { 161 | echo 0 162 | return 163 | } 164 | is-null "$left" && { 165 | echo -1 166 | return 167 | } 168 | is-null "$right" && { 169 | echo 1 170 | return 171 | } 172 | 173 | is-nat "$left" && is-nat "$right" && { 174 | order=$(order-nat "$left" "$right") 175 | continue 176 | } 177 | is-nat "$left" && { 178 | echo -1 179 | return 180 | } 181 | is-nat "$right" && { 182 | echo 1 183 | return 184 | } 185 | { 186 | order=$(order-string "$left" "$right") 187 | continue 188 | } 189 | done 190 | } 191 | 192 | # shellcheck disable=SC2206 # checked by "validate"; ok to expand prerel id's into array 193 | function compare-version() { 194 | local order 195 | validate-version "$1" V 196 | validate-version "$2" V_ 197 | 198 | # compare major, minor, patch 199 | 200 | local left=("${V[0]}" "${V[1]}" "${V[2]}") 201 | local right=("${V_[0]}" "${V_[1]}" "${V_[2]}") 202 | 203 | order=$(compare-fields left right) 204 | [ "$order" -ne 0 ] && { 205 | echo "$order" 206 | return 207 | } 208 | 209 | # compare pre-release ids when M.m.p are equal 210 | 211 | local prerel="${V[3]:1}" 212 | local prerel_="${V_[3]:1}" 213 | local left=(${prerel//./ }) 214 | local right=(${prerel_//./ }) 215 | 216 | # if left and right have no pre-release part, then left equals right 217 | # if only one of left/right has pre-release part, that one is less than simple M.m.p 218 | 219 | [ -z "$prerel" ] && [ -z "$prerel_" ] && { 220 | echo 0 221 | return 222 | } 223 | [ -z "$prerel" ] && { 224 | echo 1 225 | return 226 | } 227 | [ -z "$prerel_" ] && { 228 | echo -1 229 | return 230 | } 231 | 232 | # otherwise, compare the pre-release id's 233 | 234 | compare-fields left right 235 | } 236 | 237 | function command-bump() { 238 | local new 239 | local version 240 | local sub_version 241 | local command 242 | 243 | case $# in 244 | 2) case $1 in 245 | major | minor | patch | release) 246 | command=$1 247 | version=$2 248 | ;; 249 | *) usage-help ;; 250 | esac ;; 251 | 3) case $1 in 252 | prerel | build) 253 | command=$1 254 | sub_version=$2 version=$3 255 | ;; 256 | *) usage-help ;; 257 | esac ;; 258 | *) usage-help ;; 259 | esac 260 | 261 | validate-version "$version" parts 262 | # shellcheck disable=SC2154 263 | local major="${parts[0]}" 264 | local minor="${parts[1]}" 265 | local patch="${parts[2]}" 266 | local prere="${parts[3]}" 267 | local build="${parts[4]}" 268 | 269 | case "$command" in 270 | major) new="$((major + 1)).0.0" ;; 271 | minor) new="${major}.$((minor + 1)).0" ;; 272 | patch) new="${major}.${minor}.$((patch + 1))" ;; 273 | release) new="${major}.${minor}.${patch}" ;; 274 | prerel) new=$(validate-version "${major}.${minor}.${patch}-${sub_version}") ;; 275 | build) new=$(validate-version "${major}.${minor}.${patch}${prere}+${sub_version}") ;; 276 | *) usage-help ;; 277 | esac 278 | 279 | echo "$new" 280 | exit 0 281 | } 282 | 283 | function command-compare() { 284 | local v 285 | local v_ 286 | 287 | case $# in 288 | 2) 289 | v=$(validate-version "$1") 290 | v_=$(validate-version "$2") 291 | ;; 292 | *) usage-help ;; 293 | esac 294 | 295 | set +u # need unset array element to evaluate to null 296 | compare-version "$v" "$v_" 297 | exit 0 298 | } 299 | 300 | # shellcheck disable=SC2034 301 | function command-get() { 302 | local part version 303 | 304 | if [[ "$#" -ne "2" ]] || [[ -z "$1" ]] || [[ -z "$2" ]]; then 305 | usage-help 306 | exit 0 307 | fi 308 | 309 | part="$1" 310 | version="$2" 311 | 312 | validate-version "$version" parts 313 | local major="${parts[0]}" 314 | local minor="${parts[1]}" 315 | local patch="${parts[2]}" 316 | local prerel="${parts[3]:1}" 317 | local build="${parts[4]:1}" 318 | local release="${major}.${minor}.${patch}" 319 | 320 | case "$part" in 321 | major | minor | patch | release | prerel | build) echo "${!part}" ;; 322 | *) usage-help ;; 323 | esac 324 | 325 | exit 0 326 | } 327 | 328 | case $# in 329 | 0) 330 | echo "Unknown command: $*" 331 | usage-help 332 | ;; 333 | esac 334 | 335 | case $1 in 336 | --help | -h) 337 | echo -e "$USAGE" 338 | exit 0 339 | ;; 340 | --version | -v) usage-version ;; 341 | bump) 342 | shift 343 | command-bump "$@" 344 | ;; 345 | get) 346 | shift 347 | command-get "$@" 348 | ;; 349 | compare) 350 | shift 351 | command-compare "$@" 352 | ;; 353 | *) 354 | echo "Unknown arguments: $*" 355 | usage-help 356 | ;; 357 | esac 358 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':sample' 2 | include ':mini-processor', ':mini-common', ':mini-android', ':mini-processor-test' 3 | --------------------------------------------------------------------------------