├── Kdux-android ├── .gitignore ├── consumer-rules.pro ├── src │ ├── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── com │ │ │ └── example │ │ │ └── kdux │ │ │ └── android │ │ │ ├── AndroidKduxDslExtensions.kt │ │ │ ├── LogCatLogger.kt │ │ │ └── AndroidStoreDslExtensions.kt │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── example │ │ │ └── kdux │ │ │ └── android │ │ │ ├── ExampleUnitTest.kt │ │ │ └── Test.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── example │ │ └── kdux │ │ └── android │ │ └── ExampleInstrumentedTest.kt └── proguard-rules.pro ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── Kdux-devtools-common ├── src │ └── main │ │ └── kotlin │ │ ├── Main.kt │ │ └── org │ │ └── mattsho │ │ └── shoebox │ │ └── devtools │ │ └── common │ │ ├── Defaults.kt │ │ ├── DispatchOverride.kt │ │ ├── State.kt │ │ ├── Action.kt │ │ ├── Registration.kt │ │ ├── Command.kt │ │ ├── ServerMessage.kt │ │ ├── CurrentState.kt │ │ ├── DispatchRequest.kt │ │ ├── DispatchResult.kt │ │ ├── ServerRequest.kt │ │ ├── Synchronized.kt │ │ ├── TimeStamper.kt │ │ └── UserCommand.kt └── build.gradle.kts ├── Kdux-devtools-plugin ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── src │ └── main │ │ ├── resources │ │ ├── play_icon.svg │ │ ├── stop_icon.svg │ │ ├── next_icon.svg │ │ ├── send_icon.svg │ │ ├── previous_icon.svg │ │ ├── close_icon.svg │ │ ├── pause_icon.svg │ │ ├── trash_icon.svg │ │ ├── copy_icon.svg │ │ ├── continue_icon.svg │ │ ├── record_icon.svg │ │ ├── divider.svg │ │ ├── step_back_icon.svg │ │ ├── step_over_icon.svg │ │ ├── debug_icon.svg │ │ └── META-INF │ │ │ └── plugin.xml │ │ └── kotlin │ │ └── org │ │ └── mattshoe │ │ └── shoebox │ │ └── kduxdevtoolsplugin │ │ ├── server │ │ ├── ServerState.kt │ │ ├── RegistrationChange.kt │ │ ├── DevToolsServer.kt │ │ ├── ServerIntent.kt │ │ └── DebugState.kt │ │ ├── viewmodel │ │ ├── DispatchLog.kt │ │ ├── ViewModel.kt │ │ ├── UiState.kt │ │ └── UserIntent.kt │ │ └── ui │ │ ├── Colors.kt │ │ └── DevToolsWindowFactory.kt ├── local.properties ├── gradle.properties ├── build.gradle.kts └── gradlew.bat ├── gradle.properties ├── local.properties ├── Kdux-devtools ├── src │ ├── main │ │ └── kotlin │ │ │ └── org │ │ │ └── mattshoe │ │ │ └── shoebox │ │ │ └── devtools │ │ │ ├── DevToolsSerializer.kt │ │ │ ├── server │ │ │ ├── ClientDebugSession.kt │ │ │ └── ServerClient.kt │ │ │ └── DevToolsExtensions.kt │ └── test │ │ └── kotlin │ │ └── org │ │ └── mattshoe │ │ └── shoebox │ │ └── devtools │ │ └── DevToolsEnhancerTest.kt └── build.gradle.kts ├── SECURITY.md ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── doc_request.md │ ├── enhancement.md │ └── todo.md └── workflows │ ├── run_tests.yaml │ └── publish_release.yaml ├── .sapient └── settings.json ├── settings.gradle.kts ├── .gitignore ├── Kdux ├── src │ ├── main │ │ └── kotlin │ │ │ └── kdux │ │ │ ├── caching │ │ │ └── CacheUtility.kt │ │ │ ├── log │ │ │ └── KduxLogger.kt │ │ │ ├── StoreCreator.kt │ │ │ ├── tools │ │ │ ├── GuardEnhancer.kt │ │ │ ├── LoggingEnhancer.kt │ │ │ ├── TimeoutEnhancer.kt │ │ │ ├── FailSafeEnhancer.kt │ │ │ ├── BufferEnhancer.kt │ │ │ ├── PerformanceEnhancer.kt │ │ │ ├── BatchEnhancer.kt │ │ │ ├── DebounceEnhancer.kt │ │ │ └── ThrottleEnhancer.kt │ │ │ └── DefaultStore.kt │ └── test │ │ └── kotlin │ │ ├── kdux │ │ └── tools │ │ │ ├── GuardEnhancerTest.kt │ │ │ ├── LoggingEnhancerTest.kt │ │ │ ├── PerformanceEnhancerTest.kt │ │ │ ├── TimeoutEnhancerTest.kt │ │ │ ├── FailSafeEnhancerTest.kt │ │ │ ├── BatchEnhancerTest.kt │ │ │ ├── BufferEnhancerTest.kt │ │ │ ├── ThrottleEnhancerTest.kt │ │ │ └── DebounceEnhancerTest.kt │ │ └── Samples.kt └── build.gradle.kts ├── Kdux-gson ├── src │ ├── main │ │ └── kotlin │ │ │ └── org │ │ │ └── mattshoe │ │ │ └── shoebox │ │ │ └── kdux │ │ │ └── gson │ │ │ └── GsonExtensions.kt │ └── test │ │ └── kotlin │ │ └── org │ │ └── mattshoe │ │ └── shoebox │ │ └── kdux │ │ └── gson │ │ └── GsonextensionsKtTest.kt └── build.gradle.kts ├── ci └── zip.sh ├── docs ├── guard_enhancer.md ├── timeout_enhancer.md ├── persistence_enhancer.md ├── failsafe_enhancer.md ├── logging_enhancer.md ├── performance_enhancer.md ├── batch_enhancer.md ├── buffer_enhancer.md ├── throttle_enhancer.md ├── debounce_enhancer.md ├── store_creator.md ├── dsl.md ├── reducer.md └── third_party_support.md ├── Kdux-moshi ├── src │ ├── test │ │ └── kotlin │ │ │ └── org │ │ │ └── mattshoe │ │ │ └── shoebox │ │ │ └── kdux │ │ │ └── moshi │ │ │ └── MoshiExtensionsKtTest.kt │ └── main │ │ └── kotlin │ │ └── org │ │ └── mattshoe │ │ └── shoebox │ │ └── kdux │ │ └── moshi │ │ └── MoshiExtensions.kt └── build.gradle.kts ├── Kdux-kotlinx-serialization ├── src │ ├── test │ │ └── kotlin │ │ │ └── PersistenceTest.kt │ └── main │ │ └── kotlin │ │ └── com │ │ └── mattshoe │ │ └── shoebex │ │ └── kdux │ │ └── kotlinx │ │ └── serialization │ │ └── KotlinxSerializationExtensions.kt └── build.gradle.kts └── gradlew.bat /Kdux-android/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /Kdux-android/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattshoe/kdux/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/Main.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox 2 | 3 | fun main() { 4 | println("Hello World!") 5 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattshoe/kdux/HEAD/Kdux-devtools-plugin/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /Kdux-android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | org.gradle.jvmargs=-Xmx4096m 3 | 4 | group.id=org.mattshoe.shoebox 5 | version=1.0.11 6 | pluginVersion = 1.0.13 7 | 8 | android.useAndroidX=true -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/Defaults.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common 2 | 3 | object Defaults { 4 | val HOST = "localhost" 5 | val PORT = 9001 6 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/play_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/stop_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/next_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/send_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/previous_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/DispatchOverride.kt: -------------------------------------------------------------------------------- 1 | package org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class DispatchOverride( 7 | val action: Action 8 | ) -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/State.kt: -------------------------------------------------------------------------------- 1 | package org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class State( 7 | val name: String, 8 | val json: String 9 | ) -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/close_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/Action.kt: -------------------------------------------------------------------------------- 1 | package org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Action( 7 | val name: String, 8 | val json: String 9 | ) -------------------------------------------------------------------------------- /Kdux-devtools-plugin/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/server/ServerState.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.server 2 | 3 | sealed interface ServerState { 4 | data object Started: ServerState 5 | data object Stopped: ServerState 6 | } -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/Registration.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Registration( 7 | val storeName: String 8 | ) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Aug 31 09:15:38 EDT 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/viewmodel/DispatchLog.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.viewmodel 2 | 3 | import org.mattsho.shoebox.devtools.common.DispatchResult 4 | 5 | data class DispatchLog( 6 | val result: DispatchResult 7 | ) -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/pause_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/Command.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Command( 7 | val name: String, 8 | val payload: String? = null 9 | ) -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/ServerMessage.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ServerMessage( 7 | val responseCorrelationId: String?, 8 | val data: String 9 | ) 10 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/ui/Colors.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.ui 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | object Colors { 6 | val Red = Color(0xFFF_D32F2F) 7 | val LightGray = Color(0xFFF_B3B3B3) 8 | val DarkGray = Color(0xFFF_1E2D2F) 9 | } -------------------------------------------------------------------------------- /Kdux-android/src/main/java/com/example/kdux/android/AndroidKduxDslExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.example.kdux.android 2 | 3 | import android.content.Context 4 | import kdux.KduxMenu 5 | 6 | fun KduxMenu.android(context: Context) { 7 | 8 | cacheDir(context.cacheDir) 9 | 10 | globalLogger( 11 | LogCatLogger() 12 | ) 13 | } 14 | 15 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/server/RegistrationChange.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.server 2 | 3 | import org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common.Registration 4 | 5 | data class RegistrationChange( 6 | val value: Registration, 7 | val removed: Boolean = false 8 | ) -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/viewmodel/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.viewmodel 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface ViewModel { 6 | val state: Flow 7 | fun handleIntent(intent: UserIntent) 8 | fun dispose() 9 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/trash_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/CurrentState.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.mattsho.shoebox.devtools.common.State 5 | 6 | @Serializable 7 | data class CurrentState( 8 | val storeName: String, 9 | val state: State 10 | ) -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/copy_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /local.properties: -------------------------------------------------------------------------------- 1 | ## This file must *NOT* be checked into Version Control Systems, 2 | # as it contains information specific to your local configuration. 3 | # 4 | # Location of the SDK. This is only used by Gradle. 5 | # For customization when using a Version Control System, please read the 6 | # header note. 7 | #Fri Sep 06 19:22:00 EDT 2024 8 | sdk.dir=/Users/matthewshoemaker/Library/Android/sdk 9 | -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/DispatchRequest.kt: -------------------------------------------------------------------------------- 1 | package org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class DispatchRequest( 7 | val dispatchId: String, 8 | val storeName: String, 9 | val currentState: State, 10 | val action: Action, 11 | val timestamp: String 12 | ) 13 | 14 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/local.properties: -------------------------------------------------------------------------------- 1 | ## This file must *NOT* be checked into Version Control Systems, 2 | # as it contains information specific to your local configuration. 3 | # 4 | # Location of the SDK. This is only used by Gradle. 5 | # For customization when using a Version Control System, please read the 6 | # header note. 7 | #Wed Sep 11 15:46:02 EDT 2024 8 | sdk.dir=/Users/matthewshoemaker/Library/Android/sdk 9 | -------------------------------------------------------------------------------- /Kdux-devtools/src/main/kotlin/org/mattshoe/shoebox/devtools/DevToolsSerializer.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.devtools 2 | 3 | interface DevToolsSerializer { 4 | fun serializeAction(action: Action): String 5 | fun serializeState(state: State): String 6 | fun deserializeAction(action: org.mattsho.shoebox.devtools.common.Action): Action 7 | fun deserializeState(state: org.mattsho.shoebox.devtools.common.State): State 8 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/continue_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/gradle.properties: -------------------------------------------------------------------------------- 1 | # Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib 2 | kotlin.stdlib.default.dependency = false 3 | 4 | # Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html 5 | org.gradle.configuration-cache = true 6 | 7 | # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html 8 | org.gradle.caching = true 9 | -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/DispatchResult.kt: -------------------------------------------------------------------------------- 1 | package org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class DispatchResult( 7 | val dispatchId: String, 8 | val request: DispatchRequest, 9 | val storeName: String, 10 | val action: Action, 11 | val previousState: State, 12 | val newState: State, 13 | val timestamp: String 14 | ) -------------------------------------------------------------------------------- /Kdux-android/src/test/java/com/example/kdux/android/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.kdux.android 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 | } -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/ServerRequest.kt: -------------------------------------------------------------------------------- 1 | package org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ServerRequest( 7 | val responseCorrelationId: String? = null, 8 | val type: Type, 9 | val data: String 10 | ) { 11 | @Serializable 12 | enum class Type { 13 | REGISTRATION, 14 | CURRENT_STATE, 15 | DISPATCH_REQUEST, 16 | DISPATCH_RESULT 17 | } 18 | } -------------------------------------------------------------------------------- /Kdux-devtools-common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | alias(libs.plugins.kotlin.serialization) 4 | } 5 | 6 | group = "org.mattshoe.shoebox" 7 | version = "1.0.10" 8 | 9 | repositories { 10 | mavenCentral() 11 | google() 12 | } 13 | 14 | dependencies { 15 | implementation(libs.kotlin.serialization) 16 | implementation(libs.kotlin.coroutines) 17 | 18 | testImplementation(kotlin("test")) 19 | } 20 | 21 | tasks.test { 22 | useJUnit() 23 | } 24 | 25 | kotlin { 26 | jvmToolchain(17) 27 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/server/DevToolsServer.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.server 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.mattsho.shoebox.devtools.common.DispatchResult 5 | 6 | interface DevToolsServer { 7 | val serverState: Flow 8 | val dispatchResultStream: Flow 9 | val registrationStream: Flow 10 | val debugState: Flow 11 | fun execute(intent: ServerIntent) 12 | } 13 | 14 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | |---------|-----------| 7 | | 1.0.x | :white_check_mark: | 8 | | 0.0.x | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | Please do NOT create a GitHub Issue to report a security vulnerability. GitHub issues are public and would broadcast 12 | vulnerabilities to all consumers publicly for easy exploitation. 13 | 14 | Please do report vulnerabilities to [mattshoe81@gmail.com](mailto:mattshoe81@gmail.com?subject=Kdux Security Vulnerability) 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | # Summary 3 | _Briefly describe the changes introduced by this PR_ 4 | 5 | ## Checklist 6 | 7 | 8 | - **I have added tests to cover any code changes** 9 | - [ ] Unit Tests 10 | - **I have updated and/or created documentation to reflect any changes:** 11 | - [ ] KDocs 12 | - [ ] README.md 13 | - [ ] docs/*.md 14 | 15 | ### Additional Context 16 | 17 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/record_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Kdux-devtools/src/main/kotlin/org/mattshoe/shoebox/devtools/server/ClientDebugSession.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.devtools.server 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.mattsho.shoebox.devtools.common.ServerRequest 5 | import org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common.Command 6 | 7 | internal interface ClientDebugSession { 8 | val adHocCommands: Flow 9 | 10 | suspend fun send(serverRequest: ServerRequest) 11 | suspend fun awaitResponse(serverRequest: ServerRequest): Command 12 | suspend fun closeSession() 13 | } -------------------------------------------------------------------------------- /.sapient/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "testDirectory": "/Users/matthewshoemaker/repos/kdux/Kdux/src/test", 3 | "testDirectoryOverrideStatus": true, 4 | "unitTestLibrary": "JUNIT4", 5 | "mockingLibrary": "MOCKITO", 6 | "assertionLibrary": "HAMCREST", 7 | "setAutoComplete": false, 8 | "useLLM": true, 9 | "skipVerifyGetterMethod": false, 10 | "obfuscateLLMCode": false, 11 | "fileNamePattern": "{sourceClassName}SapientGeneratedTest.java", 12 | "fileNamePatternOverrideStatus": false, 13 | "weightChoice": "LinesOfCode", 14 | "weightConfig": { 15 | "low": 20, 16 | "mid": 50 17 | } 18 | } -------------------------------------------------------------------------------- /Kdux-android/src/main/java/com/example/kdux/android/LogCatLogger.kt: -------------------------------------------------------------------------------- 1 | package com.example.kdux.android 2 | 3 | import android.util.Log 4 | import kdux.log.KduxLogger 5 | 6 | class LogCatLogger: KduxLogger { 7 | override fun i(msg: String?) { 8 | Log.i("KDUX", msg.toString()) 9 | } 10 | 11 | override fun d(msg: String?) { 12 | Log.d("KDUX", msg.toString()) 13 | } 14 | 15 | override fun w(msg: String?) { 16 | Log.w("KDUX", msg.toString()) 17 | } 18 | 19 | override fun e(msg: String?, e: Throwable) { 20 | Log.e("KDUX", msg.toString(), e) 21 | } 22 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/server/ServerIntent.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.server 2 | 3 | import org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common.UserCommand 4 | 5 | sealed interface ServerIntent { 6 | data class Command(val command: UserCommand): ServerIntent 7 | data class StartDebugging(val storeName: String): ServerIntent 8 | data class PauseDebugging(val storeName: String): ServerIntent 9 | data class StopDebugging(val storeName: String): ServerIntent 10 | data object StopServer: ServerIntent 11 | data object StartServer: ServerIntent 12 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/viewmodel/UiState.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.viewmodel 2 | 3 | import org.mattsho.shoebox.devtools.common.DispatchRequest 4 | import org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common.CurrentState 5 | 6 | sealed interface UiState { 7 | data object DebuggingStopped: UiState 8 | data class Debugging( 9 | val storeName: String, 10 | val currentState: CurrentState? = null, 11 | val dispatchRequest: DispatchRequest? = null 12 | ): UiState 13 | data class DebuggingPaused( 14 | val storeName: String, 15 | val currentState: CurrentState? = null 16 | ): UiState 17 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] <>" 5 | labels: bug 6 | assignees: mattshoe 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Stacktrace** 24 | If applicable, add any stacktrace or error message to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here, 28 | 29 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/server/DebugState.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.server 2 | 3 | import org.mattsho.shoebox.devtools.common.DispatchRequest 4 | import org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common.CurrentState 5 | 6 | sealed interface DebugState { 7 | data class ActivelyDebugging( 8 | val storeName: String, 9 | val currentState: CurrentState? = null, 10 | val dispatchRequest: DispatchRequest? = null 11 | ): DebugState 12 | 13 | data class DebuggingPaused( 14 | val storeName: String, 15 | val currentState: CurrentState? = null 16 | ): DebugState 17 | 18 | data object NotDebugging: DebugState 19 | } -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/Synchronized.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.coroutines.sync.Mutex 4 | import kotlinx.coroutines.sync.withLock 5 | 6 | 7 | class Synchronized( 8 | private var value: T 9 | ) { 10 | private val mutex = Mutex() 11 | 12 | suspend fun update(mutator: suspend (T) -> Unit) { 13 | mutex.withLock { 14 | mutator(value) 15 | } 16 | } 17 | 18 | suspend fun access(accessor: suspend (T) -> R): R { 19 | return mutex.withLock { 20 | accessor(value) 21 | } 22 | } 23 | 24 | suspend fun set(value: T) = mutex.withLock { this.value = value } 25 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenCentral() 4 | gradlePluginPortal() 5 | google() 6 | } 7 | 8 | plugins { 9 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" 10 | // kotlin("jvm") version "2.0.10-RC2" apply false 11 | 12 | } 13 | } 14 | plugins { 15 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" 16 | } 17 | 18 | rootProject.name = "Kdux.Project" 19 | 20 | include(":Kdux") 21 | include(":Kdux-kotlinx-serialization") 22 | include(":Kdux-gson") 23 | include(":Kdux-android") 24 | include(":Kdux-moshi") 25 | include(":Kdux-devtools") 26 | include(":Kdux-devtools-common") 27 | include(":Kdux-devtools-plugin") 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/doc_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Request 3 | about: Request a piece of documentation to be created or updated. 4 | title: "[DOCUMENTATION] <>" 5 | labels: documentation 6 | assignees: mattshoe 7 | 8 | --- 9 | 10 | ## Documentation Request 11 | 12 | ### Summary 13 | 14 | 15 | ### Why? 16 | 17 | 18 | ### Details 19 | 20 | 21 | ### Additional Information 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### IntelliJ IDEA ### 8 | .idea/modules.xml 9 | .idea/jarRepositories.xml 10 | .idea/compiler.xml 11 | .idea/libraries/ 12 | *.iws 13 | *.iml 14 | *.ipr 15 | out/ 16 | !**/src/main/**/out/ 17 | !**/src/test/**/out/ 18 | 19 | ### Eclipse ### 20 | .apt_generated 21 | .classpath 22 | .factorypath 23 | .project 24 | .settings 25 | .springBeans 26 | .sts4-cache 27 | bin/ 28 | !**/src/main/**/bin/ 29 | !**/src/test/**/bin/ 30 | 31 | ### NetBeans ### 32 | /nbproject/private/ 33 | /nbbuild/ 34 | /dist/ 35 | /nbdist/ 36 | /.nb-gradle/ 37 | 38 | ### VS Code ### 39 | .vscode/ 40 | 41 | ### Mac OS ### 42 | .DS_Store 43 | 44 | .idea 45 | .kotlin -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/caching/CacheUtility.kt: -------------------------------------------------------------------------------- 1 | package kdux.caching 2 | 3 | import kdux.KduxMenu 4 | import java.io.File 5 | 6 | object CacheUtility { 7 | private lateinit var cacheDirectory: String 8 | val cacheLocation: String 9 | get() = "${cacheDirectory}/kdux" 10 | 11 | fun cacheLocation(key: String): String { 12 | val fileName = encodeFileName(key) 13 | return "$cacheLocation/${fileName}.kdux" 14 | } 15 | 16 | internal fun setCacheDirectory(file: File) { 17 | cacheDirectory = file.absolutePath 18 | File(cacheLocation).mkdirs() 19 | } 20 | 21 | private fun encodeFileName(fileName: String): String { 22 | // Perfectly fine encoding. Efficient, consistent, and filename readability doesn't matter 23 | return "${fileName.hashCode()}" 24 | } 25 | } -------------------------------------------------------------------------------- /Kdux-android/src/androidTest/java/com/example/kdux/android/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.kdux.android 2 | 3 | import android.support.test.InstrumentationRegistry 4 | import android.support.test.runner.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.kdux.android.test", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /Kdux-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. 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 -------------------------------------------------------------------------------- /Kdux-gson/src/main/kotlin/org/mattshoe/shoebox/kdux/gson/GsonExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kdux.gson 2 | 3 | import com.google.gson.Gson 4 | import kdux.dsl.StoreDslMenu 5 | 6 | val _gsonFallback by lazy { Gson() } 7 | val _charSet = Charsets.UTF_8 8 | 9 | inline fun StoreDslMenu.persistWithGson( 10 | key: String, 11 | gson: Gson = _gsonFallback, 12 | noinline onError: (State?, Throwable) -> Unit = { _, _ -> } 13 | ) { 14 | persist( 15 | key, 16 | serializer = { state, outputStream -> 17 | outputStream.write( 18 | gson.toJson(state).toByteArray(_charSet) 19 | ) 20 | }, 21 | deserializer = { 22 | gson.fromJson(it.reader(_charSet), State::class.java) 23 | }, 24 | onError 25 | ) 26 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement 3 | about: Request a feature to help us improve Kdux 4 | title: "[ENHANCEMENT] **Brief Description**" 5 | labels: enhancement 6 | assignees: mattshoe 7 | 8 | --- 9 | 10 | ## Enhancement Request 11 | 12 | ### Summary 13 | 14 | 15 | ### Why? 16 | 17 | 18 | ### Details 19 | 20 | 21 | ### Implementation Suggestions 22 | 23 | 24 | ### Additional Infomration 25 | 26 | 27 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/viewmodel/UserIntent.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.viewmodel 2 | 3 | import org.mattsho.shoebox.devtools.common.DispatchResult 4 | 5 | sealed interface UserIntent { 6 | data class StopDebugging(val storeName: String): UserIntent 7 | data class StartDebugging(val storeName: String): UserIntent 8 | data class PauseDebugging(val storeName: String): UserIntent 9 | data class StepOver(val storeName: String): UserIntent 10 | data class StepBack(val storeName: String): UserIntent 11 | data class RestoreState(val dispatch: DispatchResult): UserIntent 12 | data class ReplayAction(val dispatch: DispatchResult): UserIntent 13 | data class ReplayDispatch(val dispatch: DispatchResult): UserIntent 14 | data class DispatchOverride(val storeName: String, val text: String): UserIntent 15 | data object ClearLogs: UserIntent 16 | } -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/log/KduxLogger.kt: -------------------------------------------------------------------------------- 1 | package kdux.log 2 | 3 | internal object Logger { 4 | private var _logger: KduxLogger = KduxLoggerImpl() 5 | fun get(): KduxLogger = _logger 6 | fun set(logger: KduxLogger) { 7 | _logger = logger 8 | } 9 | } 10 | 11 | interface KduxLogger { 12 | fun i(msg: String?) 13 | fun d(msg: String?) 14 | fun w(msg: String?) 15 | fun e(msg: String?, e: Throwable) 16 | } 17 | 18 | class KduxLoggerImpl: KduxLogger { 19 | override fun i(msg: String?) { 20 | println("KDUX::info -- $msg") 21 | } 22 | 23 | override fun d(msg: String?) { 24 | println("KDUX::debug -- $msg") 25 | } 26 | 27 | override fun w(msg: String?) { 28 | println("KDUX::warning -- $msg") 29 | } 30 | 31 | override fun e(msg: String?, e: Throwable) { 32 | println("KDUX::error -- $msg: \n\t${e.stackTrace.joinToString("\n\t") { it.toString() }}") 33 | } 34 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/divider.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Kdux-devtools/src/main/kotlin/org/mattshoe/shoebox/devtools/server/ServerClient.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.devtools.server 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.plugins.* 5 | import io.ktor.client.plugins.websocket.* 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.SupervisorJob 9 | import org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common.Defaults 10 | 11 | internal object ServerClient { 12 | private val ktorClient = HttpClient { 13 | install(WebSockets) 14 | install(HttpTimeout) 15 | } 16 | private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) 17 | 18 | fun startSession( 19 | id: String, 20 | host: String, 21 | port: Int 22 | ): ClientDebugSession { 23 | return ClientDebugSessionImpl( 24 | id, 25 | host, 26 | port, 27 | ktorClient, 28 | coroutineScope 29 | ) 30 | } 31 | } 32 | 33 | 34 | -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/TimeStamper.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common 2 | 3 | import java.time.Instant 4 | import java.time.LocalDateTime 5 | import java.time.ZoneId 6 | import java.time.format.DateTimeFormatter 7 | import java.util.Locale 8 | 9 | object TimeStamper { 10 | 11 | private val isoFormatter: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT 12 | private val prettyFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS", Locale.getDefault()) 13 | 14 | // Get the current timestamp in ISO 8601 format 15 | fun now(): String { 16 | return Instant.now().toString() 17 | } 18 | 19 | // Parse an ISO 8601 timestamp and return LocalDateTime object 20 | fun parse(text: String): LocalDateTime { 21 | return LocalDateTime.ofInstant(Instant.parse(text), ZoneId.systemDefault()) 22 | } 23 | 24 | // Convert the ISO 8601 timestamp to a human-readable format 25 | fun pretty(text: String): String { 26 | val dateTime = parse(text) 27 | return dateTime.format(prettyFormatter) 28 | } 29 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/kotlin/org/mattshoe/shoebox/kduxdevtoolsplugin/ui/DevToolsWindowFactory.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kduxdevtoolsplugin.ui 2 | 3 | import androidx.compose.ui.awt.ComposePanel 4 | import com.intellij.openapi.project.Project 5 | import com.intellij.openapi.wm.ToolWindow 6 | import com.intellij.openapi.wm.ToolWindowFactory 7 | import org.mattshoe.shoebox.kduxdevtoolsplugin.viewmodel.DevToolsViewModel 8 | import java.awt.BorderLayout 9 | import javax.swing.JPanel 10 | 11 | class DevToolsWindowFactory : ToolWindowFactory { 12 | override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { 13 | val composePanel = ComposePanel() 14 | val viewModel = DevToolsViewModel() 15 | composePanel.setContent { 16 | DevToolsScreen(viewModel) 17 | } 18 | 19 | val panel = JPanel().apply { 20 | layout = BorderLayout() 21 | add(composePanel, BorderLayout.CENTER) 22 | } 23 | 24 | val content = toolWindow.contentManager.factory.createContent(panel, "Kdux DevTools", false) 25 | toolWindow.contentManager.addContent(content) 26 | } 27 | } -------------------------------------------------------------------------------- /ci/zip.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define the array of project names 4 | projects=( 5 | "Kdux" 6 | "Kdux-android" 7 | "Kdux-gson" 8 | "Kdux-kotlinx-serialization" 9 | ) 10 | 11 | set -e 12 | 13 | OUTPUT_DIR="./build/distributions" 14 | echo "$PWD" 15 | 16 | # Define the path to gradle.properties 17 | gradle_properties="gradle.properties" 18 | 19 | # Extract the version from gradle.properties 20 | version=$(grep '^version=' "$gradle_properties" | cut -d'=' -f2) 21 | 22 | # Check if version was found 23 | if [ -z "$version" ]; then 24 | echo "Version not found in $gradle_properties" 25 | exit 1 26 | fi 27 | 28 | # Output the version 29 | echo "Version found: $version" 30 | 31 | ./gradlew clean --no-daemon 32 | 33 | ./gradlew test --no-daemon 34 | 35 | for project in "${projects[@]}"; do 36 | rm -rf ~/.m2 37 | ./gradlew :"$project":generateZip --no-daemon 38 | mkdir -p $OUTPUT_DIR 39 | cp ./"$project"/build/distributions/* $OUTPUT_DIR 40 | done 41 | 42 | # Use the version to zip files 43 | output_file="$OUTPUT_DIR/kdux_artifacts_$version.zip" 44 | 45 | # Create a ZIP file containing all ZIP files in the directory 46 | zip -j "$output_file" "$OUTPUT_DIR"/*.zip 47 | 48 | echo "Created $output_file containing all ZIP files from $OUTPUT_DIR" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/todo.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: TODO 3 | about: A task that is acknowledged and needs to be done. 4 | title: "[TODO] **Brief Description**" 5 | labels: TODO 6 | assignees: mattshoe 7 | 8 | --- 9 | 10 | ## Task/To-Do 11 | 12 | ### Task Summary 13 | 14 | 15 | 16 | ### Steps to Complete 17 | 18 | 1. [Step one] 19 | 2. [Step two] 20 | 3. [Step three] 21 | 22 | ### Acceptance Criteria 23 | 24 | - [ ] Criterion 1 25 | - [ ] Criterion 2 26 | - [ ] Criterion 3 27 | 28 | ### Priority 29 | 30 | - [ ] High 31 | - [ ] Medium 32 | - [ ] Low 33 | 34 | ### Assignees 35 | 36 | - @mattshoe 37 | 38 | ### Dependencies 39 | 40 | - Depends on #123 41 | - Depends on #456 42 | 43 | ### Additional Information 44 | 45 | - [Additional context] 46 | - [Screenshots, if any] 47 | 48 | ### Progress Updates 49 | 50 | - **Update [Date]**: TBD 51 | - **Update [Date]**: ... 52 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/step_back_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/step_over_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/guard_enhancer.md: -------------------------------------------------------------------------------- 1 | # GuardEnhancer 2 | 3 | ## Overview 4 | 5 | The `GuardEnhancer` provides conditional control over which actions can be dispatched in your Kdux store. By 6 | implementing a custom authorization function, you can ensure that only authorized actions are allowed to modify the 7 | store's state. This is useful for enforcing security, permissions, or other rules that dictate which actions can take 8 | effect. 9 | 10 | ## How It Works 11 | 12 | The `GuardEnhancer` intercepts actions before they are dispatched to the store. It evaluates each action using a 13 | provided `isAuthorized` function. If the function returns `true`, the action is dispatched as usual. If the function 14 | returns `false`, the action is blocked and does not reach the store. 15 | 16 | ### Parameters 17 | 18 | - **Action**: The action requesting to be dispatched to the store. 19 | - **isAuthorized**: A suspendable function that takes an `Action` as a parameter and returns `true` if the action should 20 | be dispatched, or `false` if it should be blocked. 21 | 22 | ## Usage 23 | 24 | ### Adding the GuardEnhancer to a Store 25 | 26 | You can add the `GuardEnhancer` to your store using the `guard` DSL function: 27 | 28 | ```kotlin 29 | val store = kdux.store( 30 | initialState = MyState(), 31 | reducer = MyReducer() 32 | ) { 33 | guard { action -> 34 | // Define your authorization logic here 35 | userIsLoggedIn() && isAllowed(action) 36 | } 37 | } 38 | ``` -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/StoreCreator.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kdux 2 | 3 | /** 4 | * A factory interface for creating instances of a [Store]. This interface abstracts the creation 5 | * logic for a [Store], allowing for custom store initialization, such as applying enhancers, 6 | * middleware, or any other custom logic required during store instantiation. 7 | * 8 | * This interface is typically used in scenarios where the store creation process involves more than 9 | * just calling a constructor, for example, when creating a store with predefined enhancers or 10 | * middleware. 11 | * 12 | * @param State The type of state that the created store will manage. It must be a non-nullable type (`Any`). 13 | * @param Action The type of actions that the created store will handle. It must be a non-nullable type (`Any`). 14 | */ 15 | interface StoreCreator { 16 | 17 | /** 18 | * Creates and returns a new instance of a [Store]. The store manages the application's state and 19 | * processes actions to update the state using middleware and a reducer. 20 | * 21 | * Implementations of this function should handle the full instantiation of the store, including 22 | * setting up initial state, applying middleware, and any other initialization logic required 23 | * for the specific application. 24 | * 25 | * @return A new instance of a [Store] that is ready to manage state and handle actions. 26 | */ 27 | fun createStore(): Store 28 | } -------------------------------------------------------------------------------- /Kdux-android/src/main/java/com/example/kdux/android/AndroidStoreDslExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.example.kdux.android 2 | 3 | import android.os.Parcel 4 | import android.os.Parcelable 5 | import kdux.dsl.StoreDslMenu 6 | 7 | inline fun StoreDslMenu.persistAsParcelable( 8 | key: String, 9 | noinline onError: (State?, Throwable) -> Unit = { _, _ -> } 10 | ) { 11 | persist( 12 | key, 13 | serializer = { state, outputStream -> 14 | val parcel = Parcel.obtain() 15 | try { 16 | parcel.writeParcelable(state, 0) 17 | val bytes = parcel.marshall() 18 | outputStream.write(bytes) 19 | } catch (e: Throwable) { 20 | onError(state, e) 21 | } finally { 22 | parcel.recycle() 23 | } 24 | }, 25 | deserializer = { 26 | val bytes = it.readBytes() 27 | val parcel = Parcel.obtain() 28 | try { 29 | parcel.unmarshall(bytes, 0, bytes.size) 30 | parcel.setDataPosition(0) 31 | parcel.readParcelable(State::class.java.classLoader) 32 | } catch (e: Throwable) { 33 | onError(null, e) 34 | throw e 35 | } finally { 36 | parcel.recycle() 37 | } ?: throw IllegalStateException("Parcelable could not be deserialized: ${State::class.simpleName}") 38 | }, 39 | onError 40 | ) 41 | } -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/tools/GuardEnhancer.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.mattshoe.shoebox.kdux.Enhancer 5 | import org.mattshoe.shoebox.kdux.Store 6 | 7 | /** 8 | * The `GuardEnhancer` blocks actions that fail the [isAuthorized] check. Before dispatching an action, 9 | * it runs the [isAuthorized] function. If the function returns `true`, the action is dispatched; 10 | * otherwise, it is blocked. 11 | * 12 | * @param State The type representing the state managed by the store. 13 | * @param Action The type representing the actions that can be dispatched to the store. 14 | * @param isAuthorized A suspend function that returns `true` if the action should be dispatched, 15 | * or `false` if it should be blocked. 16 | */ 17 | open class GuardEnhancer( 18 | private val isAuthorized: suspend (Action) -> Boolean 19 | ): Enhancer { 20 | 21 | override fun enhance(store: Store): Store { 22 | return object : Store { 23 | 24 | override val name: String 25 | get() = store.name 26 | override val state: Flow 27 | get() = store.state 28 | override val currentState: State 29 | get() = store.currentState 30 | 31 | override suspend fun dispatch(action: Action) { 32 | if (isAuthorized(action)) { 33 | store.dispatch(action) 34 | } 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/debug_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/tools/LoggingEnhancer.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.coroutineScope 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.launch 6 | import org.mattshoe.shoebox.kdux.Enhancer 7 | import org.mattshoe.shoebox.kdux.Store 8 | 9 | /** 10 | * An enhancer that logs every action dispatched to the store. The logging is handled 11 | * asynchronously, allowing you to log actions without blocking the dispatch process. 12 | * 13 | * This enhancer is useful for debugging or monitoring purposes, providing insight into 14 | * the actions being dispatched and allowing you to track how the state changes over time. 15 | * 16 | * @param log A suspendable function that takes an `Action` as a parameter and logs it. 17 | * The logging function is called every time an action is dispatched. 18 | */ 19 | open class LoggingEnhancer( 20 | private val log: suspend (Action) -> Unit 21 | ): Enhancer { 22 | 23 | override fun enhance(store: Store): Store { 24 | return object : Store { 25 | 26 | override val name: String 27 | get() = store.name 28 | override val state: Flow 29 | get() = store.state 30 | override val currentState: State 31 | get() = store.currentState 32 | 33 | override suspend fun dispatch(action: Action) = coroutineScope { 34 | launch { 35 | log(action) 36 | } 37 | store.dispatch(action) 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /docs/timeout_enhancer.md: -------------------------------------------------------------------------------- 1 | ## Timeout Enhancer 2 | 3 | The `TimeoutEnhancer` enforces a time limit on the dispatching of actions within a Kdux store. If an action is not 4 | processed within the specified `timeout` duration, the dispatch is canceled, and a `TimeoutCancellationException` is 5 | thrown. 6 | 7 | ### Overview 8 | 9 | ### What is the `TimeoutEnhancer`? 10 | 11 | The `TimeoutEnhancer` is an enhancer that wraps the dispatch process of a Kdux store with a timeout mechanism. It 12 | ensures that any action dispatched to the store is processed within a given time frame. If the processing of an action 13 | takes longer than the specified timeout, the operation is canceled. 14 | 15 | ### Why Use the `TimeoutEnhancer`? 16 | 17 | In certain scenarios, you may want to enforce strict time limits on how long an action can take to process. This can be 18 | particularly useful for: 19 | 20 | - **Preventing Long-Running Operations**: Ensure that no single action can block the store indefinitely, potentially 21 | leading to a better-performing application. 22 | - **Enforcing Timeouts on Critical Actions**: For actions where timing is crucial, this enhancer ensures they are 23 | completed within a specific duration or are canceled. 24 | 25 | ### How It Works 26 | 27 | The `TimeoutEnhancer` works by wrapping the store’s `dispatch` function in a `withTimeout` block provided by Kotlin’s 28 | coroutines library. When an action is dispatched, the enhancer checks whether it can be processed within the 29 | specified `timeout`. If the action processing exceeds this duration, the operation is canceled, and 30 | a `TimeoutCancellationException` is thrown. 31 | 32 | ### Example Usage 33 | 34 | ```kotlin 35 | val store = kdux.store( 36 | initialState = MyState(), 37 | reducer = MyReducer() 38 | ) { 39 | timeout(500.milliseconds) 40 | } 41 | ``` -------------------------------------------------------------------------------- /.github/workflows/run_tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run All Tests 2 | run-name: Test Suite for PR#${{ github.event.pull_request.number }} by ${{ github.actor }} 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | - develop 9 | - "feature/*" 10 | - "release/*" 11 | - "hotfix/*" 12 | - "bugfix/*" 13 | - "fix/*" 14 | 15 | jobs: 16 | run-all-tests: 17 | runs-on: ubuntu-latest 18 | 19 | concurrency: run-tests-${{ github.event.pull_request.number }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v3 24 | 25 | - name: Setup JDK 26 | uses: actions/setup-java@v4 27 | with: 28 | java-version: '21' 29 | distribution: 'oracle' 30 | 31 | - name: Run tests 32 | uses: nick-fields/retry@v2 33 | with: 34 | timeout_minutes: 30 35 | max_attempts: 3 36 | command: ./gradlew clean test 37 | 38 | - name: Kdux Test Results 39 | if: always() 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: Kdux Test Results 43 | path: Kdux/build/reports/tests/test/** 44 | 45 | - name: Kdux-android Test Results 46 | if: always() 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: Kdux-android Test Results 50 | path: Kdux-android/build/reports/tests/test/** 51 | 52 | - name: Kdux-gson Test Results 53 | if: always() 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: Kdux-gson Test Results 57 | path: Kdux-gson/build/reports/tests/test/** 58 | 59 | - name: Kdux-kotlinx-serialization Test Results 60 | if: always() 61 | uses: actions/upload-artifact@v4 62 | with: 63 | name: Kdux-kotlinx-serialization Test Results 64 | path: Kdux-kotlinx-serialization/build/reports/tests/test/** -------------------------------------------------------------------------------- /Kdux-moshi/src/test/kotlin/org/mattshoe/shoebox/kdux/moshi/MoshiExtensionsKtTest.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kdux.moshi 2 | 3 | import app.cash.turbine.test 4 | import com.google.common.truth.Truth 5 | import kdux.kdux 6 | import kdux.reducer 7 | import kdux.store 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.test.runTest 10 | import org.junit.Assert.* 11 | import org.junit.Rule 12 | import org.junit.Test 13 | import org.junit.rules.TemporaryFolder 14 | 15 | private data class TestState( 16 | val foo: String, 17 | val bar: Int 18 | ) 19 | 20 | private data class TestAction(val value: String) 21 | 22 | class MoshiExtensionsKtTest { 23 | 24 | @get:Rule 25 | val tempFolder = TemporaryFolder() 26 | 27 | @OptIn(ExperimentalCoroutinesApi::class) 28 | @Test 29 | fun test() = runTest { 30 | val cacheLocation = tempFolder.newFolder("cache") 31 | kdux { 32 | cacheDir(cacheLocation) 33 | } 34 | val initialStore = store( 35 | TestState("initial", 0), 36 | reducer { state, action -> 37 | TestState(action.value, state.bar + 1) 38 | } 39 | ) { 40 | persistWithMoshi("testStore") { _, error -> 41 | throw error 42 | } 43 | } 44 | 45 | initialStore.dispatch(TestAction("subsequent")) 46 | 47 | val subsequentStore = store( 48 | TestState("initial", 0), 49 | reducer { state, action -> 50 | TestState(action.value, state.bar + 1) 51 | } 52 | ) { 53 | persistWithMoshi("testStore") { _, error -> 54 | throw error 55 | } 56 | } 57 | 58 | subsequentStore.state.test { 59 | Truth.assertThat(awaitItem().foo).isEqualTo("subsequent") 60 | expectNoEvents() 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/tools/TimeoutEnhancer.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.withTimeout 5 | import org.mattshoe.shoebox.kdux.Enhancer 6 | import org.mattshoe.shoebox.kdux.Store 7 | import kotlin.time.Duration 8 | 9 | /** 10 | * The `TimeoutEnhancer` enforces a time limit on the dispatching of actions. If the action is not 11 | * processed within the specified [timeout] duration, the dispatch is canceled, and a `TimeoutCancellationException` 12 | * is thrown. 13 | * 14 | * This enhancer is useful in scenarios where you want to ensure that certain actions are processed within a specific 15 | * time frame, preventing long-running operations from blocking the store. 16 | * 17 | * @param State The type representing the state managed by the store. 18 | * @param Action The type representing the actions that can be dispatched to the store. 19 | * @param timeout The maximum duration allowed for processing an action. If the action is not processed 20 | * within this duration, the dispatch is canceled. 21 | */ 22 | class TimeoutEnhancer( 23 | private val timeout: Duration 24 | ): Enhancer { 25 | 26 | init { 27 | require(timeout > Duration.ZERO) { 28 | "Timeout must be greater than zero." 29 | } 30 | } 31 | 32 | override fun enhance(store: Store): Store { 33 | return object : Store { 34 | 35 | override val name: String 36 | get() = store.name 37 | override val state: Flow 38 | get() = store.state 39 | override val currentState: State 40 | get() = store.currentState 41 | 42 | override suspend fun dispatch(action: Action) { 43 | withTimeout(timeout) { 44 | store.dispatch(action) 45 | } 46 | } 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /Kdux-gson/src/test/kotlin/org/mattshoe/shoebox/kdux/gson/GsonextensionsKtTest.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kdux.gson 2 | 3 | import app.cash.turbine.test 4 | import com.google.common.truth.Truth 5 | import kdux.kdux 6 | import kdux.reducer 7 | import kdux.store 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.test.advanceUntilIdle 10 | import kotlinx.coroutines.test.runTest 11 | import org.junit.Rule 12 | import org.junit.Test 13 | import org.junit.rules.TemporaryFolder 14 | 15 | data class TestState( 16 | val value: String 17 | ) 18 | 19 | data class TestAction(val value: String) 20 | 21 | class PersistenceTest { 22 | 23 | @get:Rule 24 | val tempFolder = TemporaryFolder() 25 | 26 | @OptIn(ExperimentalCoroutinesApi::class) 27 | @Test 28 | fun test() = runTest { 29 | val cacheLocation = tempFolder.newFolder("cache") 30 | kdux { 31 | cacheDir(cacheLocation) 32 | } 33 | 34 | val initialStore = store( 35 | initialState = TestState("initialOriginal"), 36 | reducer = reducer { state, action -> 37 | TestState(action.value) 38 | } 39 | ) { 40 | persistWithGson("test") { state, error -> 41 | println(error) 42 | } 43 | } 44 | 45 | initialStore.dispatch(TestAction("subsequentState")) 46 | 47 | advanceUntilIdle() 48 | 49 | initialStore.state.test { 50 | Truth.assertThat(awaitItem().value).isEqualTo("subsequentState") 51 | } 52 | 53 | val secondStore = store( 54 | initialState = TestState("initialOriginal"), 55 | reducer = reducer { state, action -> 56 | TestState(action.value) 57 | } 58 | ) { 59 | persistWithGson("test") 60 | } 61 | 62 | secondStore.state.test { 63 | Truth.assertThat(awaitItem().value).isEqualTo("subsequentState") 64 | } 65 | 66 | } 67 | } -------------------------------------------------------------------------------- /Kdux-devtools-plugin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("java") 3 | alias(libs.plugins.intellij) 4 | alias(libs.plugins.compose) 5 | alias(libs.plugins.compose.compiler) 6 | alias(libs.plugins.kotlin.serialization) 7 | id("org.jetbrains.kotlin.jvm") 8 | } 9 | 10 | group = project.properties["group.id"].toString() 11 | version = project.properties["pluginVersion"].toString() 12 | 13 | repositories { 14 | mavenCentral() 15 | google() 16 | } 17 | 18 | dependencies { 19 | implementation(project(":Kdux-devtools-common")) 20 | implementation(compose.desktop.currentOs) 21 | implementation(libs.kotlin.serialization) 22 | implementation(libs.ktor.server.core) 23 | implementation(libs.ktor.server.netty) 24 | implementation(libs.ktor.server.websockets) 25 | implementation(libs.ktor.serialization) 26 | } 27 | 28 | // Configure Gradle IntelliJ Plugin 29 | // Read more: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html 30 | intellij { 31 | version.set("2023.2.6") 32 | type.set("IC") // Target IDE Platform 33 | 34 | plugins.set(listOf("android", "java")) 35 | } 36 | 37 | tasks { 38 | // Set the JVM compatibility versions 39 | withType { 40 | sourceCompatibility = "17" 41 | targetCompatibility = "17" 42 | } 43 | withType { 44 | kotlinOptions.jvmTarget = "17" 45 | } 46 | 47 | patchPluginXml { 48 | sinceBuild.set("232") 49 | untilBuild = null 50 | } 51 | 52 | signPlugin { 53 | certificateChain.set(System.getenv("CERTIFICATE_CHAIN")) 54 | privateKey.set(System.getenv("PRIVATE_KEY")) 55 | password.set(System.getenv("PRIVATE_KEY_PASSWORD")) 56 | } 57 | 58 | publishPlugin { 59 | token.set(System.getenv("PUBLISH_TOKEN")) 60 | } 61 | } 62 | 63 | tasks { 64 | runIde { 65 | ideDir.set(file("/Applications/Android Studio.app/Contents")) // Path to Android Studio installation 66 | } 67 | 68 | test { 69 | useJUnit() // Run tests for the plugin 70 | } 71 | 72 | } 73 | 74 | tasks.register("prepareKotlinBuildScriptModel") {} 75 | -------------------------------------------------------------------------------- /Kdux-moshi/src/main/kotlin/org/mattshoe/shoebox/kdux/moshi/MoshiExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kdux.moshi 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 5 | import kdux.dsl.StoreDslMenu 6 | 7 | /** 8 | * DO NOT USE. 9 | */ 10 | val _kduxInternalDefaultMoshiNotForPublicUse = Moshi 11 | .Builder() 12 | .add(KotlinJsonAdapterFactory()) 13 | .build() 14 | 15 | /** 16 | * Persists the store's state using Moshi for serialization. 17 | * 18 | * This function leverages the Moshi library to serialize and deserialize the state of the store. 19 | * It uses the provided Moshi instance or falls back to a default instance. The serialized data is saved 20 | * under a unique [key], which you must ensure is unique across stores to avoid data conflicts. 21 | * 22 | * @param State The type of the state being persisted. This class should be annotated or otherwise compatible with Moshi's serialization. 23 | * @param Action The type of actions being dispatched to the store. 24 | * @param key A unique identifier for the persisted state, typically used as a filename or key under which the state is stored. 25 | * @param moshi The Moshi instance used for serialization and deserialization. If not provided, a fallback Moshi instance will be used. 26 | * @param onError A callback to handle any errors that occur during serialization or deserialization. It receives the current state (if available) and the thrown error. 27 | */ 28 | inline fun StoreDslMenu.persistWithMoshi( 29 | key: String, 30 | moshi: Moshi = _kduxInternalDefaultMoshiNotForPublicUse, 31 | noinline onError: (State?, Throwable) -> Unit = { _, _ -> } 32 | ) { 33 | val adapter = moshi.adapter(State::class.java) 34 | persist( 35 | key, 36 | serializer = { state, outputStream -> 37 | outputStream.write( 38 | adapter.toJson(state).toByteArray() 39 | ) 40 | }, 41 | deserializer = { 42 | adapter.fromJson(it.readAllBytes().decodeToString())!! 43 | }, 44 | onError 45 | ) 46 | } -------------------------------------------------------------------------------- /Kdux-kotlinx-serialization/src/test/kotlin/PersistenceTest.kt: -------------------------------------------------------------------------------- 1 | import app.cash.turbine.test 2 | import com.google.common.truth.Truth 3 | import kdux.kdux 4 | import kdux.reducer 5 | import kdux.store 6 | import kotlinx.serialization.Serializable 7 | import org.junit.Test 8 | import org.junit.rules.TemporaryFolder 9 | import com.mattshoe.shoebex.kdux.kotlinx.serialization.persistWithKotlinxSerialization 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.test.advanceUntilIdle 12 | import kotlinx.coroutines.test.runTest 13 | import org.junit.Rule 14 | 15 | @Serializable 16 | data class TestState( 17 | val value: String 18 | ) 19 | 20 | data class TestAction(val value: String) 21 | 22 | class PersistenceTest { 23 | 24 | @get:Rule 25 | val tempFolder = TemporaryFolder() 26 | 27 | @OptIn(ExperimentalCoroutinesApi::class) 28 | @Test 29 | fun test() = runTest { 30 | val cacheLocation = tempFolder.newFolder("cache") 31 | kdux { 32 | cacheDir(cacheLocation) 33 | } 34 | 35 | val initialStore = store( 36 | initialState = TestState("initialOriginal"), 37 | reducer = reducer { state, action -> 38 | TestState(action.value) 39 | } 40 | ) { 41 | persistWithKotlinxSerialization("test") { state, error -> 42 | println(error) 43 | } 44 | } 45 | 46 | initialStore.dispatch(TestAction("subsequentState")) 47 | 48 | advanceUntilIdle() 49 | 50 | initialStore.state.test { 51 | Truth.assertThat(awaitItem().value).isEqualTo("subsequentState") 52 | } 53 | 54 | val secondStore = store( 55 | initialState = TestState("initialOriginal"), 56 | reducer = reducer { state, action -> 57 | TestState(action.value) 58 | } 59 | ) { 60 | persistWithKotlinxSerialization("test") 61 | } 62 | 63 | secondStore.state.test { 64 | Truth.assertThat(awaitItem().value).isEqualTo("subsequentState") 65 | } 66 | 67 | } 68 | } -------------------------------------------------------------------------------- /Kdux/src/test/kotlin/kdux/tools/GuardEnhancerTest.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import app.cash.turbine.test 4 | import com.google.common.truth.Truth.assertThat 5 | import kdux.reducer 6 | import kotlinx.coroutines.test.runTest 7 | import org.junit.Before 8 | import org.junit.Test 9 | import org.mattshoe.shoebox.kdux.Store 10 | 11 | class GuardEnhancerTest { 12 | 13 | private lateinit var store: Store 14 | 15 | sealed class TestAction { 16 | object AllowedAction : TestAction() 17 | object BlockedAction : TestAction() 18 | } 19 | 20 | @Before 21 | fun setUp() { 22 | store = kdux.store( 23 | initialState = 0, 24 | reducer = reducer { state, action -> 25 | when (action) { 26 | is TestAction.AllowedAction -> state + 1 27 | else -> state 28 | } 29 | } 30 | ) { 31 | guard { action -> action is TestAction.AllowedAction } 32 | } 33 | } 34 | 35 | @Test 36 | fun `WHEN allowed action is dispatched THEN state is updated`() = runTest { 37 | store.state.test { 38 | assertThat(awaitItem()).isEqualTo(0) 39 | 40 | store.dispatch(TestAction.AllowedAction) 41 | 42 | assertThat(awaitItem()).isEqualTo(1) 43 | } 44 | } 45 | 46 | @Test 47 | fun `WHEN blocked action is dispatched THEN state is not updated`() = runTest { 48 | store.state.test { 49 | assertThat(awaitItem()).isEqualTo(0) 50 | 51 | store.dispatch(TestAction.BlockedAction) 52 | 53 | expectNoEvents() 54 | } 55 | } 56 | 57 | @Test 58 | fun `WHEN multiple actions are dispatched THEN only allowed actions are processed`() = runTest { 59 | store.state.test { 60 | assertThat(awaitItem()).isEqualTo(0) 61 | 62 | store.dispatch(TestAction.AllowedAction) 63 | store.dispatch(TestAction.BlockedAction) 64 | store.dispatch(TestAction.AllowedAction) 65 | 66 | assertThat(awaitItem()).isEqualTo(1) 67 | assertThat(awaitItem()).isEqualTo(2) 68 | expectNoEvents() 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /docs/persistence_enhancer.md: -------------------------------------------------------------------------------- 1 | # PersistenceEnhancer 2 | 3 | The `PersistenceEnhancer` is an enhancer designed to automatically persist and restore the state of a store. This 4 | enhancer ensures that the state of the store is saved to a persistent storage medium and restored upon initialization, 5 | providing persistence across application restarts or other lifecycle events. 6 | 7 | ## Overview 8 | 9 | The `PersistenceEnhancer` is used when you need to ensure that the state of your application, or specific parts of it, 10 | is preserved even when the application is restarted or closed. It works by serializing the state to a file and 11 | deserializing it on initialization, thereby maintaining a consistent state across application lifecycles. 12 | 13 | ## Configuration 14 | 15 | - **key:** The `key` parameter determines the filename or unique identifier under which the state is stored. If multiple 16 | stores are being persisted, ensure the key is unique to avoid conflicts. 17 | - **serializer:** A function that serializes the state into the provided `OutputStream`. It must write the state in a 18 | format that you can later deserialize. 19 | - **deserializer:** A function that deserializes the state from the provided `InputStream`. It must return a state 20 | object that matches the type of the store's state. 21 | - **onError:** This function is invoked any time an error goes uncaught during serialization/deserialization and File 22 | IO. 23 | 24 | ## Usage Example 25 | 26 | The Kdux dsl provides full support for all popular serialization libraries, including Gson, Kotlinx Serialization, Moshi, 27 | and more. Refer to the [Third Party Support](third_party_support.md) document for how to use them. 28 | 29 | Here’s a basic example of how to use the `PersistenceEnhancer` in a **Kdux** store: 30 | 31 | ```kotlin 32 | val store = kdux.store( 33 | initialState = MyState(), 34 | reducer = MyReducer() 35 | ) { 36 | persist( 37 | key = "myGloballyUniqueKey-${userId}", 38 | serializer = { state, outputStream -> /* Serialize state to be written to storage */ }, 39 | deserializer = { inputStream -> /* Deserialize the inputStream into the proper state */ }, 40 | onError = { state, error -> /* Handle error */ } 41 | ) 42 | } 43 | ``` -------------------------------------------------------------------------------- /Kdux-android/src/test/java/com/example/kdux/android/Test.kt: -------------------------------------------------------------------------------- 1 | package com.example.kdux.android 2 | 3 | import android.os.Parcelable 4 | import app.cash.turbine.test 5 | import com.google.common.truth.Truth 6 | import kdux.kdux 7 | import kdux.reducer 8 | import kdux.store 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.test.advanceUntilIdle 11 | import kotlinx.coroutines.test.runTest 12 | import kotlinx.parcelize.Parcelize 13 | import org.junit.Rule 14 | import org.junit.Test 15 | import org.junit.rules.TemporaryFolder 16 | import org.junit.runner.RunWith 17 | import org.robolectric.RobolectricTestRunner 18 | 19 | @Parcelize 20 | data class TestState( 21 | val value: String 22 | ): Parcelable 23 | 24 | data class TestAction(val value: String) 25 | 26 | @RunWith(RobolectricTestRunner::class) 27 | class PersistenceTest { 28 | 29 | @get:Rule 30 | val tempFolder = TemporaryFolder() 31 | 32 | @OptIn(ExperimentalCoroutinesApi::class) 33 | @Test 34 | fun test() = runTest { 35 | val cacheLocation = tempFolder.newFolder("cache") 36 | kdux { 37 | cacheDir(cacheLocation) 38 | } 39 | 40 | val initialStore = store( 41 | initialState = TestState("initialOriginal"), 42 | reducer = reducer { state, action -> 43 | TestState(action.value) 44 | } 45 | ) { 46 | persistAsParcelable("test") { state, error -> 47 | throw error 48 | } 49 | } 50 | 51 | initialStore.dispatch(TestAction("subsequentState")) 52 | 53 | advanceUntilIdle() 54 | 55 | initialStore.state.test { 56 | Truth.assertThat(awaitItem().value).isEqualTo("subsequentState") 57 | } 58 | 59 | val secondStore = store( 60 | initialState = TestState("initialOriginal"), 61 | reducer = reducer { state, action -> 62 | TestState(action.value) 63 | } 64 | ) { 65 | persistAsParcelable("test") { state, error -> 66 | throw error 67 | } 68 | } 69 | 70 | secondStore.state.test { 71 | Truth.assertThat(awaitItem().value).isEqualTo("subsequentState") 72 | } 73 | 74 | } 75 | } -------------------------------------------------------------------------------- /Kdux-devtools-common/src/main/kotlin/org/mattsho/shoebox/devtools/common/UserCommand.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common 2 | 3 | import kotlinx.serialization.encodeToString 4 | import kotlinx.serialization.json.Json 5 | import org.mattsho.shoebox.devtools.common.Action 6 | import org.mattsho.shoebox.devtools.common.DispatchResult 7 | 8 | sealed interface UserCommand { 9 | val storeName: String 10 | val payload: Command 11 | 12 | data class Continue( 13 | override val storeName: String, 14 | override val payload: Command = Command("continue") 15 | ): UserCommand 16 | 17 | data class Pause( 18 | override val storeName: String 19 | ): UserCommand { 20 | override val payload = Command("pause") 21 | } 22 | 23 | data class NextDispatch( 24 | override val storeName: String 25 | ): UserCommand { 26 | override val payload = Command("next") 27 | } 28 | 29 | data class PreviousDispatch( 30 | override val storeName: String 31 | ): UserCommand { 32 | override val payload = Command("previous") 33 | } 34 | 35 | data class RestoreState( 36 | val dispatch: DispatchResult, 37 | override val storeName: String = dispatch.storeName, 38 | override val payload: Command = Command( 39 | "restoreState", 40 | payload = Json.encodeToString(dispatch) 41 | ) 42 | ): UserCommand 43 | 44 | data class ReplayAction( 45 | val dispatch: DispatchResult, 46 | override val storeName: String = dispatch.storeName, 47 | override val payload: Command = Command( 48 | "replayAction", 49 | payload = Json.encodeToString(dispatch) 50 | ) 51 | ): UserCommand 52 | 53 | data class ReplayDispatch( 54 | val dispatch: DispatchResult, 55 | override val storeName: String = dispatch.storeName, 56 | override val payload: Command = Command( 57 | "replayDispatch", 58 | payload = Json.encodeToString(dispatch) 59 | ) 60 | ): UserCommand 61 | 62 | data class DispatchOverride( 63 | override val storeName: String, 64 | val action: Action 65 | ): UserCommand { 66 | override val payload = Command("override", Json.encodeToString(action)) 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/tools/FailSafeEnhancer.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.mattshoe.shoebox.kdux.Enhancer 5 | import org.mattshoe.shoebox.kdux.Store 6 | 7 | /** 8 | * The `FailSafeEnhancer` provides a mechanism to handle errors during dispatch. 9 | * 10 | * If an exception occurs while processing an action, the [onError] function is invoked, allowing you to handle the error 11 | * and optionally retry the same action or dispatch a different action. 12 | * 13 | * This enhancer is useful in scenarios where you want to prevent the store from crashing due to unexpected errors 14 | * and instead recover gracefully or provide fallback logic. 15 | * 16 | * @param State The type representing the state managed by the store. 17 | * @param Action The type representing the actions that can be dispatched to the store. 18 | * @param onError A suspend function that is invoked when an error occurs during action processing. 19 | * It receives the current state, the action that caused the error, the error itself, 20 | * and a callback function to dispatch a new action. It returns an optional action to retry 21 | * or a different action, or `null` if no further action is needed. 22 | */ 23 | class FailSafeEnhancer( 24 | private val onError: suspend ( 25 | state: State, 26 | action: Action, 27 | error: Throwable, 28 | dispatch: suspend (Action) -> Unit 29 | ) -> Unit 30 | ): Enhancer { 31 | override fun enhance(store: Store): Store { 32 | return object : Store { 33 | 34 | override val name: String 35 | get() = store.name 36 | override val state: Flow 37 | get() = store.state 38 | override val currentState: State 39 | get() = store.currentState 40 | 41 | override suspend fun dispatch(action: Action) { 42 | try { 43 | store.dispatch(action) 44 | } catch (ex: Throwable) { 45 | onError(currentState, action, ex) { newAction -> 46 | store.dispatch(newAction) 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/tools/BufferEnhancer.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.sync.Mutex 5 | import kotlinx.coroutines.sync.withLock 6 | import org.mattshoe.shoebox.kdux.Enhancer 7 | import org.mattshoe.shoebox.kdux.Store 8 | 9 | /** 10 | * An enhancer that buffers dispatched actions until a specified buffer size is reached. 11 | * Once the buffer is full, all buffered actions are dispatched to the store at once. 12 | * 13 | * This enhancer is useful in scenarios where you want to delay processing of actions 14 | * and batch them together to minimize state updates or improve performance. 15 | * 16 | * When buffer is flushed, actions are dispatched in the order they entered the buffer. 17 | * 18 | * @param bufferSize The size of the buffer that determines when the buffered actions 19 | * should be dispatched. Must be greater than zero. 20 | * 21 | * @throws IllegalArgumentException if `bufferSize` is less than or equal to zero. 22 | */ 23 | open class BufferEnhancer( 24 | private val bufferSize: Int 25 | ): Enhancer { 26 | init { 27 | require(bufferSize > 0) { 28 | "Buffer size must be greater than zero." 29 | } 30 | } 31 | 32 | override fun enhance(store: Store): Store { 33 | return object : Store { 34 | private val bufferMutex = Mutex() 35 | private val buffer = mutableListOf() 36 | 37 | override val name: String 38 | get() = store.name 39 | override val state: Flow 40 | get() = store.state 41 | override val currentState: State 42 | get() = store.currentState 43 | 44 | override suspend fun dispatch(action: Action) { 45 | val actionsToDispatch = mutableListOf() 46 | bufferMutex.withLock { 47 | buffer.add(action) 48 | 49 | if (buffer.size >= bufferSize) { 50 | actionsToDispatch.addAll(buffer) 51 | buffer.clear() 52 | } 53 | } 54 | actionsToDispatch.forEach { 55 | store.dispatch(it) 56 | } 57 | } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /docs/failsafe_enhancer.md: -------------------------------------------------------------------------------- 1 | ## FailSafeEnhancer 2 | 3 | The `FailSafeEnhancer` provides a mechanism to handle errors during the dispatch process. By integrating this enhancer into your store, you can prevent your application from crashing due to unexpected exceptions and instead recover gracefully by providing fallback logic or even retry/recovery mechanisms. 4 | 5 | ### Overview 6 | 7 | ### What is the `FailSafeEnhancer`? 8 | 9 | The `FailSafeEnhancer` intercepts errors that occur during the processing of actions. When an exception is thrown while an action is being dispatched, the enhancer catches the exception and invokes your `onError` function. This function allows you to handle the error, potentially retry the action, or even dispatch a different action to recover from the error. 10 | 11 | ### Why Use the `FailSafeEnhancer`? 12 | 13 | In complex applications, errors can occur unexpectedly during state transitions. The `FailSafeEnhancer` ensures that these errors do not cause your application to crash or enter an inconsistent state. Instead, it gives you the flexibility to: 14 | 15 | - **Retry Actions**: Attempt to re-dispatch the action that caused the error. 16 | - **Fallback Actions**: Dispatch a different action to handle the error or recover gracefully. 17 | - **Log and Monitor**: Capture error details and monitor issues in your application's state management. 18 | 19 | ### How It Works 20 | 21 | The `FailSafeEnhancer` works by wrapping the store's `dispatch` function. When an action is dispatched and an error occurs during its processing, the enhancer invokes the `onError` function. This function receives: 22 | 23 | - **Current State**: The state of the store when the error occurred. 24 | - **Action**: The action that caused the error. 25 | - **Error**: The exception that was thrown. 26 | - **Dispatch Callback**: A callback function that allows you to dispatch a new action. 27 | 28 | You can use this information to decide how to handle the error, whether by retrying the same action, dispatching a different action, or logging the error for further analysis. 29 | 30 | ### Example Usage 31 | 32 | ```kotlin 33 | val store = kdux.store( 34 | initialState = MyState(), 35 | reducer = MyReducer() 36 | ) { 37 | onError { state, action, error, dispatch -> 38 | println("Error occurred: $error") 39 | 40 | if (action is ImportantAction) { 41 | dispatch(action) 42 | } 43 | } 44 | } 45 | ``` -------------------------------------------------------------------------------- /Kdux/src/test/kotlin/kdux/tools/LoggingEnhancerTest.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.test.runTest 5 | import app.cash.turbine.test 6 | import com.google.common.truth.Truth.assertThat 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.launch 10 | import kotlinx.coroutines.runBlocking 11 | import kotlinx.coroutines.test.advanceUntilIdle 12 | import org.junit.Before 13 | import org.junit.Test 14 | import org.mattshoe.shoebox.kdux.Reducer 15 | import org.mattshoe.shoebox.kdux.Store 16 | 17 | @OptIn(ExperimentalCoroutinesApi::class) 18 | class LoggingEnhancerTest { 19 | 20 | private lateinit var store: Store 21 | private val initialState = 0 22 | private val loggedActions = mutableListOf() 23 | 24 | sealed class TestAction { 25 | object Increment : TestAction() 26 | } 27 | 28 | private class TestReducer : Reducer { 29 | override suspend fun reduce(state: Int, action: TestAction): Int { 30 | return when (action) { 31 | TestAction.Increment -> state + 1 32 | } 33 | } 34 | } 35 | 36 | @Before 37 | fun setUp() { 38 | loggedActions.clear() 39 | store = kdux.store( 40 | initialState, 41 | TestReducer() 42 | ) { 43 | log { loggedActions.add(it) } 44 | } 45 | } 46 | 47 | @Test 48 | fun `WHEN an action is dispatched THEN it is logged`() = runTest { 49 | store.state.test { 50 | assertThat(awaitItem()).isEqualTo(0) // Initial state 51 | 52 | store.dispatch(TestAction.Increment) 53 | 54 | assertThat(loggedActions).containsExactly(TestAction.Increment) 55 | assertThat(awaitItem()).isEqualTo(1) 56 | } 57 | } 58 | 59 | @Test 60 | fun `WHEN multiple actions are dispatched THEN all are logged`() = runTest { 61 | store.dispatch(TestAction.Increment) 62 | store.dispatch(TestAction.Increment) 63 | store.dispatch(TestAction.Increment) 64 | 65 | assertThat(loggedActions).containsExactly( 66 | TestAction.Increment, 67 | TestAction.Increment, 68 | TestAction.Increment 69 | ) 70 | advanceUntilIdle() 71 | 72 | store.state.test { 73 | assertThat(awaitItem()).isEqualTo(3) 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/tools/PerformanceEnhancer.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.mattshoe.shoebox.kdux.Enhancer 5 | import org.mattshoe.shoebox.kdux.Store 6 | import kotlin.time.Duration 7 | import kotlin.time.measureTime 8 | 9 | /** 10 | * An enhancer that measures and logs the performance of each action dispatched to the store. 11 | * Specifically, it measures the total time taken from when the action is first dispatched 12 | * to the time that the dispatch function completes. This includes all middleware, enhancers, 13 | * the reducer. 14 | * 15 | * This enhancer is useful for monitoring the performance of your state management system, 16 | * helping you identify slow actions and optimize them if necessary. 17 | * 18 | * @param log A suspendable function that takes a `PerformanceData` object and logs it. 19 | * The logging function is called every time an action is dispatched, with 20 | * the duration of the dispatch process. 21 | */ 22 | open class PerformanceEnhancer( 23 | private val log: suspend (PerformanceData) -> Unit 24 | ): Enhancer { 25 | override fun enhance(store: Store): Store { 26 | return object : Store { 27 | 28 | override val name: String 29 | get() = store.name 30 | override val currentState: State 31 | get() = store.currentState 32 | override val state: Flow 33 | get() = store.state 34 | 35 | override suspend fun dispatch(action: Action) { 36 | measureTime { 37 | store.dispatch(action) 38 | }.also { 39 | log( 40 | PerformanceData(store.name, action, it) 41 | ) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | /** 49 | * A data class that holds performance data related to the dispatch process. 50 | * It contains the name of the store and the duration it took to dispatch 51 | * and process the action. 52 | * 53 | * @param storeName The name of the store where the action was dispatched. 54 | * @param action The action that was dispatched 55 | * @param duration The time duration it took to dispatch and process the action. 56 | */ 57 | data class PerformanceData internal constructor( 58 | val storeName: String, 59 | val action: Action, 60 | val duration: Duration 61 | ) -------------------------------------------------------------------------------- /docs/logging_enhancer.md: -------------------------------------------------------------------------------- 1 | # Logging Enhancer 2 | 3 | The `LoggingEnhancer` is a powerful tool designed to provide real-time insights into the actions being dispatched to 4 | your store in a Redux-like state management system. By integrating this enhancer into your store, you can effectively 5 | monitor the flow of actions and observe how they impact the application's state over time. This is particularly useful 6 | for debugging, performance monitoring, and gaining a deeper understanding of your application's behavior. 7 | 8 | ## Overview 9 | 10 | ### What is the `LoggingEnhancer`? 11 | 12 | The `LoggingEnhancer` is an enhancer that intercepts every action dispatched to the store and logs it asynchronously. 13 | This means that the logging process does not block or delay the dispatch process, allowing your application to continue 14 | running smoothly while logging occurs in the background. 15 | 16 | ### Why Use the `LoggingEnhancer`? 17 | 18 | In complex applications, keeping track of the actions being dispatched and the resulting state changes can be 19 | challenging. The `LoggingEnhancer` provides a straightforward solution by logging every action as it occurs, offering 20 | the following benefits: 21 | 22 | - **Debugging**: Easily track down bugs by reviewing the sequence of actions leading up to an issue. 23 | - **Monitoring**: Monitor how actions affect the state, ensuring that your application behaves as expected. 24 | - **Performance Analysis**: Observe the frequency and types of actions dispatched to optimize performance. 25 | 26 | ## How It Works 27 | 28 | The `LoggingEnhancer` works by wrapping the store's `dispatch` function. Every time an action is dispatched, the 29 | enhancer logs the action asynchronously before passing it along to the next middleware or reducer. Here's a breakdown of 30 | the key components: 31 | 32 | - **Asynchronous Logging**: The logging is handled in a non-blocking way using Kotlin coroutines, ensuring that the 33 | dispatch process is not delayed. 34 | - **Flexible Logging Function**: The enhancer accepts a suspendable `log` function as a parameter. This function is 35 | responsible for logging the action and can be customized to suit your needs, whether you want to log to the console, a 36 | file, or a remote logging service. 37 | 38 | ### How to Use the LoggingEnhancer 39 | 40 | To use the LoggingEnhancer, simply integrate it into your store during the creation process. Here’s an example of how to 41 | do this: 42 | 43 | ```kotlin 44 | val myStore = kdux.store( 45 | initialState = MyState(), 46 | reducer = MyReducer() 47 | ) { 48 | log { println(it) } 49 | } 50 | ``` -------------------------------------------------------------------------------- /Kdux-devtools/src/test/kotlin/org/mattshoe/shoebox/devtools/DevToolsEnhancerTest.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.devtools 2 | 3 | import com.google.gson.Gson 4 | import kdux.middleware 5 | import kdux.reducer 6 | import kdux.store 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.runBlocking 10 | import org.junit.Ignore 11 | import org.junit.Test 12 | import kotlin.random.Random 13 | 14 | data class TestAction(val value: Int) 15 | data class TestState(val value: Int) 16 | 17 | val gson = Gson() 18 | 19 | @Ignore("Enable this test for manually testing the Kdux Devtools plugin") 20 | class DevToolsEnhancerTest { 21 | 22 | @Test 23 | fun test() = runBlocking { 24 | repeat(1) { storeNumber -> 25 | launch { 26 | delay(storeNumber * 1000L) 27 | val store = store( 28 | initialState = TestState(0), 29 | reducer = reducer { state, action -> 30 | TestState(state.value + action.value).also { 31 | println("New Test State --> $it") 32 | } 33 | } 34 | ) { 35 | name("TestStore$storeNumber") 36 | devtools( 37 | actionSerializer = { 38 | gson.toJson(it) 39 | }, 40 | actionDeserializer = { 41 | gson.fromJson(it.json, TestAction::class.java) 42 | }, 43 | stateSerializer = { 44 | gson.toJson(it) 45 | }, 46 | stateDeserializer = { 47 | gson.fromJson(it.json, TestState::class.java) 48 | } 49 | ) 50 | 51 | // add( 52 | // middleware { store, action, next -> 53 | // if (store.currentState.value < 3){ 54 | // store.dispatch(TestAction(10)) 55 | // } 56 | // } 57 | // ) 58 | } 59 | 60 | repeat (20) { 61 | delay(1000) 62 | store.dispatch( 63 | TestAction( 64 | Random.nextInt(10) 65 | ) 66 | ) 67 | } 68 | } 69 | } 70 | 71 | while (true) { 72 | delay(1000) 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /Kdux/src/test/kotlin/Samples.kt: -------------------------------------------------------------------------------- 1 | import kdux.store 2 | import kotlinx.coroutines.flow.Flow 3 | import kotlinx.coroutines.flow.filter 4 | import org.mattshoe.shoebox.kdux.* 5 | import kotlin.time.measureTime 6 | 7 | sealed interface SampleState { 8 | data object Loading: SampleState 9 | data object Success: SampleState 10 | data object Failure: SampleState 11 | } 12 | 13 | 14 | 15 | sealed interface SampleAction { 16 | data object Foo: SampleAction 17 | data object Bar: SampleAction 18 | } 19 | 20 | 21 | 22 | class SampleMiddleware: Middleware { 23 | override suspend fun apply( 24 | store: Store, 25 | action: SampleAction, 26 | next: suspend (SampleAction) -> Unit 27 | ) { 28 | val elapsedTime = measureTime { 29 | next(action) 30 | } 31 | println("Action processing took ${elapsedTime.inWholeMilliseconds}ms") 32 | } 33 | } 34 | 35 | 36 | 37 | class SampleEnhancer: Enhancer { 38 | override fun enhance(store: Store): Store { 39 | return object : Store { 40 | private val history = mutableListOf() 41 | 42 | override val state: Flow 43 | get() = store.state 44 | .filter { 45 | it is SampleState.Failure 46 | } 47 | 48 | override val currentState: SampleState 49 | get() = store.currentState 50 | 51 | override suspend fun dispatch(action: SampleAction) { 52 | history.add(action) 53 | store.dispatch(action) 54 | } 55 | } 56 | } 57 | } 58 | 59 | 60 | class SampleReducer: Reducer { 61 | override suspend fun reduce(state: SampleState, action: SampleAction): SampleState { 62 | return when (state) { 63 | is SampleState.Loading -> SampleState.Success 64 | is SampleState.Failure -> SampleState.Loading 65 | is SampleState.Success -> SampleState.Success 66 | } 67 | } 68 | } 69 | 70 | val store2 = store( 71 | SampleState.Success, 72 | SampleReducer() 73 | ) { 74 | 75 | } 76 | 77 | class SampleStore( 78 | reducer: Reducer 79 | ): Store 80 | by store( 81 | SampleState.Success, 82 | reducer, 83 | { 84 | add( 85 | SampleMiddleware(), 86 | SampleMiddleware() 87 | ) 88 | add( 89 | SampleEnhancer(), 90 | SampleEnhancer() 91 | ) 92 | } 93 | ) 94 | -------------------------------------------------------------------------------- /docs/performance_enhancer.md: -------------------------------------------------------------------------------- 1 | # Performance Enhancer 2 | 3 | The `PerformanceEnhancer` is a valuable tool for monitoring and analyzing the performance of your state management 4 | system. By integrating this enhancer into your store, you can gain insights into the time taken to process each action 5 | from the moment it is dispatched to the moment the dispatch process completes. This includes the time spent in 6 | middleware, enhancers, and the reducer. 7 | 8 | ## Overview 9 | 10 | ### What is the `PerformanceEnhancer`? 11 | 12 | The `PerformanceEnhancer` is an enhancer that measures and logs the performance of each action dispatched to the store. 13 | It tracks the total time taken from the dispatch of an action to the completion of the dispatch function, including all 14 | the processing done by middleware, enhancers, and the reducer. This helps in identifying slow actions that may impact 15 | the application's performance. 16 | 17 | ### Why Use the `PerformanceEnhancer`? 18 | 19 | In complex applications, certain actions may take longer to process due to extensive middleware, complex reducers, or 20 | other factors. The `PerformanceEnhancer` provides a mechanism to: 21 | 22 | - **Monitor Performance**: Keep track of how long it takes to process actions, helping you understand the performance 23 | characteristics of your state management system. 24 | - **Identify Bottlenecks**: Detect actions that are slow to process, which may indicate areas where performance 25 | optimizations are needed. 26 | - **Optimize Application**: Use the insights gained from performance data to improve the overall responsiveness and 27 | efficiency of your application. 28 | 29 | ## How It Works 30 | 31 | The `PerformanceEnhancer` works by wrapping the store's `dispatch` function. Every time an action is dispatched, the 32 | enhancer measures the total time taken for the dispatch process to complete. This time includes all middleware, 33 | enhancers, and the reducer. The measured duration is then logged using a custom logging function. 34 | 35 | - **Time Measurement**: The enhancer uses Kotlin's `measureTime` function to accurately measure the time taken for the 36 | entire dispatch process. 37 | - **Performance Data Logging**: The enhancer accepts a suspendable `log` function that logs the performance data for 38 | each dispatched action. This function can be customized to log data to the console, a file, or a remote logging 39 | service. 40 | 41 | ### How to Use the PerformanceEnhancer 42 | 43 | To use the PerformanceEnhancer, integrate it into your store during the creation process. Here’s an example of how to do 44 | this: 45 | 46 | ```kotlin 47 | val myStore = kdux.store( 48 | initialState = MyState(), 49 | reducer = MyReducer() 50 | ) { 51 | monitorPerformance { 52 | println("Store: ${it.storeName}, Action: ${it.action}, Duration: ${it.duration}") 53 | } 54 | } 55 | ``` 56 | 57 | -------------------------------------------------------------------------------- /docs/batch_enhancer.md: -------------------------------------------------------------------------------- 1 | # Batch Enhancer 2 | 3 | The `BatchEnhancer` is a useful tool designed to batch actions over a specified time duration before dispatching them all at once. This enhancer is particularly effective in scenarios where you want to minimize the number of state updates, thereby improving performance by reducing the frequency of dispatches. 4 | 5 | ## Overview 6 | 7 | ### What is the `BatchEnhancer`? 8 | 9 | The `BatchEnhancer` is an enhancer that accumulates actions in a batch over a specified time period (`batchDuration`). Once the elapsed time since the start of the batch exceeds this duration, all accumulated actions are dispatched together during the next dispatch call. This batching mechanism helps in controlling the flow of actions and reducing the overhead of frequent state updates. 10 | 11 | ### Why Use the `BatchEnhancer`? 12 | 13 | In applications where actions are frequently dispatched, updating the state after each action can lead to performance issues, especially if the state changes trigger expensive operations such as re-rendering UI components or saving data. The `BatchEnhancer` provides several key benefits: 14 | 15 | - **Performance Optimization**: By batching actions together, you can reduce the number of state updates and improve overall performance. 16 | - **Controlled Dispatching**: Accumulating actions over a defined duration allows you to manage when state updates occur, providing better control over your application's behavior. 17 | - **Reduced Overhead**: Fewer state updates mean less overhead in managing state changes, particularly in complex applications with heavy processing on state updates. 18 | 19 | ## How It Works 20 | 21 | The `BatchEnhancer` works by wrapping the store's `dispatch` function. When an action is dispatched, it is added to a batch rather than being immediately processed. The enhancer tracks the time elapsed since the start of the batch, and if this elapsed time exceeds the specified `batchDuration`, all actions in the batch are dispatched together on the next call to `dispatch`. 22 | 23 | ### Key Features 24 | 25 | - **Time-Based Batching**: Actions are accumulated until the specified `batchDuration` has passed. 26 | - **Non-Blocking Operation**: The enhancer uses a mutex to ensure thread safety while accumulating actions, without blocking other operations. 27 | - **Flexible Duration**: The duration for batching actions can be easily customized, making the enhancer adaptable to various application needs. 28 | 29 | ### How to Use the `BatchEnhancer` 30 | 31 | To use the `BatchEnhancer`, integrate it into your store during the creation process. You can specify the `batchDuration` to control how long actions are accumulated before they are dispatched. 32 | 33 | Example usage: 34 | 35 | ```kotlin 36 | val myStore = store( 37 | initialState = MyState(), 38 | reducer = MyReducer() 39 | ) { 40 | batched(5.seconds) 41 | } 42 | } 43 | ``` -------------------------------------------------------------------------------- /Kdux-devtools-plugin/src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | org.mattshoe.shoebox.Kdux-devtools-plugin 5 | 6 | 8 | Kdux Devtools 9 | 10 | 11 | ShoeBox OSS 12 | 13 | 16 | Kdux DevTools 18 |

Enhance your debugging experience with live inspection and Time-Travel debugging for Kdux-powered applications.

19 | 20 |

Key Features:

21 |
    22 |
  • Live State Inspection: Track and inspect changes in application state in real time as actions are dispatched.
  • 23 |
  • Time-Travel Debugging: Rewind to any previous state and replay actions to test and simulate different scenarios.
  • 24 |
  • Action Replay: Instantly replay actions to see how state changes would have occurred in different conditions.
  • 25 |
  • Dispatch Snapshot Replay: Restore the state at the beginning of any dispatch and replay its action for full time-travel simulation.
  • 26 |
  • Real-Time Action Editing: Modify actions live while debugging to experiment with different inputs and behaviors.
  • 27 |
  • Seamless Integration: Kdux DevTools works directly with your Kdux-powered stores for an effortless debugging experience, enhancing your development workflow.
  • 28 |
29 | ]]>
30 | 31 | 33 | com.intellij.modules.platform 34 | 35 | 37 | 38 | 41 | 42 |
-------------------------------------------------------------------------------- /Kdux-kotlinx-serialization/src/main/kotlin/com/mattshoe/shoebex/kdux/kotlinx/serialization/KotlinxSerializationExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.mattshoe.shoebex.kdux.kotlinx.serialization 2 | 3 | import kdux.dsl.StoreDslMenu 4 | import kotlinx.serialization.ExperimentalSerializationApi 5 | import kotlinx.serialization.encodeToString 6 | import kotlinx.serialization.json.Json 7 | import kotlinx.serialization.json.decodeFromStream 8 | 9 | /** 10 | * Adds a [PersistenceEnhancer] to the store that uses `kotlinx-serialization` to serialize State objects, enabling automatic persistence and restoration of the store's state. 11 | * This function is designed to make it easy to persist and restore the state of your store across application restarts 12 | * or other lifecycle events. 13 | * 14 | * This function enables automatic state persistence for the store, leveraging Kotlinx Serialization to serialize 15 | * the state and store it in a file. The state is restored from the file when the store is initialized. 16 | * 17 | * ### Important Details: 18 | * 19 | * The [key] parameter is used to determine the filename or unique identifier for the stored state. You must ensure that the 20 | * key is unique if multiple stores are being persisted, to avoid conflicts. The [key] can be used to associate user-data to avoid 21 | * exposing the wrong user's data to another, by appending a user-id or otherwise to the key. 22 | * 23 | * @param State The type representing the state managed by the store. The state must be serializable by Kotlinx Serialization. 24 | * @param Action The type representing the actions that can be dispatched to the store. 25 | * @param key A unique identifier for the persisted state, used to determine the filename or key under which the state is stored. 26 | * @param onError A lambda function invoked if an error occurs during serialization or deserialization. Defaults to a no-op. 27 | */ 28 | 29 | @OptIn(ExperimentalSerializationApi::class) 30 | inline fun StoreDslMenu.persistWithKotlinxSerialization( 31 | key: String, 32 | noinline onError: (State?, Throwable) -> Unit = { _, _ -> } 33 | ) { 34 | persist( 35 | key, 36 | serializer = { state, outputStream -> 37 | outputStream.write( 38 | Json.encodeToString(state).toByteArray() 39 | ) 40 | }, 41 | deserializer = { 42 | Json.decodeFromStream(it) 43 | }, 44 | onError 45 | ) 46 | } 47 | 48 | 49 | 50 | 51 | @OptIn(ExperimentalSerializationApi::class) 52 | fun StoreDslMenu.persist( 53 | key: String, 54 | onError: (State?, Throwable) -> Unit 55 | ) { 56 | persist( 57 | key, 58 | serializer = { state, outputStream -> 59 | TODO() 60 | }, 61 | deserializer = { 62 | TODO() 63 | }, 64 | onError 65 | ) 66 | } -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/tools/BatchEnhancer.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.sync.Mutex 5 | import kotlinx.coroutines.sync.withLock 6 | import org.mattshoe.shoebox.kdux.Enhancer 7 | import org.mattshoe.shoebox.kdux.Store 8 | import kotlin.time.Duration 9 | import kotlin.time.TimeSource 10 | 11 | /** 12 | * An enhancer that batches actions based on a specified time duration. Actions are accumulated 13 | * in a batch, and when the elapsed time since the start of the batch exceeds the specified 14 | * `batchDuration`, all actions in the batch are dispatched to the store at once. 15 | * 16 | * This enhancer is useful in scenarios where you want to delay processing actions and 17 | * dispatch them together to minimize state updates or improve performance. 18 | * 19 | * Note that batched actions will not be dispatched until the next dispatch call after the 20 | * [batchDuration] expires. 21 | * 22 | * @param batchDuration The duration for which actions are accumulated before being dispatched. 23 | * Once this duration is exceeded, the batch of actions is dispatched at the 24 | * next call to dispatch. 25 | */ 26 | open class BatchEnhancer( 27 | private val batchDuration: Duration 28 | ): Enhancer { 29 | 30 | init { 31 | require(batchDuration > Duration.ZERO) { 32 | "Batch duration must be greater than zero." 33 | } 34 | } 35 | 36 | override fun enhance(store: Store): Store { 37 | return object : Store { 38 | 39 | private val timeSource = TimeSource.Monotonic 40 | private val now: TimeSource.Monotonic.ValueTimeMark get() = timeSource.markNow() 41 | private var batchStart = now 42 | private val elapsedTime: Duration get() = now.minus(batchStart) 43 | private val batch = mutableListOf() 44 | private val batchMutex = Mutex() 45 | 46 | override val name: String 47 | get() = store.name 48 | override val state: Flow 49 | get() = store.state 50 | override val currentState: State 51 | get() = store.currentState 52 | 53 | override suspend fun dispatch(action: Action) { 54 | val actionsToDispatch = mutableListOf() 55 | batchMutex.withLock { 56 | batch.add(action) 57 | 58 | if (elapsedTime > batchDuration) { 59 | actionsToDispatch.addAll(batch) 60 | batch.clear() 61 | batchStart = now 62 | } 63 | } 64 | 65 | actionsToDispatch.forEach { 66 | store.dispatch(it) 67 | } 68 | } 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /docs/buffer_enhancer.md: -------------------------------------------------------------------------------- 1 | # Buffer Enhancer 2 | 3 | The `BufferEnhancer` is a tool designed to accumulate dispatched actions in a buffer until a specified buffer size is 4 | reached. Once the buffer is full, all buffered actions are dispatched to the store at once. This approach is 5 | particularly effective in scenarios where you want to reduce the frequency of state updates and process actions in 6 | batches, thereby improving performance. 7 | 8 | ## Overview 9 | 10 | ### What is the `BufferEnhancer`? 11 | 12 | The `BufferEnhancer` is an enhancer that buffers actions as they are dispatched to the store. Actions are accumulated in 13 | a buffer, and when the number of buffered actions reaches the specified `bufferSize`, all actions in the buffer are 14 | dispatched together. This ensures that state updates are minimized, which can be beneficial in performance-critical 15 | applications. 16 | 17 | ### Why Use the `BufferEnhancer`? 18 | 19 | In applications where frequent actions are dispatched, immediate state updates can lead to performance bottlenecks, 20 | especially if each state change triggers expensive operations. The `BufferEnhancer` offers several key benefits: 21 | 22 | - **Performance Optimization**: By buffering actions and dispatching them in batches, the enhancer reduces the number of 23 | state updates, which can significantly improve performance. 24 | - **Order Preservation**: Actions are dispatched in the same order they entered the buffer, ensuring that the sequence 25 | of operations remains consistent and predictable. 26 | - **Reduced Overhead**: By batching actions, the overhead associated with frequent state updates is minimized, making it 27 | ideal for high-throughput applications. 28 | 29 | ## How It Works 30 | 31 | The `BufferEnhancer` works by wrapping the store's `dispatch` function. When an action is dispatched, it is added to a 32 | buffer instead of being immediately processed. Once the number of actions in the buffer reaches the 33 | specified `bufferSize`, all buffered actions are dispatched to the store in the order they were added. 34 | 35 | ### Key Features 36 | 37 | - **Buffering Mechanism**: Actions are accumulated in a buffer until the specified `bufferSize` is reached. 38 | - **Thread-Safe Operation**: The enhancer uses a mutex to ensure that actions are safely added to the buffer in 39 | concurrent environments. 40 | - **Order Preservation**: Actions are dispatched in the exact order they were buffered, ensuring that the sequence of 41 | operations is maintained. 42 | 43 | ### How to Use the `BufferEnhancer` 44 | 45 | To use the `BufferEnhancer`, integrate it into your store during the creation process. You can specify the `bufferSize` 46 | to control how many actions are accumulated before they are dispatched. 47 | 48 | Example usage: 49 | 50 | ```kotlin 51 | fun createStore(): Store { 52 | return store( 53 | initialState = MyState(), 54 | reducer = MyReducer() 55 | ) { 56 | buffer(size = 10) 57 | } 58 | } 59 | ``` -------------------------------------------------------------------------------- /docs/throttle_enhancer.md: -------------------------------------------------------------------------------- 1 | # Throttle Enhancer 2 | 3 | The `ThrottleEnhancer` is a powerful tool that limits the rate at which actions are dispatched to a store in a Redux-like state management system. This enhancer ensures that actions are dispatched at most once per specified interval, preventing the store from being overwhelmed by rapid-fire actions and minimizing unnecessary state updates. 4 | 5 | ## Overview 6 | 7 | ### What is the `ThrottleEnhancer`? 8 | 9 | The `ThrottleEnhancer` is an enhancer that regulates the dispatch rate of actions. It does not drop any actions; instead, if actions are dispatched too quickly, they are queued up and dispatched one at a time, with each dispatch separated by the specified interval. This is particularly useful in scenarios where actions are triggered frequently, such as user input events, network polling, or other high-frequency events. 10 | 11 | ### Why Use the `ThrottleEnhancer`? 12 | 13 | In scenarios where actions may be dispatched rapidly, such as frequent user interactions or real-time data streams, uncontrolled dispatching can lead to performance bottlenecks and excessive state updates. The `ThrottleEnhancer` addresses this by: 14 | 15 | - **Rate Limiting**: Ensuring that actions are dispatched at most once per specified interval. 16 | - **Action Queuing**: Queuing up actions that occur too quickly and dispatching them in sequence, preserving the order of execution. 17 | - **State Management Optimization**: Reducing unnecessary state updates, which can improve overall application performance and responsiveness. 18 | 19 | ## How It Works 20 | 21 | The `ThrottleEnhancer` works by wrapping the store’s `dispatch` function with a mechanism that tracks the time of the last dispatched action. When a new action is dispatched, the enhancer checks whether enough time has passed since the last dispatch: 22 | 23 | - If the interval has passed, the action is dispatched immediately. 24 | - If the interval has not passed, the action is delayed until the remaining time in the interval has elapsed. 25 | 26 | This ensures that actions are dispatched no more than once per specified interval, with any excess actions being processed sequentially. 27 | 28 | ### Key Features 29 | 30 | - **Non-Blocking Dispatch**: Actions are not dropped but queued up and dispatched at the correct time, ensuring that no action is lost. 31 | - **Sequential Dispatching**: When multiple actions are queued, they are processed in the order they were dispatched, maintaining the correct sequence of operations. 32 | - **Concurrency Handling**: The enhancer uses a mutex to ensure that actions are queued and dispatched in the correct order, even when multiple coroutines attempt to dispatch actions simultaneously. 33 | 34 | ## Example Usage 35 | 36 | To use the `ThrottleEnhancer`, integrate it into your store during the creation process. Below is an example: 37 | 38 | ```kotlin 39 | val store = store( 40 | initialState = MyState(), 41 | reducer = MyReducer() 42 | ) { 43 | throttle(500.milliseconds) 44 | } 45 | ``` -------------------------------------------------------------------------------- /Kdux/src/test/kotlin/kdux/tools/PerformanceEnhancerTest.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.test.runTest 5 | import app.cash.turbine.test 6 | import com.google.common.truth.Truth.assertThat 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.launch 10 | import kotlin.time.Duration 11 | import kotlin.time.ExperimentalTime 12 | import kotlin.time.measureTime 13 | import org.junit.Before 14 | import org.junit.Test 15 | import org.mattshoe.shoebox.kdux.Reducer 16 | import org.mattshoe.shoebox.kdux.Store 17 | 18 | @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) 19 | class PerformanceEnhancerTest { 20 | 21 | private lateinit var store: Store 22 | private val initialState = 0 23 | private val loggedPerformanceData = mutableListOf>() 24 | 25 | sealed class TestAction { 26 | object Increment : TestAction() 27 | } 28 | 29 | private class TestReducer : Reducer { 30 | override suspend fun reduce(state: Int, action: TestAction): Int { 31 | return when (action) { 32 | TestAction.Increment -> state + 1 33 | } 34 | } 35 | } 36 | 37 | @Before 38 | fun setUp() { 39 | store = kdux.store( 40 | initialState, 41 | TestReducer() 42 | ) { 43 | monitorPerformance { 44 | loggedPerformanceData.add(it) 45 | } 46 | } 47 | } 48 | 49 | @Test 50 | fun `WHEN an action is dispatched THEN its performance is logged`() = runTest { 51 | store.state.test { 52 | assertThat(awaitItem()).isEqualTo(0) // Initial state 53 | 54 | store.dispatch(TestAction.Increment) 55 | 56 | assertThat(loggedPerformanceData).hasSize(1) 57 | val loggedData = loggedPerformanceData.first() 58 | assertThat(loggedData.storeName).isNotEmpty() // Assuming store name is not empty 59 | assertThat(loggedData.action).isEqualTo(TestAction.Increment) 60 | assertThat(loggedData.duration).isGreaterThan(Duration.ZERO) 61 | 62 | assertThat(awaitItem()).isEqualTo(1) 63 | } 64 | } 65 | 66 | @Test 67 | fun `WHEN multiple actions are dispatched THEN all are logged with performance data`() = runTest { 68 | store.dispatch(TestAction.Increment) 69 | store.dispatch(TestAction.Increment) 70 | 71 | assertThat(loggedPerformanceData).hasSize(2) 72 | loggedPerformanceData.forEach { loggedData -> 73 | assertThat(loggedData.storeName).isNotEmpty() 74 | assertThat(loggedData.action).isEqualTo(TestAction.Increment) 75 | assertThat(loggedData.duration).isGreaterThan(Duration.ZERO) 76 | } 77 | 78 | store.state.test { 79 | assertThat(awaitItem()).isEqualTo(2) 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /docs/debounce_enhancer.md: -------------------------------------------------------------------------------- 1 | # Debounce Enhancer 2 | 3 | The `DebounceEnhancer` is a powerful tool designed to limit the rate at which actions are dispatched to your store in a Redux-like state management system. By integrating this enhancer, you can prevent rapid successive actions from overwhelming your application, ensuring that actions are only processed if a specified amount of time has passed since the last dispatch. 4 | 5 | ## Overview 6 | 7 | ### What is the `DebounceEnhancer`? 8 | 9 | The `DebounceEnhancer` is an enhancer that debounces actions based on a specified time duration. This means that if actions are dispatched more frequently than the debounce duration, only the first action in the burst will be processed. This is particularly useful in scenarios where you want to avoid excessive state updates in response to rapid user inputs or other frequent events. 10 | 11 | ### Why Use the `DebounceEnhancer`? 12 | 13 | In applications where actions can be triggered rapidly (such as from user inputs, network events, or timers), immediate processing of every action can lead to performance bottlenecks or undesired behaviors like excessive re-renders. The `DebounceEnhancer` provides several key benefits: 14 | 15 | - **Performance Optimization**: By debouncing actions, you can reduce the frequency of state updates, improving overall performance. 16 | - **Controlled Rate of Dispatch**: Ensures that actions are processed at a controlled rate, preventing rapid sequences of actions from overwhelming the system. 17 | - **Enhanced User Experience**: Prevents unnecessary processing in response to rapid inputs, leading to smoother and more predictable application behavior. 18 | 19 | ## How It Works 20 | 21 | The `DebounceEnhancer` works by wrapping the store's `dispatch` function. When an action is dispatched, the enhancer checks the time elapsed since the last dispatched action. If the elapsed time exceeds the specified debounce duration, the action is dispatched to the store. Otherwise, the action is ignored. 22 | 23 | ### Key Features 24 | 25 | - **Time-Based Debouncing**: Actions are only dispatched if a specified amount of time has passed since the last action was processed. 26 | - **Thread-Safe Operation**: The enhancer uses a mutex to ensure that the debouncing logic is thread-safe in concurrent environments. 27 | - **Flexible Duration**: The debounce duration can be customized to suit the needs of your application, providing fine control over the rate of action dispatches. 28 | 29 | ### How to Use the `DebounceEnhancer` 30 | 31 | To use the `DebounceEnhancer`, integrate it into your store during the creation process. You can specify the debounce `duration` to control the minimum time interval between dispatched actions. 32 | 33 | Example usage: 34 | 35 | ```kotlin 36 | fun createStore(): Store { 37 | return store( 38 | initialState = MyState(), 39 | reducer = MyReducer() 40 | ) { 41 | enhancers( 42 | DebounceEnhancer(Duration.seconds(1)) 43 | ) 44 | } 45 | } 46 | ``` -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/tools/DebounceEnhancer.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.sync.Mutex 5 | import kotlinx.coroutines.sync.withLock 6 | import org.mattshoe.shoebox.kdux.Enhancer 7 | import org.mattshoe.shoebox.kdux.Store 8 | import kotlin.time.Duration 9 | import kotlin.time.Duration.Companion.days 10 | import kotlin.time.TimeSource 11 | 12 | internal val LONG_TIME = 10.days 13 | 14 | /** 15 | * An enhancer that debounces dispatched actions based on a specified time duration. 16 | * This means that actions will only be dispatched if a specified amount of time has passed 17 | * since the last dispatched action. If actions are dispatched more frequently than the 18 | * debounce duration, only the first action in the burst will be dispatched. 19 | * 20 | * In other words, if you try to dispatch more than one action in a given [duration], then 21 | * all actions besides the first will be DROPPED. They will not be queued to execute later. 22 | * 23 | * This enhancer is useful in scenarios where you want to limit the rate at which actions 24 | * are processed, such as preventing excessive updates in response to rapid user input. 25 | * 26 | * @param duration The debounce duration. Actions will only be dispatched if this amount 27 | * of time has passed since the last dispatched action. 28 | * 29 | * @throws IllegalArgumentException if `duration` is less than or equal to zero. 30 | */ 31 | open class DebounceEnhancer( 32 | private val duration: Duration 33 | ): Enhancer { 34 | 35 | init { 36 | require(duration > Duration.ZERO) { 37 | "Debounce duration must be greater than zero." 38 | } 39 | } 40 | 41 | override fun enhance(store: Store): Store { 42 | return object : Store { 43 | private val timeSource = TimeSource.Monotonic 44 | private val now: TimeSource.Monotonic.ValueTimeMark get() = timeSource.markNow() 45 | private var debounceStart = now.minus(LONG_TIME) 46 | private val elapsedTimeSinceLastDispatch: Duration get() = now.minus(debounceStart) 47 | private val debounceMutex = Mutex() 48 | 49 | override val name: String 50 | get() = store.name 51 | override val state: Flow 52 | get() = store.state 53 | override val currentState: State 54 | get() = store.currentState 55 | 56 | override suspend fun dispatch(action: Action) { 57 | var actionToDispatch: Action? = null 58 | 59 | debounceMutex.withLock { 60 | if (elapsedTimeSinceLastDispatch > duration) { 61 | actionToDispatch = action 62 | debounceStart = now 63 | } 64 | } 65 | 66 | actionToDispatch?.let { 67 | store.dispatch(it) 68 | } 69 | } 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | 3 | # Gradle Plugin versions 4 | intellijPlugin = "1.17.3" 5 | composePlugin = "1.6.11" 6 | composeCompilerPlugin = "2.0.20" 7 | kotlinSerializationPlugin = "1.9.0" 8 | androidLibraryPlugin = "8.2.0" 9 | androidPlugin = "2.0.10-RC2" 10 | kotlinParcelize = "2.0.20" 11 | 12 | # Production Dependency Versions 13 | kotlinxCoroutines = "1.9.0" 14 | kotlinSerialization = "1.7.2" 15 | ktor = "2.3.12" 16 | gson = "2.11.0" 17 | moshi = "1.15.1" 18 | 19 | # Test Dependency Versions 20 | junit = "4.13.2" 21 | truth = "1.4.4" 22 | turbine = "1.1.0" 23 | mockkVersion = "1.13.12" 24 | robolectric = "4.13" 25 | 26 | 27 | ######################################################################################################################## 28 | 29 | 30 | [libraries] 31 | 32 | # Producation Dependencies 33 | kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization"} 34 | kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } 35 | ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } 36 | ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } 37 | ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } 38 | ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } 39 | ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } 40 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 41 | ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } 42 | gson = { module = "com.google.code.gson:gson", version.ref = "gson" } 43 | moshi = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } 44 | 45 | # Test Dependencies 46 | test-kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } 47 | test-junit = { group = "junit", name = "junit", version.ref = "junit" } 48 | test-truth = { module = "com.google.truth:truth", version.ref = "truth" } 49 | test-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } 50 | test-mockk = { module = "io.mockk:mockk", version.ref = "mockkVersion" } 51 | test-robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } 52 | 53 | 54 | ######################################################################################################################## 55 | 56 | 57 | [plugins] 58 | intellij = { id = "org.jetbrains.intellij", version.ref = "intellijPlugin"} 59 | compose = { id = "org.jetbrains.compose", version.ref = "composePlugin" } 60 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "composeCompilerPlugin" } 61 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerializationPlugin" } 62 | android-library = { id = "com.android.library", version.ref = "androidLibraryPlugin" } 63 | android = { id = "org.jetbrains.kotlin.android", version.ref = "androidPlugin" } 64 | 65 | -------------------------------------------------------------------------------- /Kdux-devtools-plugin/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /docs/store_creator.md: -------------------------------------------------------------------------------- 1 | # StoreCreator in Kdux 2 | 3 | The `StoreCreator` interface in Kdux serves as a factory for creating instances of a `Store`. This interface abstracts 4 | the creation logic for a `Store`, allowing for custom store initialization, such as applying enhancers, middleware, or 5 | any other custom logic required during store instantiation. 6 | 7 | ## Purpose 8 | 9 | The `StoreCreator` interface is typically used in scenarios where the store creation process involves more than just 10 | calling a constructor. For example, it is useful when creating a store with predefined enhancers, middleware, or any 11 | other custom setup that the application might require. 12 | 13 | ## Key Concepts 14 | 15 | - **Factory Interface**: `StoreCreator` acts as a factory, meaning it encapsulates the logic needed to create a fully 16 | initialized `Store` instance. 17 | - **Customization**: This interface allows for a high degree of customization during the store creation process. You can 18 | use it to apply middleware, enhancers, or any other initialization logic needed for your specific application. 19 | 20 | ## Method Summary 21 | 22 | ### `createStore()` 23 | 24 | - **Description**: Creates and returns a new instance of a `Store`. The store manages the application's state and 25 | processes actions to update the state using middleware and a reducer. 26 | - **Implementation Notes**: Implementations of this function should handle the full instantiation of the store, 27 | including setting up initial state, applying middleware, and any other initialization logic required for the specific 28 | application. 29 | - **Returns**: A new instance of a `Store` that is ready to manage state and handle actions. 30 | 31 | ## Example Usage 32 | 33 | ```kotlin 34 | // Define the state and actions 35 | data class AppState(val count: Int = 0) 36 | 37 | sealed class AppAction { 38 | object Increment : AppAction() 39 | object Decrement : AppAction() 40 | } 41 | 42 | // Create a simple reducer 43 | class AppReducer : Reducer { 44 | override suspend fun reduce(state: AppState, action: AppAction): AppState { 45 | return when (action) { 46 | is AppAction.Increment -> state.copy(count = state.count + 1) 47 | is AppAction.Decrement -> state.copy(count = state.count - 1) 48 | } 49 | } 50 | } 51 | 52 | // Implement the StoreCreator interface 53 | class CustomStoreCreator : StoreCreator { 54 | override fun createStore(): Store { 55 | return store( 56 | initialState = AppState(), 57 | reducer = AppReducer() 58 | ) { 59 | // Add any middleware or enhancers here if needed 60 | // Example: add(LoggingMiddleware()) 61 | } 62 | } 63 | } 64 | 65 | // Usage 66 | fun main() { 67 | // Create the store using the custom store creator 68 | val storeCreator = CustomStoreCreator() 69 | val store = storeCreator.createStore() 70 | 71 | // Dispatch actions 72 | store.dispatch(AppAction.Increment) 73 | store.dispatch(AppAction.Decrement) 74 | 75 | // Observe the state 76 | store.state.collect { state -> 77 | println("Current count: ${state.count}") 78 | } 79 | } 80 | ``` -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/tools/ThrottleEnhancer.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.sync.Mutex 6 | import kotlinx.coroutines.sync.withLock 7 | import org.mattshoe.shoebox.kdux.Enhancer 8 | import org.mattshoe.shoebox.kdux.Store 9 | import kotlin.time.Duration 10 | import kotlin.time.TimeSource 11 | 12 | /** 13 | * The `ThrottleEnhancer` is an [Enhancer] that limits the rate at which actions are dispatched. 14 | * It ensures that actions are only dispatched at most once per specified [interval]. 15 | * 16 | * Dispatches are **_not dropped_** if they happen too quickly, they are simply queued up and `dispatch` will suspend 17 | * until the appropriate amount of time has passed. If multiple coroutines attempt to dispatch at once, 18 | * then they are queued up and executed in the order they were dispatched, at the rate of 1 queued dispatch 19 | * per [interval]. 20 | * 21 | * This is useful in scenarios where actions might be dispatched rapidly (e.g., user input events) 22 | * and you want to avoid overwhelming the store or causing unnecessary state updates. 23 | * 24 | * The enhancer works by delaying subsequent actions if they occur before the defined interval 25 | * has passed since the last dispatched action. 26 | * 27 | * @param State The type representing the state managed by the store. 28 | * @param Action The type representing the actions that can be dispatched to the store. 29 | * @param interval The minimum time interval between consecutive action dispatches. If an action 30 | * is dispatched before this interval has passed since the last action, it will be delayed. 31 | * 32 | * Example usage: 33 | * ```kotlin 34 | * val store = store( 35 | * initialState = MyState(), 36 | * reducer = MyReducer() 37 | * ) { 38 | * add(ThrottleEnhancer(500.milliseconds)) 39 | * } 40 | * ``` 41 | */ 42 | open class ThrottleEnhancer( 43 | private val interval: Duration 44 | ): Enhancer { 45 | 46 | init { 47 | require(interval > Duration.ZERO) { 48 | "Throttle interval must be greater than zero." 49 | } 50 | } 51 | 52 | override fun enhance(store: Store): Store { 53 | return object : Store { 54 | 55 | private val timeSource = TimeSource.Monotonic 56 | private val now: TimeSource.Monotonic.ValueTimeMark get() = timeSource.markNow() 57 | private var lastDispatch = now.minus(interval) 58 | private val elapsedTime: Duration get() = now.minus(lastDispatch) 59 | private val timeToWait: Duration get() = interval.minus(elapsedTime) 60 | private val mutex = Mutex() 61 | 62 | override val name: String 63 | get() = store.name 64 | override val state: Flow 65 | get() = store.state 66 | override val currentState: State 67 | get() = store.currentState 68 | 69 | override suspend fun dispatch(action: Action) { 70 | mutex.withLock { 71 | if (timeToWait > Duration.ZERO) { 72 | delay(timeToWait) 73 | } 74 | lastDispatch = now 75 | } 76 | 77 | store.dispatch(action) 78 | } 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /Kdux/src/test/kotlin/kdux/tools/TimeoutEnhancerTest.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import app.cash.turbine.test 4 | import com.google.common.truth.Truth.assertThat 5 | import kdux.reducer 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.TimeoutCancellationException 8 | import kotlinx.coroutines.delay 9 | import kotlinx.coroutines.test.advanceTimeBy 10 | import kotlinx.coroutines.test.runTest 11 | import org.junit.Test 12 | import kotlin.test.assertFailsWith 13 | import kotlin.time.Duration.Companion.milliseconds 14 | import kotlin.time.Duration.Companion.seconds 15 | import kotlin.time.ExperimentalTime 16 | 17 | @OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class) 18 | class TimeoutEnhancerTest { 19 | 20 | @Test 21 | fun `WHEN action dispatches within timeout THEN it is processed successfully`() = runTest { 22 | val store = kdux.store( 23 | initialState = 0, 24 | reducer = reducer { state, action -> state + action } 25 | ) { 26 | timeout(1.seconds) 27 | } 28 | 29 | store.state.test { 30 | assertThat(awaitItem()).isEqualTo(0) 31 | 32 | store.dispatch(3) 33 | assertThat(awaitItem()).isEqualTo(3) 34 | 35 | expectNoEvents() 36 | } 37 | } 38 | 39 | @Test 40 | fun `WHEN action dispatch exceeds timeout THEN dispatch is canceled`() = runTest { 41 | val store = kdux.store( 42 | initialState = 0, 43 | reducer = reducer { state, action -> 44 | delay(2000) // Simulate a long-running operation 45 | state + action 46 | } 47 | ) { 48 | timeout(500.milliseconds) 49 | } 50 | 51 | store.state.test { 52 | assertThat(awaitItem()).isEqualTo(0) 53 | 54 | try { 55 | store.dispatch(3) 56 | } catch (e: Exception) { 57 | assertThat(e).isInstanceOf(TimeoutCancellationException::class.java) 58 | } 59 | 60 | expectNoEvents() 61 | } 62 | } 63 | 64 | @Test 65 | fun `WHEN multiple actions are dispatched THEN only those within timeout are processed`() = runTest { 66 | val store = kdux.store Unit>( 67 | initialState = 0, 68 | reducer = reducer { state, action -> 69 | action() 70 | state + 1 71 | } 72 | ) { 73 | timeout(500.milliseconds) 74 | } 75 | 76 | store.state.test { 77 | assertThat(awaitItem()).isEqualTo(0) 78 | 79 | // Don't exceed timeout 80 | store.dispatch { 81 | delay(499) 82 | } 83 | 84 | assertThat(awaitItem()).isEqualTo(1) 85 | 86 | try { 87 | store.dispatch { 88 | delay(501) 89 | } 90 | } catch (e: Exception) { 91 | assertThat(e).isInstanceOf(TimeoutCancellationException::class.java) 92 | } 93 | expectNoEvents() 94 | } 95 | } 96 | 97 | @Test 98 | fun `WHEN duration is set to zero THEN throw exception`() { 99 | val exception = assertFailsWith { 100 | TimeoutEnhancer(0.milliseconds) 101 | } 102 | assertThat(exception).hasMessageThat().contains("Timeout must be greater than zero.") 103 | } 104 | } -------------------------------------------------------------------------------- /Kdux/src/main/kotlin/kdux/DefaultStore.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.kdux 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.flow.* 5 | 6 | val __internalstateOverride = mutableMapOf>() 7 | 8 | /** 9 | * Default implementation of the [Store] interface that manages the state and handles 10 | * dispatched actions by passing them through the middleware chain and then reducing them into a new state. 11 | * The store updates its state flow, allowing observers to track state changes over time. 12 | * 13 | * @param State The type of state that this store holds. It must be a non-nullable type (`Any`). 14 | * @param Action The type of actions that can be dispatched to the store. It must be a non-nullable type (`Any`). 15 | * @property initialState The initial state of the store when it is created. 16 | * @property reducer The reducer function that determines how the state should change when an action is dispatched. 17 | * @property middlewares A list of middlewares that intercept and process the actions before they reach the reducer. 18 | */ 19 | internal class DefaultStore( 20 | customName: String? = null, 21 | private val initialState: State, 22 | private val reducer: Reducer, 23 | private val middlewares: List> = emptyList() 24 | ) : Store { 25 | private val _state = MutableStateFlow(initialState) 26 | 27 | override val name = customName ?: super.toString() 28 | override val state: Flow = _state.asStateFlow() 29 | override val currentState: State 30 | get() = _state.value 31 | 32 | init { 33 | __internalstateOverride[name] = _state 34 | } 35 | 36 | override suspend fun dispatch(action: Action) { 37 | processMiddleware(0, action) 38 | } 39 | 40 | override fun toString(): String { 41 | return name 42 | } 43 | 44 | /** 45 | * Recursively processes the middleware chain. Each middleware can intercept, modify, or block 46 | * the action before it is passed to the next middleware or the reducer. 47 | * 48 | * This function is called recursively to process each middleware in sequence. Once all middleware 49 | * has been processed, the action is passed to the reducer to update the state. 50 | * 51 | * @param index The current index in the middleware list. Used to determine which middleware to apply next. 52 | * @param action The action currently being processed by the middleware chain. 53 | */ 54 | private suspend fun processMiddleware(index: Int, action: Action) { 55 | if (index < middlewares.size) { 56 | val nextMiddleware = middlewares[index] 57 | nextMiddleware.apply(this, action) { nextAction -> 58 | processMiddleware(index + 1, nextAction) 59 | } 60 | } else { 61 | // No more middleware, reduce the action and update the state 62 | reduceAndUpdate(action) 63 | } 64 | } 65 | 66 | /** 67 | * Reduces the current state based on the dispatched action using the provided reducer function. 68 | * The reducer is responsible for determining how the state should change based on the given action. 69 | * 70 | * This method updates the internal state and notifies any observers of the new state. 71 | * 72 | * @param action The action to be reduced. This action determines how the state will be updated. 73 | */ 74 | private suspend fun reduceAndUpdate(action: Action) { 75 | _state.update { 76 | reducer.reduce(_state.value, action) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /.github/workflows/publish_release.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | run-name: Release Publication for commit '${{ github.sha }}' by ${{ github.actor }} 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | release: 11 | permissions: 12 | contents: write 13 | actions: write 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | id: checkout_code 20 | uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Setup JDK 25 | id: setup_jdk 26 | uses: actions/setup-java@v4 27 | with: 28 | java-version: '21' 29 | distribution: 'oracle' 30 | 31 | - name: Extract version from gradle.properties 32 | id: get_version 33 | run: | 34 | version=$(grep '^version=' gradle.properties | cut -d'=' -f2) 35 | echo "VERSION=$version" >> $GITHUB_ENV 36 | 37 | - name: Check if tag exists 38 | id: tag_check 39 | run: | 40 | git config --global user.name "GitHub Actions" 41 | git config --global user.email "actions@github.com" 42 | 43 | TAG_VERSION="v${{ env.VERSION }}" 44 | echo "Tag version: $TAG_VERSION" 45 | TAG=$(git tag -l "$TAG_VERSION") 46 | echo "Captured Tag: $TAG" 47 | if [ -n "$TAG" ]; then 48 | echo "Tag v${{ env.VERSION }} already exists. Failing the build." 49 | exit 1 50 | else 51 | echo "Tag v${{ env.VERSION }} does not exist. Continuing." 52 | fi 53 | 54 | - name: Generate Artifacts 55 | id: generate_artifacts 56 | env: 57 | OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} 58 | OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} 59 | run: | 60 | ./ci/zip.sh 61 | 62 | - name: Tag the Commit 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | run: | 66 | git config --global user.name "GitHub Actions" 67 | git config --global user.email "actions@github.com" 68 | git tag -a "v${{ env.VERSION }}" -m "Release version ${{ env.VERSION }}" 69 | git push origin "v${{ env.VERSION }}" 70 | 71 | - name: Create GitHub Release 72 | id: create_release 73 | uses: actions/create-release@v1 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | with: 77 | tag_name: v${{ env.VERSION }} 78 | release_name: ${{ env.VERSION }} 79 | body: | 80 | [Maven Central Repository](https://central.sonatype.com/artifact/org.mattshoe.shoebox/Kdux/versions) 81 | draft: false 82 | prerelease: false 83 | 84 | - name: Upload Assets 85 | id: upload_github_release_assets 86 | uses: actions/upload-release-asset@v1 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | with: 90 | upload_url: ${{ steps.create_release.outputs.upload_url }} 91 | asset_path: build/distributions/kdux_artifacts_${{ env.VERSION }}.zip 92 | asset_name: kdux_artifacts_${{ env.VERSION }}.zip 93 | asset_content_type: application/zip 94 | 95 | - name: Publish to Nexus OSSRH for Maven Central 96 | id: publish_kdux 97 | env: 98 | OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} 99 | OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} 100 | GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} 101 | GPG_SIGNING_PASSPHRASE: ${{ secrets.GPG_SIGNING_PASSPHRASE }} 102 | run: | 103 | ./gradlew publish 104 | 105 | -------------------------------------------------------------------------------- /Kdux/src/test/kotlin/kdux/tools/FailSafeEnhancerTest.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.first 6 | import kotlinx.coroutines.runBlocking 7 | import kotlinx.coroutines.test.runTest 8 | import org.junit.Before 9 | import org.junit.Test 10 | import com.google.common.truth.Truth.assertThat 11 | import kdux.store 12 | import kotlinx.coroutines.test.advanceUntilIdle 13 | import org.mattshoe.shoebox.kdux.Reducer 14 | import org.mattshoe.shoebox.kdux.Store 15 | 16 | class FailSafeEnhancerTest { 17 | 18 | private lateinit var store: Store 19 | private val initialState = 0 20 | 21 | sealed class TestAction { 22 | object Increment : TestAction() 23 | object Fail : TestAction() 24 | object Decrement: TestAction() 25 | } 26 | 27 | private class TestReducer : Reducer { 28 | override suspend fun reduce(state: Int, action: TestAction): Int { 29 | return when (action) { 30 | is TestAction.Increment -> state + 1 31 | is TestAction.Decrement -> state - 1 32 | is TestAction.Fail -> throw RuntimeException("Forced failure") 33 | } 34 | } 35 | } 36 | 37 | @Before 38 | fun setUp() { 39 | store = store( 40 | initialState, 41 | TestReducer() 42 | ) { 43 | onError { state, action, error, dispatch -> 44 | // If an error occurs, dispatch an Increment action as a recovery 45 | if (action is TestAction.Fail) { 46 | dispatch(TestAction.Increment) 47 | } 48 | } 49 | } 50 | } 51 | 52 | @Test 53 | fun `WHEN an action fails THEN recovery action is dispatched`() = runTest { 54 | store.state.test { 55 | assertThat(awaitItem()).isEqualTo(0) 56 | 57 | store.dispatch(TestAction.Fail) 58 | assertThat(awaitItem()).isEqualTo(1) // Recovery action increments the state 59 | 60 | expectNoEvents() 61 | } 62 | } 63 | 64 | @Test 65 | fun `WHEN an action succeeds THEN no additional actions are dispatched`() = runTest { 66 | store.state.test { 67 | assertThat(awaitItem()).isEqualTo(0) 68 | 69 | store.dispatch(TestAction.Decrement) 70 | assertThat(awaitItem()).isEqualTo(-1) 71 | 72 | expectNoEvents() 73 | } 74 | } 75 | 76 | @Test 77 | fun `WHEN multiple actions fail THEN recovery actions are dispatched`() = runTest { 78 | store.state.test { 79 | assertThat(awaitItem()).isEqualTo(0) 80 | 81 | store.dispatch(TestAction.Fail) 82 | assertThat(awaitItem()).isEqualTo(1) 83 | 84 | store.dispatch(TestAction.Fail) 85 | assertThat(awaitItem()).isEqualTo(2) 86 | 87 | expectNoEvents() 88 | } 89 | } 90 | 91 | @Test 92 | fun `WHEN no recovery action is provided THEN state remains unchanged after failure`() = runTest { 93 | val logs = mutableListOf() 94 | store = store( 95 | initialState, 96 | TestReducer() 97 | ) { 98 | onError { _, action, _, _ -> 99 | logs.add(action) 100 | } 101 | } 102 | 103 | store.state.test { 104 | assertThat(awaitItem()).isEqualTo(0) 105 | 106 | store.dispatch(TestAction.Fail) 107 | expectNoEvents() // State remains unchanged due to failure 108 | advanceUntilIdle() 109 | assertThat(logs).containsExactly(TestAction.Fail) 110 | 111 | store.dispatch(TestAction.Increment) 112 | assertThat(awaitItem()).isEqualTo(1) 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /docs/dsl.md: -------------------------------------------------------------------------------- 1 | # Kdux DSL: Detailed Guide 2 | 3 | The Kdux DSL provides a powerful and flexible way to configure and manage state in your Kotlin applications. This guide 4 | will walk you through each component of the DSL, explaining how to use it effectively. 5 | 6 | ## Global Settings 7 | 8 | To configure global settings in your Kdux-powered application, you will use the `kdux {...}` function. This function accepts a 9 | lambda for you to define global behaviors that will be applied to all Kdux Stores in the application. 10 | 11 | ### Usage Example 12 | 13 | Here’s a list of the various global configurations available to you. 14 |
Note that each of these are entirely optional: 15 | 16 | ```kotlin 17 | kdux { 18 | // Add a global error handler to all stores 19 | globalErrorHandler { state, action, error -> 20 | reportErrorToSomewhere(state, action, error) 21 | } 22 | 23 | // Add a global action filter, blocking any dispatch in the application as you see fit 24 | globalGuard { action -> 25 | isUserLoggedIn() && isAllowed(action) 26 | } 27 | 28 | // Log all dispatches in the application 29 | globalLogger { action -> 30 | println("Action dispatched: $action") 31 | } 32 | 33 | // Receive performance metrics for every dispatch in the application 34 | globalPerformanceMonitor { data -> 35 | println("Store: ${data.storeName} -- Action `${data.action}` took ${data.duration.inWholeMilliseconds}ms") 36 | } 37 | 38 | // Clear any and all previously defined global behaviors 39 | clearGlobals() 40 | } 41 | ``` 42 | 43 | ## Creating a Store 44 | 45 | You can create a store using the `store(...) {...}` function provided by the DSL. The lambda allows you to configure any 46 | additional functionality you want your `Store` to have, such as debouncing, action logging, buffered dispatches, and 47 | any other functionality you could come up with. 48 | 49 | Note that the lambda and all functions inside it are entirely optional. Only `initialState` and `reducer` are required. 50 | 51 | ```kotlin 52 | val store = kdux.store( 53 | initialState = MyState(), 54 | reducer = MyReducer() 55 | ) { 56 | // Give your store a custom name (helpful in debugging or reporting) 57 | name("MyStore") 58 | 59 | // Add middleware (order matters) 60 | add(MyMiddleware1(), MyMiddleware2(), MyMiddleware3()) 61 | 62 | // Add custom enhancers 63 | add(MyEnhancer(), AnotherEnhancer()) 64 | 65 | // Add persistence to automatically restore state across app starts 66 | persist( 67 | key = "myGloballyUniqueKey-${userId}", 68 | serializer = { state, outputStream -> 69 | serialize(state, outputStream) 70 | }, 71 | deserializer = { inputStream -> 72 | deserialize(inputStream) 73 | } 74 | ) 75 | 76 | // Block actions that fail an authorization check 77 | guard { action -> 78 | isUserLoggedIn() && isAllowed(action) 79 | } 80 | 81 | // Add logging 82 | log { action -> 83 | doTheLogging(action) 84 | } 85 | 86 | // Add error recovery 87 | onError { state, action, error, dispatch -> 88 | myLogger("Encountered error: ${error}") 89 | if (action is ImportantAction) { 90 | dispatch(RecoveryAction) 91 | } 92 | } 93 | 94 | // Add a timeout to cancel any dispatch that takes too long 95 | timeout(2.seconds) 96 | 97 | // Add performance reporting 98 | monitorPerformance { data -> 99 | doTheMonitoring(data) 100 | } 101 | 102 | // Throttle dispatch processing to once per interval 103 | throttle(interval = 500.milliseconds) 104 | 105 | // Buffer your dispatches to be flushed all at once when they reach the size limit 106 | buffer(size = 12) 107 | 108 | // Add dispatch debouncing 109 | debounce(1.seconds) 110 | 111 | // Batch your dispatches 112 | batched(duration = 1.minute) 113 | } 114 | ``` -------------------------------------------------------------------------------- /Kdux/src/test/kotlin/kdux/tools/BatchEnhancerTest.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import app.cash.turbine.test 4 | import com.google.common.truth.Truth.assertThat 5 | import kotlinx.coroutines.delay 6 | import kotlinx.coroutines.launch 7 | import kotlinx.coroutines.runBlocking 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.mattshoe.shoebox.kdux.Reducer 11 | import org.mattshoe.shoebox.kdux.Store 12 | import kotlin.test.assertFailsWith 13 | import kotlin.time.Duration.Companion.milliseconds 14 | 15 | class BatchEnhancerTest { 16 | 17 | private lateinit var store: Store 18 | private val initialState = 0 19 | 20 | sealed class TestAction { 21 | object Increment : TestAction() 22 | } 23 | 24 | private class TestReducer() : Reducer { 25 | 26 | override suspend fun reduce(state: Int, action: TestAction): Int { 27 | return when (action) { 28 | TestAction.Increment -> state + 1 29 | } 30 | } 31 | } 32 | 33 | @Before 34 | fun setUp() { 35 | store = kdux.store( 36 | initialState, 37 | TestReducer() 38 | ) { 39 | batched(500.milliseconds) 40 | } 41 | } 42 | 43 | @Test 44 | fun `WHEN actions dispatched within batch duration THEN they are batched and dispatched together`() = runBlocking { 45 | store.dispatch(TestAction.Increment) 46 | store.dispatch(TestAction.Increment) 47 | store.dispatch(TestAction.Increment) 48 | 49 | delay(600) // Exceed batch duration 50 | 51 | store.dispatch(TestAction.Increment) // Trigger this batch flush 52 | 53 | store.state.test { 54 | assertThat(awaitItem()).isEqualTo(4) 55 | } 56 | } 57 | 58 | @Test 59 | fun `WHEN actions are dispatched with delay exceeding batch duration THEN they are processed separately`() = runBlocking { 60 | store.dispatch(TestAction.Increment) 61 | delay(600) // Exceed batch duration 62 | store.dispatch(TestAction.Increment) // Trigger flush 63 | delay(100) 64 | store.state.test { 65 | assertThat(awaitItem()).isEqualTo(2) 66 | } 67 | 68 | store.dispatch(TestAction.Increment) 69 | delay(600) // Exceed batch duration 70 | store.dispatch(TestAction.Increment) // Trigger flush 71 | store.state.test { 72 | assertThat(awaitItem()).isEqualTo(4) 73 | } 74 | } 75 | 76 | @Test 77 | fun `WHEN dispatch is called in parallel THEN actions are batched correctly`() = runBlocking { 78 | launch { store.dispatch(TestAction.Increment) } 79 | launch { store.dispatch(TestAction.Increment) } 80 | launch { store.dispatch(TestAction.Increment) } 81 | 82 | delay(600) // Exceed batch duration 83 | store.dispatch(TestAction.Increment) // trigger flush 84 | 85 | store.state.test { 86 | assertThat(awaitItem()).isEqualTo(4) 87 | } 88 | } 89 | 90 | @Test 91 | fun `WHEN batch duration is less than or equal to zero THEN throw exception`() { 92 | val exception = assertFailsWith { 93 | BatchEnhancer(0.milliseconds) 94 | } 95 | assertThat(exception).hasMessageThat().contains("Batch duration must be greater than zero.") 96 | } 97 | 98 | @Test 99 | fun `WHEN multiple batches of actions are dispatched THEN they are processed in correct intervals`() = runBlocking { 100 | store.dispatch(TestAction.Increment) 101 | store.dispatch(TestAction.Increment) 102 | 103 | delay(600) // Exceed batch duration 104 | store.dispatch(TestAction.Increment) // trigger flush 105 | store.state.test { 106 | assertThat(awaitItem()).isEqualTo(3) 107 | } 108 | 109 | store.dispatch(TestAction.Increment) 110 | store.dispatch(TestAction.Increment) 111 | 112 | delay(500) // Exceed batch duration 113 | store.dispatch(TestAction.Increment) // trigger flush 114 | store.state.test { 115 | assertThat(awaitItem()).isEqualTo(6) 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /Kdux-moshi/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | id("maven-publish") 4 | signing 5 | } 6 | 7 | dependencies { 8 | implementation(project(":Kdux")) 9 | implementation(libs.moshi) 10 | 11 | testImplementation(kotlin("test")) 12 | testImplementation(libs.test.truth) 13 | testImplementation(libs.test.turbine) 14 | testImplementation(libs.test.kotlin.coroutines) 15 | testImplementation(libs.test.mockk) 16 | } 17 | 18 | val GROUP_ID: String = project.properties["group.id"].toString() 19 | val VERSION: String = project.properties["version"].toString() 20 | val ARTIFACT_ID: String = "Kdux-moshi" 21 | val PUBLICATION_NAME = "KduxMoshi" 22 | 23 | kotlin { 24 | jvmToolchain(17) 25 | } 26 | 27 | plugins.withId("java") { 28 | java { 29 | withJavadocJar() 30 | withSourcesJar() 31 | } 32 | } 33 | 34 | afterEvaluate { 35 | plugins.withId("maven-publish") { 36 | publishing { 37 | publications { 38 | repositories { 39 | maven { 40 | name = "Nexus" 41 | url = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") 42 | 43 | credentials { 44 | username = System.getenv("OSSRH_USERNAME") ?: "" 45 | password = System.getenv("OSSRH_PASSWORD") ?: "" 46 | } 47 | } 48 | mavenLocal() 49 | } 50 | 51 | create(PUBLICATION_NAME) { 52 | from(components["java"]) 53 | groupId = GROUP_ID 54 | artifactId = ARTIFACT_ID 55 | version = VERSION 56 | pom { 57 | name = "Kdux-moshi" 58 | description = """ 59 | Kdux-moshi is an add-on to the Kdux library to add built-in support for Moshi serialization. 60 | """.trimIndent() 61 | url = "https://github.com/mattshoe/kdux" 62 | properties = mapOf( 63 | "myProp" to "value" 64 | ) 65 | packaging = "aar" 66 | inceptionYear = "2024" 67 | licenses { 68 | license { 69 | name = "The Apache License, Version 2.0" 70 | url = "http://www.apache.org/licenses/LICENSE-2.0.txt" 71 | } 72 | } 73 | developers { 74 | developer { 75 | id = "mattshoe" 76 | name = "Matthew Shoemaker" 77 | email = "mattshoe81@gmail.com" 78 | } 79 | } 80 | scm { 81 | connection = "scm:git:git@github.com:mattshoe/kdux.git" 82 | developerConnection = "scm:git:git@github.com:mattshoe/kdux.git" 83 | url = "https://github.com/mattshoe/kdux" 84 | } 85 | } 86 | } 87 | 88 | 89 | signing { 90 | val signingKey = providers.environmentVariable("GPG_SIGNING_KEY") 91 | val signingPassphrase = providers.environmentVariable("GPG_SIGNING_PASSPHRASE") 92 | if (signingKey.isPresent && signingPassphrase.isPresent) { 93 | useInMemoryPgpKeys(signingKey.get(), signingPassphrase.get()) 94 | sign(publishing.publications[PUBLICATION_NAME]) 95 | } 96 | } 97 | } 98 | } 99 | } 100 | 101 | tasks.register("generateZip") { 102 | val publishTask = tasks.named( 103 | "publish${PUBLICATION_NAME.replaceFirstChar { it.uppercaseChar() }}PublicationToMavenLocalRepository", 104 | PublishToMavenRepository::class.java 105 | ) 106 | from(publishTask.map { it.repository.url }) 107 | archiveFileName.set("${PUBLICATION_NAME}_${VERSION}.zip") 108 | } 109 | } -------------------------------------------------------------------------------- /Kdux/src/test/kotlin/kdux/tools/BufferEnhancerTest.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import app.cash.turbine.test 4 | import com.google.common.truth.Truth.assertThat 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.launch 7 | import kotlinx.coroutines.test.advanceTimeBy 8 | import kotlinx.coroutines.test.advanceUntilIdle 9 | import kotlinx.coroutines.test.runTest 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.mattshoe.shoebox.kdux.Reducer 13 | import org.mattshoe.shoebox.kdux.Store 14 | import kotlin.test.assertFailsWith 15 | import kotlin.time.ExperimentalTime 16 | 17 | @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) 18 | class BufferEnhancerTest { 19 | 20 | private lateinit var store: Store 21 | private val initialState = 0 22 | 23 | sealed class TestAction { 24 | object Increment : TestAction() 25 | } 26 | 27 | private class TestReducer : Reducer { 28 | override suspend fun reduce(state: Int, action: TestAction): Int { 29 | return when (action) { 30 | TestAction.Increment -> state + 1 31 | } 32 | } 33 | } 34 | 35 | @Before 36 | fun setUp() { 37 | store = kdux.store( 38 | initialState, 39 | TestReducer() 40 | ) { 41 | buffer(size = 3) 42 | } 43 | } 44 | 45 | @Test 46 | fun `WHEN buffer size is reached THEN actions are dispatched`() = runTest { 47 | store.dispatch(TestAction.Increment) 48 | store.dispatch(TestAction.Increment) 49 | store.state.test { 50 | assertThat(awaitItem()).isEqualTo(0) 51 | expectNoEvents() 52 | } 53 | store.dispatch(TestAction.Increment) 54 | advanceUntilIdle() 55 | 56 | store.state.test { 57 | assertThat(awaitItem()).isEqualTo(3) 58 | expectNoEvents() // No further state changes 59 | } 60 | } 61 | 62 | @Test 63 | fun `WHEN buffer size is exceeded THEN new actions are buffered separately`() = runTest { 64 | store.dispatch(TestAction.Increment) 65 | store.dispatch(TestAction.Increment) 66 | store.dispatch(TestAction.Increment) 67 | advanceUntilIdle() 68 | store.state.test { 69 | assertThat(awaitItem()).isEqualTo(3) // First batch is dispatched 70 | } 71 | 72 | store.dispatch(TestAction.Increment) 73 | store.dispatch(TestAction.Increment) 74 | 75 | store.state.test { 76 | assertThat(awaitItem()).isEqualTo(3) // Nothing else dispatched yet 77 | expectNoEvents() 78 | } 79 | 80 | store.dispatch(TestAction.Increment) 81 | advanceUntilIdle() 82 | 83 | store.state.test { 84 | assertThat(awaitItem()).isEqualTo(6) // Second batch is dispatched 85 | } 86 | } 87 | 88 | @Test 89 | fun `WHEN dispatch is called in parallel THEN actions are buffered and dispatched correctly`() = runTest { 90 | launch { store.dispatch(TestAction.Increment) } 91 | launch { store.dispatch(TestAction.Increment) } 92 | store.state.test { 93 | assertThat(awaitItem()).isEqualTo(0) 94 | expectNoEvents() 95 | } 96 | launch { store.dispatch(TestAction.Increment) } 97 | advanceUntilIdle() 98 | 99 | store.state.test { 100 | assertThat(awaitItem()).isEqualTo(3) 101 | } 102 | 103 | launch { store.dispatch(TestAction.Increment) } 104 | launch { store.dispatch(TestAction.Increment) } 105 | store.state.test { 106 | assertThat(awaitItem()).isEqualTo(3) 107 | expectNoEvents() 108 | } 109 | launch { store.dispatch(TestAction.Increment) } 110 | advanceUntilIdle() 111 | 112 | store.state.test { 113 | assertThat(awaitItem()).isEqualTo(6) // The next batch is dispatched together 114 | } 115 | } 116 | 117 | @Test 118 | fun `WHEN buffer size is set to zero THEN throw exception`() { 119 | val exception = assertFailsWith { 120 | BufferEnhancer(0) 121 | } 122 | assertThat(exception).hasMessageThat().contains("Buffer size must be greater than zero.") 123 | } 124 | } -------------------------------------------------------------------------------- /Kdux-gson/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.serialization) 3 | kotlin("jvm") 4 | id("maven-publish") 5 | signing 6 | } 7 | 8 | dependencies { 9 | implementation(project(":Kdux")) 10 | implementation(libs.gson) 11 | 12 | testImplementation(kotlin("test")) 13 | testImplementation(libs.test.truth) 14 | testImplementation(libs.test.turbine) 15 | testImplementation(libs.test.kotlin.coroutines) 16 | testImplementation(libs.test.mockk) 17 | } 18 | 19 | val GROUP_ID: String = project.properties["group.id"].toString() 20 | val VERSION: String = project.properties["version"].toString() 21 | val ARTIFACT_ID: String = "Kdux-gson" 22 | val PUBLICATION_NAME = "KduxGson" 23 | 24 | kotlin { 25 | jvmToolchain(17) 26 | } 27 | 28 | plugins.withId("java") { 29 | java { 30 | withJavadocJar() 31 | withSourcesJar() 32 | } 33 | } 34 | 35 | afterEvaluate { 36 | plugins.withId("maven-publish") { 37 | publishing { 38 | publications { 39 | repositories { 40 | maven { 41 | name = "Nexus" 42 | url = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") 43 | 44 | credentials { 45 | username = System.getenv("OSSRH_USERNAME") ?: "" 46 | password = System.getenv("OSSRH_PASSWORD") ?: "" 47 | } 48 | } 49 | mavenLocal() 50 | } 51 | 52 | create(PUBLICATION_NAME) { 53 | from(components["java"]) 54 | groupId = GROUP_ID 55 | artifactId = ARTIFACT_ID 56 | version = VERSION 57 | pom { 58 | name = "Kdux-gson" 59 | description = """ 60 | Kdux-gson is an add-on to the Kdux library to add built-in support for Gson serialization. 61 | """.trimIndent() 62 | url = "https://github.com/mattshoe/kdux" 63 | properties = mapOf( 64 | "myProp" to "value" 65 | ) 66 | packaging = "aar" 67 | inceptionYear = "2024" 68 | licenses { 69 | license { 70 | name = "The Apache License, Version 2.0" 71 | url = "http://www.apache.org/licenses/LICENSE-2.0.txt" 72 | } 73 | } 74 | developers { 75 | developer { 76 | id = "mattshoe" 77 | name = "Matthew Shoemaker" 78 | email = "mattshoe81@gmail.com" 79 | } 80 | } 81 | scm { 82 | connection = "scm:git:git@github.com:mattshoe/kdux.git" 83 | developerConnection = "scm:git:git@github.com:mattshoe/kdux.git" 84 | url = "https://github.com/mattshoe/kdux" 85 | } 86 | } 87 | } 88 | 89 | 90 | signing { 91 | val signingKey = providers.environmentVariable("GPG_SIGNING_KEY") 92 | val signingPassphrase = providers.environmentVariable("GPG_SIGNING_PASSPHRASE") 93 | if (signingKey.isPresent && signingPassphrase.isPresent) { 94 | useInMemoryPgpKeys(signingKey.get(), signingPassphrase.get()) 95 | sign(publishing.publications[PUBLICATION_NAME]) 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | tasks.register("generateZip") { 103 | val publishTask = tasks.named( 104 | "publish${PUBLICATION_NAME.replaceFirstChar { it.uppercaseChar() }}PublicationToMavenLocalRepository", 105 | PublishToMavenRepository::class.java 106 | ) 107 | from(publishTask.map { it.repository.url }) 108 | archiveFileName.set("${PUBLICATION_NAME}_${VERSION}.zip") 109 | } 110 | } -------------------------------------------------------------------------------- /Kdux-kotlinx-serialization/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.serialization) 3 | kotlin("jvm") 4 | id("maven-publish") 5 | signing 6 | } 7 | 8 | dependencies { 9 | implementation(project(":Kdux")) 10 | implementation(libs.kotlin.serialization) 11 | 12 | testImplementation(kotlin("test")) 13 | testImplementation(libs.test.truth) 14 | testImplementation(libs.test.turbine) 15 | testImplementation(libs.test.kotlin.coroutines) 16 | testImplementation(libs.test.mockk) 17 | } 18 | 19 | val GROUP_ID: String = project.properties["group.id"].toString() 20 | val VERSION: String = project.properties["version"].toString() 21 | val ARTIFACT_ID: String = "Kdux-kotlinx-serialization" 22 | val PUBLICATION_NAME = "KduxKotlinxSerialization" 23 | 24 | kotlin { 25 | jvmToolchain(17) 26 | } 27 | 28 | plugins.withId("java") { 29 | java { 30 | withJavadocJar() 31 | withSourcesJar() 32 | } 33 | } 34 | 35 | afterEvaluate { 36 | plugins.withId("maven-publish") { 37 | publishing { 38 | publications { 39 | repositories { 40 | maven { 41 | name = "Nexus" 42 | url = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") 43 | 44 | credentials { 45 | username = System.getenv("OSSRH_USERNAME") ?: "" 46 | password = System.getenv("OSSRH_PASSWORD") ?: "" 47 | } 48 | } 49 | mavenLocal() 50 | } 51 | 52 | create(PUBLICATION_NAME) { 53 | from(components["java"]) 54 | groupId = GROUP_ID 55 | artifactId = ARTIFACT_ID 56 | version = VERSION 57 | pom { 58 | name = "Kdux-kotlinx-serialization" 59 | description = """ 60 | Kdux-kotlinx-serialization is an add-on to the Kdux library to add built-in support for Kotlinx Serialization serialization. 61 | """.trimIndent() 62 | url = "https://github.com/mattshoe/kdux" 63 | properties = mapOf( 64 | "myProp" to "value" 65 | ) 66 | packaging = "aar" 67 | inceptionYear = "2024" 68 | licenses { 69 | license { 70 | name = "The Apache License, Version 2.0" 71 | url = "http://www.apache.org/licenses/LICENSE-2.0.txt" 72 | } 73 | } 74 | developers { 75 | developer { 76 | id = "mattshoe" 77 | name = "Matthew Shoemaker" 78 | email = "mattshoe81@gmail.com" 79 | } 80 | } 81 | scm { 82 | connection = "scm:git:git@github.com:mattshoe/kdux.git" 83 | developerConnection = "scm:git:git@github.com:mattshoe/kdux.git" 84 | url = "https://github.com/mattshoe/kdux" 85 | } 86 | } 87 | } 88 | 89 | 90 | signing { 91 | val signingKey = providers.environmentVariable("GPG_SIGNING_KEY") 92 | val signingPassphrase = providers.environmentVariable("GPG_SIGNING_PASSPHRASE") 93 | if (signingKey.isPresent && signingPassphrase.isPresent) { 94 | useInMemoryPgpKeys(signingKey.get(), signingPassphrase.get()) 95 | sign(publishing.publications[PUBLICATION_NAME]) 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | tasks.register("generateZip") { 103 | val publishTask = tasks.named( 104 | "publish${PUBLICATION_NAME.replaceFirstChar { it.uppercaseChar() }}PublicationToMavenLocalRepository", 105 | PublishToMavenRepository::class.java 106 | ) 107 | from(publishTask.map { it.repository.url }) 108 | archiveFileName.set("${PUBLICATION_NAME}_${VERSION}.zip") 109 | } 110 | } -------------------------------------------------------------------------------- /Kdux/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | id("maven-publish") 4 | signing 5 | } 6 | 7 | dependencies { 8 | implementation(libs.kotlin.coroutines) 9 | 10 | testImplementation(kotlin("test")) 11 | testImplementation(libs.test.truth) 12 | testImplementation(libs.test.turbine) 13 | testImplementation(libs.test.kotlin.coroutines) 14 | testImplementation(libs.test.mockk) 15 | } 16 | 17 | val GROUP_ID: String = project.properties["group.id"].toString() 18 | val VERSION: String = project.properties["version"].toString() 19 | val ARTIFACT_ID: String = "Kdux" 20 | val PUBLICATION_NAME = "Kdux" 21 | 22 | kotlin { 23 | jvmToolchain(17) 24 | } 25 | 26 | plugins.withId("java") { 27 | java { 28 | withJavadocJar() 29 | withSourcesJar() 30 | } 31 | } 32 | 33 | afterEvaluate { 34 | plugins.withId("maven-publish") { 35 | publishing { 36 | publications { 37 | repositories { 38 | maven { 39 | name = "Nexus" 40 | url = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") 41 | 42 | credentials { 43 | username = System.getenv("OSSRH_USERNAME") ?: "" 44 | password = System.getenv("OSSRH_PASSWORD") ?: "" 45 | } 46 | } 47 | mavenLocal() 48 | } 49 | 50 | create(PUBLICATION_NAME) { 51 | from(components["java"]) 52 | groupId = GROUP_ID 53 | artifactId = ARTIFACT_ID 54 | version = VERSION 55 | pom { 56 | name = "Kdux" 57 | description = """ 58 | Kdux is a Kotlin-based, platform-agnostic state management library that implements the Redux pattern, 59 | providing structured concurrency with built-in coroutine support. It is designed to integrate seamlessly 60 | with any Kotlin project, particularly excelling in Android applications using MVI architecture. 61 | """.trimIndent() 62 | url = "https://github.com/mattshoe/kdux" 63 | properties = mapOf( 64 | "myProp" to "value" 65 | ) 66 | packaging = "aar" 67 | inceptionYear = "2024" 68 | licenses { 69 | license { 70 | name = "The Apache License, Version 2.0" 71 | url = "http://www.apache.org/licenses/LICENSE-2.0.txt" 72 | } 73 | } 74 | developers { 75 | developer { 76 | id = "mattshoe" 77 | name = "Matthew Shoemaker" 78 | email = "mattshoe81@gmail.com" 79 | } 80 | } 81 | scm { 82 | connection = "scm:git:git@github.com:mattshoe/kdux.git" 83 | developerConnection = "scm:git:git@github.com:mattshoe/kdux.git" 84 | url = "https://github.com/mattshoe/kdux" 85 | } 86 | } 87 | } 88 | 89 | 90 | signing { 91 | val signingKey = providers.environmentVariable("GPG_SIGNING_KEY") 92 | val signingPassphrase = providers.environmentVariable("GPG_SIGNING_PASSPHRASE") 93 | if (signingKey.isPresent && signingPassphrase.isPresent) { 94 | useInMemoryPgpKeys(signingKey.get(), signingPassphrase.get()) 95 | sign(publishing.publications[PUBLICATION_NAME]) 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | tasks.register("generateZip") { 103 | val publishTask = tasks.named( 104 | "publish${PUBLICATION_NAME.replaceFirstChar { it.uppercaseChar() }}PublicationToMavenLocalRepository", 105 | PublishToMavenRepository::class.java 106 | ) 107 | from(publishTask.map { it.repository.url }) 108 | archiveFileName.set("${PUBLICATION_NAME}_${VERSION}.zip") 109 | } 110 | } -------------------------------------------------------------------------------- /Kdux-devtools/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | id("maven-publish") 4 | signing 5 | } 6 | 7 | dependencies { 8 | implementation(project(":Kdux")) 9 | implementation(project(":Kdux-devtools-common")) 10 | implementation(libs.kotlin.coroutines) 11 | implementation(libs.kotlin.serialization) 12 | 13 | implementation(libs.ktor.client.cio) 14 | implementation(libs.ktor.client.core) 15 | implementation(libs.ktor.server.netty) 16 | implementation(libs.ktor.client.websockets) 17 | 18 | testImplementation(kotlin("test")) 19 | testImplementation(libs.test.truth) 20 | testImplementation(libs.test.turbine) 21 | testImplementation(libs.test.kotlin.coroutines) 22 | testImplementation(libs.test.mockk) 23 | testImplementation(libs.gson) 24 | } 25 | 26 | val GROUP_ID: String = project.properties["group.id"].toString() 27 | val VERSION: String = project.properties["version"].toString() 28 | val ARTIFACT_ID: String = "Kdux-devtools" 29 | val PUBLICATION_NAME = "KduxDevTools" 30 | 31 | kotlin { 32 | jvmToolchain(17) 33 | } 34 | 35 | plugins.withId("java") { 36 | java { 37 | withJavadocJar() 38 | withSourcesJar() 39 | } 40 | } 41 | 42 | afterEvaluate { 43 | plugins.withId("maven-publish") { 44 | publishing { 45 | publications { 46 | repositories { 47 | maven { 48 | name = "Nexus" 49 | url = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") 50 | 51 | credentials { 52 | username = System.getenv("OSSRH_USERNAME") ?: "" 53 | password = System.getenv("OSSRH_PASSWORD") ?: "" 54 | } 55 | } 56 | mavenLocal() 57 | } 58 | 59 | create(PUBLICATION_NAME) { 60 | from(components["java"]) 61 | groupId = GROUP_ID 62 | artifactId = ARTIFACT_ID 63 | version = VERSION 64 | pom { 65 | name = "Kdux-devtools" 66 | description = """ 67 | Kdux-devtools enables live inspection and time-travel debugging for Kdux Stores. 68 | """.trimIndent() 69 | url = "https://github.com/mattshoe/kdux" 70 | properties = mapOf( 71 | "myProp" to "value" 72 | ) 73 | packaging = "aar" 74 | inceptionYear = "2024" 75 | licenses { 76 | license { 77 | name = "The Apache License, Version 2.0" 78 | url = "http://www.apache.org/licenses/LICENSE-2.0.txt" 79 | } 80 | } 81 | developers { 82 | developer { 83 | id = "mattshoe" 84 | name = "Matthew Shoemaker" 85 | email = "mattshoe81@gmail.com" 86 | } 87 | } 88 | scm { 89 | connection = "scm:git:git@github.com:mattshoe/kdux.git" 90 | developerConnection = "scm:git:git@github.com:mattshoe/kdux.git" 91 | url = "https://github.com/mattshoe/kdux" 92 | } 93 | } 94 | } 95 | 96 | 97 | signing { 98 | val signingKey = providers.environmentVariable("GPG_SIGNING_KEY") 99 | val signingPassphrase = providers.environmentVariable("GPG_SIGNING_PASSPHRASE") 100 | if (signingKey.isPresent && signingPassphrase.isPresent) { 101 | useInMemoryPgpKeys(signingKey.get(), signingPassphrase.get()) 102 | sign(publishing.publications[PUBLICATION_NAME]) 103 | } 104 | } 105 | } 106 | } 107 | } 108 | 109 | tasks.register("generateZip") { 110 | val publishTask = tasks.named( 111 | "publish${PUBLICATION_NAME.replaceFirstChar { it.uppercaseChar() }}PublicationToMavenLocalRepository", 112 | PublishToMavenRepository::class.java 113 | ) 114 | from(publishTask.map { it.repository.url }) 115 | archiveFileName.set("${PUBLICATION_NAME}_${VERSION}.zip") 116 | } 117 | } -------------------------------------------------------------------------------- /Kdux/src/test/kotlin/kdux/tools/ThrottleEnhancerTest.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import app.cash.turbine.test 4 | import com.google.common.truth.Truth.assertThat 5 | import kdux.store 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.runBlocking 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.mattshoe.shoebox.kdux.Reducer 12 | import org.mattshoe.shoebox.kdux.Store 13 | import kotlin.test.assertFailsWith 14 | import kotlin.time.Duration 15 | import kotlin.time.Duration.Companion.milliseconds 16 | import kotlin.time.measureTime 17 | 18 | class ThrottleEnhancerTest { 19 | 20 | private lateinit var store: Store 21 | private val initialState = 0 22 | 23 | sealed class TestAction { 24 | object Increment : TestAction() 25 | } 26 | 27 | private class TestReducer : Reducer { 28 | override suspend fun reduce(state: Int, action: TestAction): Int { 29 | return when (action) { 30 | is TestAction.Increment -> state + 1 31 | } 32 | } 33 | } 34 | 35 | @Before 36 | fun setUp() { 37 | store = store( 38 | initialState = initialState, 39 | reducer = TestReducer() 40 | ) { 41 | throttle(500.milliseconds) 42 | } 43 | } 44 | 45 | @Test 46 | fun `WHEN actions are dispatched rapidly THEN they are throttled correctly`() = runBlocking { 47 | store.state.test { 48 | assertThat(awaitItem()).isEqualTo(0) 49 | 50 | var timeTaken = measureTime { 51 | store.dispatch(TestAction.Increment) 52 | awaitItem() 53 | } 54 | assertThat(timeTaken).isLessThan(50.milliseconds) 55 | 56 | timeTaken = measureTime { 57 | store.dispatch(TestAction.Increment) 58 | awaitItem() 59 | } 60 | assertThat(timeTaken).isGreaterThan(500.milliseconds) 61 | assertThat(timeTaken).isLessThan(550.milliseconds) 62 | 63 | timeTaken = measureTime { 64 | store.dispatch(TestAction.Increment) 65 | awaitItem() 66 | } 67 | assertThat(timeTaken).isGreaterThan(500.milliseconds) 68 | assertThat(timeTaken).isLessThan(550.milliseconds) 69 | 70 | expectNoEvents() 71 | } 72 | } 73 | 74 | @OptIn(ExperimentalCoroutinesApi::class) 75 | @Test 76 | fun `WHEN dispatch is called in parallel THEN actions are queued and throttled`() = runBlocking { 77 | store.state.test { 78 | assertThat(awaitItem()).isEqualTo(0) 79 | 80 | val timeTaken = measureTime { 81 | store.dispatch(TestAction.Increment) 82 | store.dispatch(TestAction.Increment) 83 | store.dispatch(TestAction.Increment) 84 | 85 | assertThat(awaitItem()).isEqualTo(1) 86 | assertThat(awaitItem()).isEqualTo(2) 87 | assertThat(awaitItem()).isEqualTo(3) 88 | expectNoEvents() 89 | } 90 | 91 | assertThat(timeTaken).isGreaterThan(1000.milliseconds) 92 | assertThat(timeTaken).isLessThan(1100.milliseconds) 93 | } 94 | } 95 | 96 | @Test 97 | fun `WHEN dispatch is called with delay THEN each action is processed immediately`() = runBlocking { 98 | store.state.test { 99 | assertThat(awaitItem()).isEqualTo(0) 100 | 101 | store.dispatch(TestAction.Increment) 102 | assertThat(awaitItem()).isEqualTo(1) 103 | 104 | delay(600) // Delay longer than the throttle interval 105 | 106 | var timeTaken = measureTime { 107 | store.dispatch(TestAction.Increment) 108 | assertThat(awaitItem()).isEqualTo(2) 109 | } 110 | assertThat(timeTaken).isLessThan(50.milliseconds) 111 | 112 | delay(600) // Delay longer than the throttle interval 113 | 114 | timeTaken = measureTime { 115 | store.dispatch(TestAction.Increment) 116 | assertThat(awaitItem()).isEqualTo(3) 117 | } 118 | assertThat(timeTaken).isLessThan(50.milliseconds) 119 | 120 | expectNoEvents() 121 | } 122 | } 123 | 124 | @Test 125 | fun `WHEN throttle duration is set to less than zero THEN throw exception`() { 126 | val exception = assertFailsWith { 127 | ThrottleEnhancer(Duration.ZERO) 128 | } 129 | assertThat(exception).hasMessageThat().contains("Throttle interval must be greater than zero.") 130 | } 131 | } -------------------------------------------------------------------------------- /Kdux/src/test/kotlin/kdux/tools/DebounceEnhancerTest.kt: -------------------------------------------------------------------------------- 1 | package kdux.tools 2 | 3 | import app.cash.turbine.test 4 | import com.google.common.truth.Truth.assertThat 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.StateFlow 9 | import kotlinx.coroutines.launch 10 | import kotlinx.coroutines.runBlocking 11 | import kotlinx.coroutines.sync.Mutex 12 | import kotlinx.coroutines.sync.withLock 13 | import kotlinx.coroutines.test.advanceTimeBy 14 | import kotlinx.coroutines.test.runBlockingTest 15 | import kotlinx.coroutines.test.runTest 16 | import org.junit.Before 17 | import org.junit.Test 18 | import org.mattshoe.shoebox.kdux.Reducer 19 | import org.mattshoe.shoebox.kdux.Store 20 | import kotlin.test.assertFailsWith 21 | import kotlin.time.Duration.Companion.milliseconds 22 | import kotlin.time.Duration.Companion.seconds 23 | import kotlin.time.ExperimentalTime 24 | 25 | @OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class) 26 | class DebounceEnhancerTest { 27 | 28 | private lateinit var store: Store 29 | private val initialState = 0 30 | 31 | sealed class TestAction { 32 | object Increment : TestAction() 33 | } 34 | 35 | private class TestReducer() : Reducer { 36 | 37 | override suspend fun reduce(state: Int, action: TestAction): Int { 38 | return when (action) { 39 | TestAction.Increment -> state + 1 40 | } 41 | } 42 | } 43 | 44 | @Before 45 | fun setUp() { 46 | store = kdux.store( 47 | initialState, 48 | TestReducer() 49 | ) { 50 | debounce(500.milliseconds) 51 | } 52 | } 53 | 54 | @Test 55 | fun `WHEN dispatch is called rapidly THEN only one action is processed`() = runTest { 56 | store.state.test { 57 | assertThat(awaitItem()).isEqualTo(0) 58 | 59 | store.dispatch(TestAction.Increment) 60 | store.dispatch(TestAction.Increment) 61 | store.dispatch(TestAction.Increment) 62 | store.dispatch(TestAction.Increment) 63 | 64 | assertThat(awaitItem()).isEqualTo(1) 65 | 66 | advanceTimeBy(2000) // Advance time to exceed debounce duration 67 | 68 | // Only the first action is processed 69 | expectNoEvents() // No further state changes 70 | } 71 | } 72 | 73 | @Test 74 | fun `WHEN dispatch is called with sufficient delay THEN actions are processed`() = runBlocking { 75 | store.state.test { 76 | assertThat(awaitItem()).isEqualTo(0) 77 | 78 | store.dispatch(TestAction.Increment) 79 | assertThat(awaitItem()).isEqualTo(1) 80 | 81 | delay(700) 82 | 83 | store.dispatch(TestAction.Increment) 84 | assertThat(awaitItem()).isEqualTo(2) 85 | } 86 | } 87 | 88 | @Test 89 | fun `WHEN dispatch is called in parallel THEN only one action is processed`() = runBlocking { 90 | store.state.test { 91 | assertThat(awaitItem()).isEqualTo(0) 92 | 93 | launch { store.dispatch(TestAction.Increment) } 94 | launch { store.dispatch(TestAction.Increment) } 95 | launch { store.dispatch(TestAction.Increment) } 96 | delay(700) // Advance time to exceed debounce duration 97 | 98 | assertThat(awaitItem()).isEqualTo(1) 99 | expectNoEvents() 100 | } 101 | } 102 | 103 | @Test 104 | fun `WHEN duration is set to less than zero THEN throw exception`() { 105 | val exception = assertFailsWith { 106 | DebounceEnhancer(0.seconds) 107 | } 108 | assertThat(exception).hasMessageThat().contains("Debounce duration must be greater than zero.") 109 | } 110 | 111 | @Test 112 | fun `WHEN multiple actions dispatched with delays THEN correct actions processed`() = runBlocking { 113 | store.state.test { 114 | assertThat(awaitItem()).isEqualTo(0) 115 | 116 | store.dispatch(TestAction.Increment) 117 | delay(700) // Advance time to exceed debounce duration 118 | assertThat(awaitItem()).isEqualTo(1) 119 | 120 | delay(500) // Advance less than debounce duration 121 | store.dispatch(TestAction.Increment) 122 | delay(500) // Now exceed the debounce duration 123 | assertThat(awaitItem()).isEqualTo(2) 124 | 125 | store.dispatch(TestAction.Increment) 126 | delay(1000) // Advance time to exceed debounce duration 127 | assertThat(awaitItem()).isEqualTo(3) 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /Kdux-devtools/src/main/kotlin/org/mattshoe/shoebox/devtools/DevToolsExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.mattshoe.shoebox.devtools 2 | 3 | import kdux.dsl.StoreDslMenu 4 | import org.mattshoe.shoebox.org.mattsho.shoebox.devtools.common.Defaults 5 | 6 | object KduxHost { 7 | val ANDROID_EMULATOR = "10.0.2.2" 8 | } 9 | 10 | /** 11 | * DSL extension that enhances a Kdux `Store` by integrating it with an external server-based 12 | * debugging tool. It enables remote control of the store's state and actions through a WebSocket 13 | * connection, allowing users to track, replay, and override state transitions and dispatched actions 14 | * in real time. 15 | * 16 | * ### Features: 17 | * 18 | * - **Serialization/Deserialization**: Accepts custom serializer and deserializer functions for both 19 | * actions and state. These are necessary for converting actions and state into a transmittable format 20 | * (such as JSON) and reconstructing them from received data. 21 | * - **DevTools Integration**: The `DevToolsEnhancer` is automatically added to the store, allowing real-time 22 | * debugging and manipulation of the store from a remote server. 23 | * - **Flexible Serialization**: The function is generic, supporting any `State` and `Action` types as long 24 | * as they can be serialized and deserialized using the provided functions. 25 | * 26 | * @param actionSerializer A suspend function that serializes an `Action` to a `String` for transmission 27 | * to the WebSocket server. 28 | * @param actionDeserializer A suspend function that deserializes a `String` into an `Action` from data 29 | * received over the WebSocket. 30 | * @param stateSerializer A suspend function that serializes the `State` to a `String` for transmission 31 | * to the WebSocket server. 32 | * @param stateDeserializer A suspend function that deserializes a `String` into a `State` from data 33 | * received over the WebSocket. 34 | * @param State The type representing the store's state. 35 | * @param Action The type representing actions that can be dispatched to the store. 36 | */ 37 | inline fun StoreDslMenu.devtools( 38 | host: String = Defaults.HOST, 39 | port: Int = Defaults.PORT, 40 | noinline actionSerializer: suspend (Action) -> String, 41 | noinline actionDeserializer: suspend (org.mattsho.shoebox.devtools.common.Action) -> Action, 42 | noinline stateSerializer: suspend (State) -> String, 43 | noinline stateDeserializer: suspend (org.mattsho.shoebox.devtools.common.State) -> State 44 | ) { 45 | add( 46 | DevToolsEnhancer( 47 | host, 48 | port, 49 | actionSerializer, 50 | actionDeserializer, 51 | stateSerializer, 52 | stateDeserializer 53 | ) 54 | ) 55 | } 56 | 57 | /** 58 | * DSL extension that enhances a Kdux `Store` by integrating it with an external server-based 59 | * debugging tool using a `DevToolsSerializer` for state and action serialization. 60 | * 61 | * This extension simplifies the process of integrating debugging tools by accepting a single 62 | * `DevToolsSerializer` object that encapsulates all serialization and deserialization logic 63 | * for both the state and actions. The `DevToolsEnhancer` is then added to the store, allowing 64 | * for real-time debugging and control over the store's state transitions and actions. 65 | * 66 | * ### Features: 67 | * 68 | * - **Simplified Serializer Interface**: This extension accepts a `DevToolsSerializer` object, 69 | * which encapsulates both action and state serialization logic. This makes it easier to manage 70 | * the serialization/deserialization process in a single place. 71 | * - **DevTools Integration**: The `DevToolsEnhancer` is automatically added to the store, enabling 72 | * real-time debugging and manipulation from an external server over a WebSocket connection. 73 | * - **Flexible Serialization**: Supports any `State` and `Action` types as long as they can be 74 | * serialized and deserialized using the provided `DevToolsSerializer` functions. 75 | * 76 | * @param serializer A `DevToolsSerializer` object that provides the serialization and deserialization 77 | * logic for both the state and actions. 78 | * @param State The type representing the store's state. 79 | * @param Action The type representing actions that can be dispatched to the store. 80 | */ 81 | inline fun StoreDslMenu.devtools( 82 | host: String = Defaults.HOST, 83 | port: Int = Defaults.PORT, 84 | serializer: DevToolsSerializer 85 | ) { 86 | add( 87 | DevToolsEnhancer( 88 | host, 89 | port, 90 | serializer::serializeAction, 91 | serializer::deserializeAction, 92 | serializer::serializeState, 93 | serializer::deserializeState 94 | ) 95 | ) 96 | } -------------------------------------------------------------------------------- /docs/reducer.md: -------------------------------------------------------------------------------- 1 | ## Reducer 2 | 3 | The `Reducer` is a core component in a Redux-like architecture, responsible for processing actions and determining how 4 | the application's state should change in response to those actions. 5 | 6 | ### Key Characteristics 7 | 8 | - **Pure Function**: Reducers are pure functions, meaning they do not produce side effects. Given the same state and 9 | action, they will always return the same new state without modifying the original state. 10 | - **Immutability**: Reducers treat the state as immutable. Instead of modifying the existing state, they return a new 11 | instance with the necessary changes. 12 | - **Action-Driven**: Reducers are driven by actions that represent events or intents within the application. Each action 13 | contains logic describing what happened, and the reducer determines how the state should change in response. 14 | 15 | ### How Reducers Work 16 | 17 | 1. **Receive Action**: The reducer receives an action dispatched by the store after it has passed through all 18 | middleware if present. 19 | 2. **Process State**: The reducer examines the current state and the dispatched action to determine how the state should 20 | change. 21 | 3. **Return New State**: The reducer creates a new state object based on the logic associated with the action and 22 | returns it. The store then updates its state to the new state returned by the reducer. 23 | 24 | ### Principles of Reducer Design 25 | 26 | - **Immutability**: The reducer must never modify the original state directly. Instead, it should return a new state 27 | object with the necessary updates. In Kotlin, this is typically achieved by using `data class` copy methods, which 28 | allow you to create modified copies of an immutable object. 29 | - **Action-Driven Logic**: The reducer is entirely action-driven. Each action should represent an event or intent, and 30 | the reducer's logic should handle that action to produce a new state. Reducers can handle multiple types of actions, 31 | and you can use Kotlin's `when` expression to branch based on the type of action received. 32 | - **Deterministic**: Given the same inputs (state and action), a reducer must always produce the same output. This 33 | predictability makes the application easier to test and reason about. 34 | - **No Side Effects**: Reducers should not trigger side effects like network requests or database writes. These types of 35 | operations are handled in middleware. The reducer's job is solely to compute the next state. 36 | 37 | ### Reducer Lifecycle in the Store 38 | 39 | The reducer is a critical part of the store's lifecycle. When an action is dispatched to the store: 40 | 41 | 1. **Action Dispatch**: The store receives the dispatched action. 42 | 2. **Middleware Processing**: The action passes through the middleware chain, which may modify or intercept the action. 43 | 3. **Reducer Invocation**: The store invokes the reducer with the current state and the processed action. 44 | 4. **New State Calculation**: The reducer processes the action and returns a new state based on the action's type and 45 | payload. 46 | 5. **State Update**: The store updates its state to the new state returned by the reducer. 47 | 6. **State Notification**: The store emits the new state to all subscribers, allowing the application to react to the 48 | state change. 49 | 50 | ### Example Use Case 51 | 52 | In a shopping cart application, the state might represent the items in the cart, and the reducer might handle actions 53 | like adding an item, removing an item, or clearing the cart. The reducer would take the current cart state and the 54 | dispatched action (e.g., `AddItemAction`) and return a new state that includes the updated cart contents. 55 | 56 | ```kotlin 57 | data class CartState(val items: List) 58 | 59 | sealed class CartAction { 60 | data class AddItem(val item: Item) : CartAction() 61 | data class RemoveItem(val itemId: String) : CartAction() 62 | object ClearCart : CartAction() 63 | } 64 | 65 | class CartReducer : Reducer { 66 | override suspend fun reduce(state: CartState, action: CartAction): CartState { 67 | return when (action) { 68 | is CartAction.AddItem -> state.copy(items = state.items + action.item) 69 | is CartAction.RemoveItem -> state.copy(items = state.items.filter { it.id != action.itemId }) 70 | is CartAction.ClearCart -> state.copy(items = emptyList()) 71 | } 72 | } 73 | } 74 | ``` 75 | 76 | ### Conclusion 77 | 78 | The Reducer interface plays a central role in determining how the state evolves over time in response to actions. It is 79 | the pure function that handles state transitions, ensuring that the state is updated predictably and immutably. Reducers 80 | are designed to be simple, deterministic, and free of side effects, ensuring that the state management system remains 81 | consistent, testable, and predictable. 82 | -------------------------------------------------------------------------------- /docs/third_party_support.md: -------------------------------------------------------------------------------- 1 | # Third Party Integration 2 | 3 | Kdux allows you to mix-in support for third party libraries as you see fit. Kdux aims to support all major relevant 4 | third party libraries to provide a seamless and efficient development experience. 5 | 6 | By breaking out support into numerous "add-on" dependencies, you can pick and choose only the support you need, so that 7 | you can keep your application size as small as possible. 8 | 9 |
10 |
11 | 12 | # Android 13 | 14 | Kdux provides full integration with the Android SDK, leveraging LogCat, on-device caching, `Parcelable` serialization, 15 | `WorkManager` integration, and all other relevant features of the Android platform. 16 | 17 | #### Dependency 18 | ![Maven Central Version](https://img.shields.io/maven-central/v/org.mattshoe.shoebox/Kdux-android) 19 | 20 | ```kotlin 21 | dependencies { 22 | implementation("org.mattshoe.shoebox:Kdux-android:1.0.10") 23 | } 24 | ``` 25 | 26 | #### Usage 27 | 28 | ```kotlin 29 | // Configure Kdux globally to integrate with Android. Leverage LogCat, Parcelable, on-device caching, and more 30 | class MyApplication: Application() { 31 | override fun onCreate() { 32 | kdux { 33 | android(this@MyApplication) 34 | } 35 | } 36 | } 37 | 38 | // Now when constructing your stores, you can leverage Parcelable for serialization 39 | val store = kdux.store( 40 | initialValue = MyState(), 41 | reducer = MyReducer() 42 | ) { 43 | persistAsParcelable(key = "myGloballyUniqueKey") { state, error -> 44 | // handle error 45 | } 46 | } 47 | ``` 48 | 49 |
50 |
51 | 52 | # Serialization 53 | 54 | ## Kotlinx Serialization 55 | 56 | Kdux supports [Kotlinx Serialization](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/serialization-guide.md) for its [State Persistence](persistence_enhancer.md) functionality. 57 | 58 | #### Dependency 59 | ![Maven Central Version](https://img.shields.io/maven-central/v/org.mattshoe.shoebox/Kdux-kotlinx-serialization) 60 | 61 | ```kotlin 62 | dependencies { 63 | implementation("org.mattshoe.shoebox:Kdux-kotlinx-serialization:1.0.10") 64 | } 65 | ``` 66 | 67 | #### Usage 68 | 69 | ```kotlin 70 | val store = kdux.store( 71 | initialValue = MyState(), 72 | reducer = MyReducer() 73 | ) { 74 | persistWithKotlinxSerialization( 75 | key = "myGloballyUniqueKey" 76 | ) { state, error -> 77 | // handle error 78 | } 79 | } 80 | ``` 81 | 82 | ## Gson 83 | 84 | Kdux supports [Gson](https://github.com/google/gson) for its [State Persistence](persistence_enhancer.md) functionality. 85 | 86 | #### Dependency 87 | ![Maven Central Version](https://img.shields.io/maven-central/v/org.mattshoe.shoebox/Kdux-gson) 88 | ```kotlin 89 | dependencies { 90 | implementation("org.mattshoe.shoebox:Kdux-gson:1.0.10") 91 | } 92 | ``` 93 | 94 | #### Usage 95 | 96 | ```kotlin 97 | val store = kdux.store( 98 | initialValue = MyState(), 99 | reducer = MyReducer() 100 | ) { 101 | persistWithGson( 102 | key = "myGloballyUniqueKey" 103 | ) { state, error -> 104 | // handle error 105 | } 106 | } 107 | ``` 108 | 109 | ## Moshi 110 | 111 | Kdux supports [Moshi](https://github.com/square/moshi) for its [State Persistence](persistence_enhancer.md) functionality. 112 | 113 | #### Dependency![Maven Central Version](https://img.shields.io/maven-central/v/org.mattshoe.shoebox/Kdux-moshi) 114 | 115 | ```kotlin 116 | dependencies { 117 | implementation("org.mattshoe.shoebox:Kdux-moshi:1.0.10") 118 | } 119 | ``` 120 | 121 | #### Usage 122 | 123 | ```kotlin 124 | val store = kdux.store( 125 | initialValue = MyState(), 126 | reducer = MyReducer() 127 | ) { 128 | persistWithMoshi( 129 | key = "myGloballyUniqueKey" 130 | ) { state, error -> 131 | // handle error 132 | } 133 | } 134 | ``` 135 | 136 | ## ProtoBuf (coming soon) 137 | 138 | Kdux supports Google's [ProtoBuf](https://github.com/square/moshi) serialization for its [State Persistence](persistence_enhancer.md) functionality. 139 | 140 | #### Dependency 141 | ```kotlin 142 | dependencies { 143 | implementation("org.mattshoe.shoebox:Kdux-protobuf:1.0.10") 144 | } 145 | ``` 146 | 147 | #### Usage 148 | 149 | ```kotlin 150 | val store = kdux.store( 151 | initialValue = MyState(), 152 | reducer = MyReducer() 153 | ) { 154 | persistWithProtoBuf( 155 | key = "myGloballyUniqueKey" 156 | ) { state, error -> 157 | // handle error 158 | } 159 | } 160 | ``` 161 | 162 | ## Jackson (coming soon) 163 | 164 | Kdux supports [Jackson](https://github.com/FasterXML/jackson-module-kotlin) serialization for its [State Persistence](persistence_enhancer.md) functionality. 165 | 166 | #### Dependency 167 | ```kotlin 168 | dependencies { 169 | implementation("org.mattshoe.shoebox:Kdux-jackson:1.0.10") 170 | } 171 | ``` 172 | 173 | #### Usage 174 | 175 | ```kotlin 176 | val store = kdux.store( 177 | initialValue = MyState(), 178 | reducer = MyReducer() 179 | ) { 180 | persistWithJackson( 181 | key = "myGloballyUniqueKey" 182 | ) { state, error -> 183 | // handle error 184 | } 185 | } 186 | ``` 187 | 188 | 189 | 190 | 191 | --------------------------------------------------------------------------------