From b9d7b5b0baa0eed9c6891bcadb26395e2784977b Mon Sep 17 00:00:00 2001 From: Dmitrii Berdnikov Date: Mon, 12 Sep 2022 13:38:26 +0300 Subject: [PATCH 01/87] Rewrite elmslie-core to Kotlin Coroutines; update tests and docs. --- elmslie-android/build.gradle | 1 + .../money/elmslie/android/screen/ElmScreen.kt | 107 ++--- .../elmslie/android/util/LifecycleExt.kt | 34 -- .../money/elmslie/compose/EffectWithKey.kt | 12 - .../elmslie/compose/ElmComponentActivity.kt | 11 +- .../elmslie/compose/ElmComponentFragment.kt | 11 +- .../elmslie/compose/util/SubscribeAsState.kt | 28 -- elmslie-core/build.gradle | 8 + .../elmslie/core/config/ElmslieConfig.kt | 33 +- .../core/disposable/CompositeDisposable.kt | 22 -- .../elmslie/core/disposable/Disposable.kt | 8 - .../money/elmslie/core/store/DefaultActor.kt | 12 +- .../money/elmslie/core/store/ElmStore.kt | 144 +++---- .../money/elmslie/core/store/NoOpActor.kt | 14 +- .../vivid/money/elmslie/core/store/Result.kt | 16 +- .../vivid/money/elmslie/core/store/Store.kt | 46 +-- .../core/store/binding/ConversationRules.kt | 41 +- .../core/store/binding/ConversionContract.kt | 78 ++-- .../core/store/binding/Coordination.kt | 7 +- .../money/elmslie/core/switcher/Switcher.kt | 64 +-- .../elmslie/core/util/ConcurrentHashSet.kt | 6 - .../elmslie/core/util/DistinctUntilChanged.kt | 17 - .../vivid/money/elmslie/core/util/Option.kt | 6 - .../money/elmslie/core/store/ElmStoreTest.kt | 274 +++++++++---- .../core/store/ElmStoreWithChildTest.kt | 372 +++++++++--------- elmslie-coroutines/build.gradle | 14 +- .../elmslie/coroutines/ElmStoreCompat.kt | 58 +-- .../elmslie/coroutines/SwitcherCompat.kt | 14 +- elmslie-rxjava-2/build.gradle | 1 + .../vivid/money/elmslie/rx2/ElmStoreCompat.kt | 52 +-- .../elmslie/rx2/switcher/SwitcherCompat.kt | 34 +- elmslie-rxjava-3/build.gradle | 1 + .../vivid/money/elmslie/rx3/ElmStoreCompat.kt | 52 +-- .../elmslie/rx3/switcher/SwitcherCompat.kt | 43 +- .../elmslie/samples/android/loader/App.kt | 1 + .../android/compose/view/PagingFragment.kt | 20 +- .../android/compose/view/PagingScreen.kt | 8 +- elmslie-samples/java-notes/build.gradle | 2 + .../samples/notes/store/NotesActor.java | 9 +- .../elmslie/samples/notes/NotesTest.java | 8 + .../kotlin-calculator/build.gradle | 7 + .../elmslie/samples/calculator/StoreKtTest.kt | 36 +- elmslie-test/build.gradle | 7 + .../executor/TestDispatcherExtension.kt | 28 ++ gradle/dependencies.gradle | 3 + 45 files changed, 839 insertions(+), 931 deletions(-) delete mode 100644 elmslie-android/src/main/java/vivid/money/elmslie/android/util/LifecycleExt.kt delete mode 100644 elmslie-compose/src/main/java/vivid/money/elmslie/compose/EffectWithKey.kt delete mode 100644 elmslie-compose/src/main/java/vivid/money/elmslie/compose/util/SubscribeAsState.kt delete mode 100644 elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/CompositeDisposable.kt delete mode 100644 elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/Disposable.kt delete mode 100644 elmslie-core/src/main/java/vivid/money/elmslie/core/util/ConcurrentHashSet.kt delete mode 100644 elmslie-core/src/main/java/vivid/money/elmslie/core/util/DistinctUntilChanged.kt delete mode 100644 elmslie-core/src/main/java/vivid/money/elmslie/core/util/Option.kt create mode 100644 elmslie-test/src/main/java/vivid/money/elmslie/test/background/executor/TestDispatcherExtension.kt diff --git a/elmslie-android/build.gradle b/elmslie-android/build.gradle index 674939ca..580c4b45 100644 --- a/elmslie-android/build.gradle +++ b/elmslie-android/build.gradle @@ -9,6 +9,7 @@ dependencies { implementation(deps.android.appcompat) implementation(deps.android.appStartup) implementation(deps.android.lifecycle) + implementation(deps.android.lifecycleKtx) } apply from: "../gradle/junit-5.gradle" diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt index dadb3021..53b9fa0c 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt +++ b/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt @@ -1,16 +1,16 @@ package vivid.money.elmslie.android.screen import android.app.Activity -import android.os.Handler -import android.os.Looper -import androidx.lifecycle.Lifecycle +import androidx.lifecycle.* import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.Lifecycle.State.STARTED +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import vivid.money.elmslie.android.processdeath.ProcessDeathDetector.isRestoringAfterProcessDeath import vivid.money.elmslie.android.processdeath.StopElmOnProcessDeath -import vivid.money.elmslie.android.util.bindToState -import vivid.money.elmslie.android.util.onCreate -import vivid.money.elmslie.android.util.postSingle import vivid.money.elmslie.core.config.ElmslieConfig class ElmScreen( @@ -19,61 +19,74 @@ class ElmScreen( private val activityProvider: () -> Activity, ) { - val store get() = delegate.storeHolder.store + val store + get() = delegate.storeHolder.store - private val stateHandler = Handler(Looper.getMainLooper()) - private val effectHandler = Handler(Looper.getMainLooper()) private val logger = ElmslieConfig.logger + private val ioDispatcher: CoroutineDispatcher = ElmslieConfig.ioDispatchers private var isAfterProcessDeath = false - private val isRenderable get() = screenLifecycle.currentState.isAtLeast(STARTED) + private val canRender + get() = screenLifecycle.currentState.isAtLeast(STARTED) init { with(screenLifecycle) { - onCreate(::saveProcessDeathState) - onCreate(::triggerInitEventIfNecessary) - bindToState(RESUMED, ::observeEffects) - bindToState(STARTED, ::observeStates) - } - } - - private fun observeEffects() = store.effects { - effectHandler.post { - catchEffectErrors { - delegate.handleEffect(it) + coroutineScope.launchWhenCreated { + saveProcessDeathState() + triggerInitEventIfNecessary() + } + coroutineScope.launch { + store + .effects() + .flowWithLifecycle( + lifecycle = screenLifecycle, + minActiveState = RESUMED, + ) + .collect { effect -> catchEffectErrors { delegate.handleEffect(effect) } } + } + coroutineScope.launch { + store + .states() + .flowWithLifecycle( + lifecycle = screenLifecycle, + minActiveState = STARTED, + ) + .map { state -> + val list = mapListItems(state) + state to list + } + .catch { logger.fatal("Crash while mapping state", it) } + .flowOn(ioDispatcher) + .collect { (state, listItems) -> + catchStateErrors { + if (canRender) { + delegate.renderList(state, listItems) + delegate.render(state) + } + } + } } } } - private fun observeStates() = store.states { - val list = mapListItems(it) - stateHandler.postSingle { renderListItems(it, list) } - } - - private fun mapListItems(state: State) = catchStateErrors { - delegate.mapList(state) - } ?: emptyList() - - private fun renderListItems(state: State, list: List) = catchStateErrors { - if (isRenderable) { - delegate.renderList(state, list) - delegate.render(state) - } - } + private fun mapListItems(state: State) = + catchStateErrors { delegate.mapList(state) } ?: emptyList() @Suppress("TooGenericExceptionCaught") - private fun catchStateErrors(action: () -> T?) = try { - action() - } catch (t: Throwable) { - logger.fatal("Crash while rendering state", t) - null - } + private fun catchStateErrors(action: () -> T?) = + try { + action() + } catch (t: Throwable) { + logger.fatal("Crash while rendering state", t) + null + } @Suppress("TooGenericExceptionCaught") - private fun catchEffectErrors(action: () -> T?) = try { - action() - } catch (t: Throwable) { - logger.fatal("Crash while handling effect", t) - } + private fun catchEffectErrors(action: () -> T?) = + try { + action() + } catch (t: Throwable) { + logger.fatal("Crash while handling effect", t) + } private fun saveProcessDeathState() { isAfterProcessDeath = isRestoringAfterProcessDeath diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/util/LifecycleExt.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/util/LifecycleExt.kt deleted file mode 100644 index 73097cdc..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/util/LifecycleExt.kt +++ /dev/null @@ -1,34 +0,0 @@ -package vivid.money.elmslie.android.util - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.Event.ON_CREATE -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import vivid.money.elmslie.core.disposable.Disposable - -/** - * Executes a given [action] at [ON_CREATE] event of the provided [Lifecycle] - */ -fun Lifecycle.onCreate( - action: () -> Unit -) = addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event == ON_CREATE) action() - } -}) - -/** - * Binds the specified [disposableProvider] to the lifecycle [state] - */ -fun Lifecycle.bindToState( - state: Lifecycle.State, - disposableProvider: () -> Disposable -) = addObserver(object : LifecycleEventObserver { - private var disposable: Disposable? = null - private val startEvent = Lifecycle.Event.upTo(state) - private val endEvent = Lifecycle.Event.downFrom(state) - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event == startEvent) disposable = disposableProvider() - if (event == endEvent) disposable?.dispose().also { disposable = null } - } -}) diff --git a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/EffectWithKey.kt b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/EffectWithKey.kt deleted file mode 100644 index efd49bec..00000000 --- a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/EffectWithKey.kt +++ /dev/null @@ -1,12 +0,0 @@ -package vivid.money.elmslie.compose - -import androidx.compose.runtime.Stable - -// Not a data class intentionally -@Stable -class EffectWithKey(val value: T) { - val key = this - - @Suppress("UNCHECKED_CAST") - inline fun takeIfInstanceOf() = takeIf { value is R } as EffectWithKey? -} diff --git a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt index 9e9fb7ab..7ee2271e 100644 --- a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt +++ b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt @@ -2,11 +2,9 @@ package vivid.money.elmslie.compose import androidx.activity.ComponentActivity import androidx.annotation.LayoutRes -import androidx.compose.runtime.Composable import vivid.money.elmslie.android.screen.ElmDelegate import vivid.money.elmslie.android.screen.ElmScreen import vivid.money.elmslie.android.storeholder.LifecycleAwareStoreHolder -import vivid.money.elmslie.compose.util.subscribeAsState abstract class ElmComponentActivity : ComponentActivity, ElmDelegate { @@ -18,15 +16,10 @@ abstract class ElmComponentActivity : @Suppress("LeakingThis", "UnusedPrivateMember") private val elm = ElmScreen(this, lifecycle) { this } - val store get() = storeHolder.store + val store + get() = storeHolder.store override val storeHolder = LifecycleAwareStoreHolder(lifecycle) { createStore()!! } - @Composable - fun state() = store::states.subscribeAsState(initial = store.currentState) - - @Composable - fun effect() = store::effects.subscribeAsState(::EffectWithKey, initial = null) - final override fun render(state: State) = Unit } diff --git a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt index eadb94e0..805107e3 100644 --- a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt +++ b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt @@ -6,10 +6,9 @@ import androidx.fragment.app.Fragment import vivid.money.elmslie.android.screen.ElmDelegate import vivid.money.elmslie.android.screen.ElmScreen import vivid.money.elmslie.android.storeholder.LifecycleAwareStoreHolder -import vivid.money.elmslie.compose.util.subscribeAsState -abstract class ElmComponentFragment : Fragment, - ElmDelegate { +abstract class ElmComponentFragment : + Fragment, ElmDelegate { constructor() : super() @@ -24,10 +23,4 @@ abstract class ElmComponentFragment : Fr override val storeHolder = LifecycleAwareStoreHolder(lifecycle) { createStore()!! } final override fun render(state: State) = Unit - - @Composable - fun state() = store::states.subscribeAsState(initial = store.currentState) - - @Composable - fun effect() = store::effects.subscribeAsState(::EffectWithKey, initial = null) } diff --git a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/util/SubscribeAsState.kt b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/util/SubscribeAsState.kt deleted file mode 100644 index ba940e67..00000000 --- a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/util/SubscribeAsState.kt +++ /dev/null @@ -1,28 +0,0 @@ -package vivid.money.elmslie.compose.util - -import androidx.compose.runtime.* -import vivid.money.elmslie.core.disposable.Disposable - -/** - * Subscribes to the callback and represents it's values via [State] - */ -@Composable -fun (((T) -> Unit) -> Disposable).subscribeAsState( - initial: T -): State = subscribeAsState({ it }, initial = initial) - -/** - * Subscribes to the callback and represents it's values via [State] with applied [transformation] - */ -@Composable -fun (((T) -> Unit) -> Disposable).subscribeAsState( - transformation: (T) -> V, - initial: V -): State { - val state = remember { mutableStateOf(initial) } - DisposableEffect(this) { - val disposable = this@subscribeAsState { state.value = transformation(it) } - onDispose { disposable.dispose() } - } - return state -} diff --git a/elmslie-core/build.gradle b/elmslie-core/build.gradle index 130380bf..e05a6b68 100644 --- a/elmslie-core/build.gradle +++ b/elmslie-core/build.gradle @@ -2,10 +2,18 @@ plugins { id("kotlin") } +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions.freeCompilerArgs += [ + "-opt-in=kotlin.RequiresOptIn" + ] +} + dependencies { + implementation(deps.coroutines.core) testImplementation(project(":elmslie-test")) testImplementation(deps.test.kotestAssertions) testImplementation(deps.test.kotestProperty) + testImplementation(deps.coroutines.test) testRuntimeOnly(deps.test.kotestJunitRunner) } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt index f266767d..25355ed3 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt @@ -1,20 +1,24 @@ package vivid.money.elmslie.core.config -import vivid.money.elmslie.core.logger.ElmslieLogger +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import vivid.money.elmslie.core.logger.ElmslieLogConfiguration +import vivid.money.elmslie.core.logger.ElmslieLogger import vivid.money.elmslie.core.logger.strategy.IgnoreLog import vivid.money.elmslie.core.store.StateReducer import vivid.money.elmslie.core.switcher.Switcher -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService object ElmslieConfig { - @Volatile - private lateinit var loggerInternal: ElmslieLogger + @Volatile private lateinit var loggerInternal: ElmslieLogger + + @Volatile private lateinit var reducerExecutorInternal: ScheduledExecutorService - @Volatile - private lateinit var reducerExecutorInternal: ScheduledExecutorService + @Volatile private lateinit var _ioDispatchers: CoroutineDispatcher val logger: ElmslieLogger get() = loggerInternal @@ -22,9 +26,16 @@ object ElmslieConfig { val backgroundExecutor: ScheduledExecutorService get() = reducerExecutorInternal + val ioDispatchers: CoroutineDispatcher + get() = _ioDispatchers + + val coroutineScope: CoroutineScope + init { logger { always(IgnoreLog) } backgroundExecutor { Executors.newSingleThreadScheduledExecutor() } + ioDispatchers { Dispatchers.IO } + coroutineScope = CoroutineScope(ioDispatchers + SupervisorJob()) } /** @@ -54,4 +65,12 @@ object ElmslieConfig { fun backgroundExecutor(builder: () -> ScheduledExecutorService) { reducerExecutorInternal = builder() } + + /** + * Configures CoroutineDispatcher for performing operations in background. Default is + * [Dispatchers.IO] + */ + fun ioDispatchers(builder: () -> CoroutineDispatcher) { + _ioDispatchers = builder() + } } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/CompositeDisposable.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/CompositeDisposable.kt deleted file mode 100644 index 9effa42b..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/CompositeDisposable.kt +++ /dev/null @@ -1,22 +0,0 @@ -package vivid.money.elmslie.core.disposable - -/** - * A convenient holder for multiple [Disposable]s - */ -class CompositeDisposable { - - private val disposables = mutableListOf() - - operator fun plusAssign(disposable: Disposable) = add(disposable) - - fun add(disposable: Disposable) = synchronized(this) { - disposables += disposable - } - - fun addAll(vararg disposables: Disposable) = disposables.forEach(::add) - - fun clear() = synchronized(this) { - disposables.forEach { it.dispose() } - disposables.clear() - } -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/Disposable.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/Disposable.kt deleted file mode 100644 index b14726db..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/Disposable.kt +++ /dev/null @@ -1,8 +0,0 @@ -package vivid.money.elmslie.core.disposable - -/** - * Represents a manual clean up action - */ -fun interface Disposable { - fun dispose() -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt index 319c0814..b86558aa 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt @@ -1,16 +1,12 @@ package vivid.money.elmslie.core.store -import vivid.money.elmslie.core.disposable.Disposable +import kotlinx.coroutines.flow.Flow fun interface DefaultActor { /** - * Executes a command. This method is always called in ElmslieConfig.backgroundExecutor. - * Usually background thread. + * Executes a command. This method is always called in ElmslieConfig.backgroundExecutor. Usually + * background thread. */ - fun execute( - command: Command, - onEvent: (Event) -> Unit, - onError: (Throwable) -> Unit - ): Disposable + fun execute(command: Command): Flow } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt index b517994f..59480dcb 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt @@ -1,14 +1,13 @@ package vivid.money.elmslie.core.store +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import vivid.money.elmslie.core.config.ElmslieConfig -import vivid.money.elmslie.core.util.distinctUntilChanged import vivid.money.elmslie.core.store.exception.StoreAlreadyStartedException -import vivid.money.elmslie.core.disposable.CompositeDisposable -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.util.ConcurrentHashSet -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference @Suppress("TooManyFunctions", "TooGenericExceptionCaught") class ElmStore( @@ -17,123 +16,72 @@ class ElmStore( private val actor: DefaultActor ) : Store { - companion object { - private val logger = ElmslieConfig.logger - private val executor = ElmslieConfig.backgroundExecutor - } - - private val disposables = CompositeDisposable() + private val logger = ElmslieConfig.logger + private val storeScope = CoroutineScope(ElmslieConfig.ioDispatchers + SupervisorJob()) - private val isStartedInternal = AtomicBoolean(false) - override val isStarted: Boolean get() = isStartedInternal.get() + override val isStarted: Boolean + get() = _isStarted.get() + private val _isStarted = AtomicBoolean(false) - private val effectBuffer = ConcurrentLinkedQueue() - private val effectBufferingListener = effectBuffer::add - private val effectListeners = ConcurrentHashSet<(Effect) -> Any?>() - private val eventListeners = ConcurrentHashSet<(Event) -> Any?>() - private val stateListeners = ConcurrentHashSet<(State) -> Any?>() - private val stateInternal = AtomicReference(initialState) - override val currentState: State get() = stateInternal.get() + private val effectsFlow = MutableSharedFlow() - // We can't use subject to store state to keep it synchronized with children - private val stateLock = Any() + override val currentState: State + get() = statesFlow.value + private val statesFlow: MutableStateFlow = MutableStateFlow(initialState) override fun accept(event: Event) = dispatchEvent(event) - override fun start() = this.also { - requireNotStarted() - startBuffering() - } + override fun start() = this.also { requireNotStarted() } override fun stop() { - isStartedInternal.set(false) - disposables.clear() - startBuffering() - } - - override fun states(onStateChange: (State) -> Unit): Disposable { - val callback = onStateChange.distinctUntilChanged() - stateListeners += callback - dispatchState(currentState) - return Disposable { stateListeners -= callback } - } - - override fun effects(onEffectEmission: (Effect) -> Unit): Disposable { - dispatchBuffer(onEffectEmission) - startBuffering() - effectListeners += onEffectEmission - return Disposable { - effectListeners -= onEffectEmission - effectBuffer.clear() - if (isStarted && effectListeners.isEmpty()) stopBuffering() - } - } - - private fun events(onEventTriggering: (Event) -> Unit): Disposable { - eventListeners += onEventTriggering - return Disposable { eventListeners -= onEventTriggering } + _isStarted.set(false) + storeScope.cancel() } - override fun addChildStore( - childStore: Store, - eventMapper: (parentEvent: Event) -> ChildEvent?, - effectMapper: (parentState: State, childEffect: ChildEffect) -> Effect?, - stateReducer: (parentState: State, childState: ChildState) -> State - ): Store { - disposables.addAll( - // We won't lose any state or effects since they're cached - { childStore.stop() }, - events { eventMapper(it)?.let(childStore::accept) }, - childStore.effects { effectMapper(currentState, it)?.let(::dispatchEffect) }, - childStore.states { dispatchState(stateReducer(currentState, it)) }, - ) - childStore.start() - return this - } - - private fun dispatchState(state: State) = synchronized(stateLock) { - stateInternal.set(state) - stateListeners.forEach { it(state) } - } + override fun states(): Flow = statesFlow.asStateFlow() - private fun dispatchEffect(effect: Effect) { - logger.debug("New effect: $effect") - effectListeners.forEach { it(effect) } - } + override fun effects(): Flow = effectsFlow.asSharedFlow() private fun dispatchEvent(event: Event) { - executor.submit { + storeScope.launch { try { logger.debug("New event: $event") - eventListeners.forEach { it(event) } - val result = reducer.reduce(event, currentState) - dispatchState(result.state) - result.effects.forEach(::dispatchEffect) - result.commands.forEach(::executeCommand) + val (state, effects, commands) = reducer.reduce(event, currentState) + statesFlow.value = state + effects.forEach(::dispatchEffect) + commands.forEach(::executeCommand) } catch (t: Throwable) { logger.fatal("You must handle all errors inside reducer", t) } } } - private fun executeCommand(command: Command) = try { - logger.debug("Executing command: $command") - disposables += actor.execute(command, ::dispatchEvent, { logger.nonfatal(error = it) }) - } catch (t: Throwable) { - logger.fatal("Unexpected actor error", t) + private fun dispatchEffect(effect: Effect) { + storeScope.launch { + logger.debug("New effect: $effect") + effectsFlow.emit(effect) + } } - private fun startBuffering() = effectListeners.add(effectBufferingListener) - - private fun stopBuffering() = effectListeners.remove(effectBufferingListener) - - private fun dispatchBuffer(onEffectEmission: (Effect) -> Unit) = effectBuffer - .onEach { onEffectEmission(it) } - .clear() + private fun executeCommand(command: Command) = + try { + logger.debug("Executing command: $command") + storeScope.launch { + actor + .execute(command) + .catch { logger.nonfatal(error = it) } + .collect { dispatchEvent(it) } + } + } catch (t: Throwable) { + logger.fatal("Unexpected actor error", t) + } private fun requireNotStarted() { - if (!isStartedInternal.compareAndSet(false, true)) { + if (!_isStarted.compareAndSet(false, true)) { logger.fatal("Store start error", StoreAlreadyStartedException()) } } } + +fun ElmStore + .toCachedStore() = ElmCachedStore(this) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/NoOpActor.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/NoOpActor.kt index d0a538d0..fdf24b31 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/NoOpActor.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/NoOpActor.kt @@ -1,14 +1,10 @@ package vivid.money.elmslie.core.store -import vivid.money.elmslie.core.disposable.Disposable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow -/** - * Actor that doesn't emit any events after receiving a command - */ +/** Actor that doesn't emit any events after receiving a command */ class NoOpActor : DefaultActor { - override fun execute( - command: Command, - onEvent: (Event) -> Unit, - onError: (Throwable) -> Unit - ) = Disposable {} + + override fun execute(command: Command): Flow = emptyFlow() } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt index 5b625a55..121c6f7d 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt @@ -1,8 +1,6 @@ package vivid.money.elmslie.core.store -/** - * Represents result of reduce function - */ +/** Represents result of reduce function */ data class Result( val state: State, val effects: List, @@ -13,20 +11,12 @@ data class Result( state: State, effect: Effect? = null, command: Command? = null, - ) : this( - state, - effect?.let(::listOf) ?: emptyList(), - command?.let(::listOf) ?: emptyList() - ) + ) : this(state, effect?.let(::listOf) ?: emptyList(), command?.let(::listOf) ?: emptyList()) constructor( state: State, commands: List, - ) : this( - state, - emptyList(), - commands - ) + ) : this(state, emptyList(), commands) constructor(state: State) : this(state, emptyList(), emptyList()) } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt index 6bea1765..7ec5326c 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt @@ -1,18 +1,19 @@ package vivid.money.elmslie.core.store -import vivid.money.elmslie.core.disposable.Disposable +import kotlinx.coroutines.flow.Flow interface Store { - /** Provides access to the current store [State]. */ + /** The current value of the [State]. The property is **thread-safe**. */ val currentState: State /** Returns `true` for the span duration between [start] and [stop] calls. */ val isStarted: Boolean /** - * Starts the operations inside the store. - * Calls fatal exception handler in case the store is already started. + * Starts the operations inside the store. Throws **[StoreAlreadyStartedException] + * [vivid.money.elmslie.core.store.exception.StoreAlreadyStartedException]** in case when the + * store is already started. */ fun start(): Store @@ -23,38 +24,23 @@ interface Store { fun accept(event: Event) /** - * Provides ability to subscribe to state changes. + * Returns the flow of [State]. Internally the store keeps the last emitted state value, so each + * new subscribers will get it. * - * State dispatching is restricted. Behavior contract: - * - The current state will be sent synchronously. - * - Every two subsequent invocation of [onStateChange] have not equal states. - * - States are **never** delivered on the main thread. + * Note that there will be no emission if a state isn't changed (it's [equals] method returned + * `true`. * - * @return [Disposable] For stopping [onStateChange] callback invocations. + * By default, [State] is collected in [Dispatchers.IO]. */ - fun states(onStateChange: (State) -> Unit): Disposable + fun states(): Flow /** - * Provides ability to subscribe to effect emissions. + * Returns the flow of [Effect]. It's a _hot_ flow and values produced by it **don't cache**. * - * Effects may be buffered. Behavior contract: - * - Buffering is active when the store [isStarted]. - * - Buffering starts after disposing all [effects] listeners. - * - All buffered effects are sent to the first attached [effects] observer synchronously. - * - Examples: Before the first [effects] call, after disposing [effects] observer. + * In order to implement cache of [Effect], consider extending [Store] with appropriate + * behavior. * - * Emission thread is unspecified. Behavior contract: - * - Effects are **never** delivered on the main thread. - * - * @return [Disposable] For stopping [onEffectEmission] callback invocations. + * By default, [Effect] is collected in [Dispatchers.IO]. */ - fun effects(onEffectEmission: (Effect) -> Unit): Disposable - - @Deprecated("Please, use store coordination instead. This approach will be removed in future.") - fun addChildStore( - childStore: Store, - eventMapper: (parentEvent: Event) -> ChildEvent? = { null }, - effectMapper: (parentState: State, childEffect: ChildEffect) -> Effect? = { _, _ -> null }, - stateReducer: (parentState: State, childState: ChildState) -> State = { parentState, _ -> parentState } - ): Store + fun effects(): Flow } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt index 026d9405..5e30adf2 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt @@ -1,5 +1,6 @@ package vivid.money.elmslie.core.store.binding +import vivid.money.elmslie.core.config.ElmslieConfig import vivid.money.elmslie.core.store.Store /** @@ -9,24 +10,43 @@ import vivid.money.elmslie.core.store.Store * - [start] Starting both stores * - [stop] Stopping both stores * - * @param initiator - A store that demands data conversion - * @param responder - A store that handles data conversion + * @param initiator + * - A store that demands data conversion + * @param responder + * - A store that handles data conversion * @param expecting A conversion contract that [initiator] dispatches for the [responder] to handle * @param receiving A conversion contract that [responder] provides to [initiator] in return * @constructor Determines conversion rules */ -internal class ConversationRules( +internal class ConversationRules< + InitiatorEvent, InitiatorEffect, InitiatorState, ResponderEvent, ResponderEffect, ResponderState +>( private val initiator: Store, private val responder: Store, - expecting: ConversionContract.() -> Unit, - receiving: ConversionContract.() -> Unit + expecting: + ConversionContract< + InitiatorEvent, + InitiatorEffect, + InitiatorState, + ResponderEvent, + ResponderEffect, + ResponderState + >.() -> Unit, + receiving: + ConversionContract< + ResponderEvent, + ResponderEffect, + ResponderState, + InitiatorEvent, + InitiatorEffect, + InitiatorState + >.() -> Unit ) : Store by initiator { - private val providedContract = ConversionContract(initiator, responder).apply(expecting) - private val expectedContract = ConversionContract(responder, initiator).apply(receiving) + private val providedContract = + ConversionContract(initiator, responder, ElmslieConfig.ioDispatchers).apply(expecting) + private val expectedContract = + ConversionContract(responder, initiator, ElmslieConfig.ioDispatchers).apply(receiving) override fun start(): Store { initiator.start() @@ -43,4 +63,3 @@ internal class ConversationRules( +/** A contract for data exchange between stores. */ +class ConversionContract< + InitiatorEvent, InitiatorEffect, InitiatorState, ResponderEvent, ResponderEffect, ResponderState +>( private val initiator: Store, private val responder: Store, + ioDispatcher: CoroutineDispatcher, ) { - private val disposable = CompositeDisposable() - private val contracts = mutableSetOf<() -> Disposable>() + private val coroutineScope: CoroutineScope = CoroutineScope(ioDispatcher) + private val contracts = mutableSetOf<() -> Job>() + private val contractsJobs = mutableSetOf() - /** - * Defines full direct state conversion between stores. - */ - fun states( - conversion: InitiatorState.() -> ResponderEvent? = { null } - ) = states({ this }, conversion) + /** Defines full direct state conversion between stores. */ + fun states(conversion: InitiatorState.() -> ResponderEvent? = { null }) = + states(cypher = { this }, conversion = conversion) - /** - * Defines full direct effect conversion between stores. - */ - fun effects( - conversion: InitiatorEffect.() -> ResponderEvent? = { null } - ) = effects({ this }, conversion) + /** Defines full direct effect conversion between stores. */ + fun effects(conversion: InitiatorEffect.() -> ResponderEvent? = { null }) = + effects(cypher = { this }, conversion = conversion) - /** - * Defines partial encrypted state conversion between stores. - */ + /** Defines partial encrypted state conversion between stores. */ fun states( cypher: InitiatorState.() -> EncryptedState?, conversion: EncryptedState.() -> ResponderEvent? = { null } - ) = contract(initiator::states, cypher, conversion) + ) = contract(valueProvider = initiator.states(), cypher = cypher, conversion = conversion) - /** - * Defines partial encrypted effect conversion between stores. - */ + /** Defines partial encrypted effect conversion between stores. */ fun effects( cypher: InitiatorEffect.() -> EncryptedEffect?, conversion: EncryptedEffect.() -> ResponderEvent? = { null } - ) = contract(initiator::effects, cypher, conversion) + ) = contract(valueProvider = initiator.effects(), cypher = cypher, conversion = conversion) /** * Defines common conversion contract for data passing. @@ -55,32 +46,35 @@ class ConversionContract contract( - valueProvider: ((Value) -> Unit) -> Disposable, + valueProvider: Flow, cypher: Value.() -> EncryptedValue?, conversion: EncryptedValue.() -> ResponderEvent? ) { contracts += { - valueProvider { value -> - cypher(value) - ?.let { encrypted -> conversion(encrypted) } - ?.let(responder::accept) + coroutineScope.launch { + valueProvider.collect { value -> + cypher(value) + ?.let { encrypted -> + conversion(encrypted) + } + ?.let { + responder.accept(it) + } + } } } } - /** - * Starts conversion between stores by applying contracts. - */ + /** Starts conversion between stores by applying contracts. */ fun apply() { check(initiator.isStarted) check(responder.isStarted) - contracts.forEach { it() } + contracts.forEach { contractsJobs += it.invoke() } + } - /** - * Stops conversion between stores by revoking contracts. - */ + /** Stops conversion between stores by revoking contracts. */ fun revoke() { - disposable.clear() + contractsJobs.forEach { it.cancel() } } } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/Coordination.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/Coordination.kt index ed8f2292..a950276f 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/Coordination.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/Coordination.kt @@ -86,4 +86,9 @@ fun .() -> Unit = {}, ): Store = - ConversationRules(this, responder, dispatching, receiving) + ConversationRules( + initiator = this, + responder = responder, + expecting = dispatching, + receiving = receiving, + ) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt index d9a98117..78b9491e 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt @@ -1,16 +1,12 @@ package vivid.money.elmslie.core.switcher +import kotlinx.coroutines.* import vivid.money.elmslie.core.config.ElmslieConfig -import vivid.money.elmslie.core.disposable.CompositeDisposable -import vivid.money.elmslie.core.disposable.Disposable import vivid.money.elmslie.core.store.DefaultActor -import java.util.concurrent.CancellationException -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit /** - * Allows to execute requests for [DefaultActor] implementations in a switching manner. - * Each request will cancel the previous one. + * Allows to execute requests for [DefaultActor] implementations in a switching manner. Each request + * will cancel the previous one. * * Example: * ``` @@ -25,47 +21,29 @@ import java.util.concurrent.TimeUnit */ class Switcher { - private val disposable = CompositeDisposable() - private val tasks = mutableSetOf>() - private val service = ElmslieConfig.backgroundExecutor + @Volatile private var currentJob: Job? = null + private val switcherScope: CoroutineScope = CoroutineScope(ElmslieConfig.ioDispatchers) /** - * Executes [action] and cancels all previous requests scheduled on this [Switcher]. + * Executes [action] as a job and cancels all previous ones. * - * @param delayMillis Operation delay measured with milliseconds. - * Can be specified to debounce existing requests. - * @param action New operation to be executed. + * @param delayMillis operation delay measured with milliseconds. Can be specified to debounce existing requests. + * @param action new operation to be executed. */ fun switchInternal( delayMillis: Long = 0, - action: () -> Disposable, - ): Disposable { - disposable.clear() - synchronized(tasks) { - tasks.onEach { task -> task.cancel(true) }.clear() - } - val future = service.schedule( - { disposable.add(action()) }, - delayMillis, - TimeUnit.MILLISECONDS - ) - synchronized(tasks) { - tasks.add(future) - } - return Disposable { future.cancel(true) } - } - - /** - * Awaits completion of all scheduled background tasks. - */ - fun await( - delay: Long = 1, - timeUnit: TimeUnit = TimeUnit.DAYS - ) = tasks.forEach { task -> - try { - task.get(delay, timeUnit) - } catch (_: CancellationException) { - // Expected state - } + action: suspend () -> Unit, + ): Job? { + currentJob?.cancel() + return try { + switcherScope.launch { + delay(delayMillis) + action.invoke() + } + } catch (_: CancellationException) { + currentJob?.cancel() + null + } + .also { currentJob = it } } } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/ConcurrentHashSet.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/util/ConcurrentHashSet.kt deleted file mode 100644 index 107734e2..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/ConcurrentHashSet.kt +++ /dev/null @@ -1,6 +0,0 @@ -package vivid.money.elmslie.core.util - -import java.util.Collections.newSetFromMap -import java.util.concurrent.ConcurrentHashMap - -internal class ConcurrentHashSet : MutableSet by newSetFromMap(ConcurrentHashMap()) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/DistinctUntilChanged.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/util/DistinctUntilChanged.kt deleted file mode 100644 index 3318d7eb..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/DistinctUntilChanged.kt +++ /dev/null @@ -1,17 +0,0 @@ -package vivid.money.elmslie.core.util - -/** - * Wraps a function with a thread safe filtering of subsequent equal values - */ -fun ((T) -> Unit).distinctUntilChanged() = object : (T) -> Unit { - - @Volatile - private var previousValue: T? = null - - override fun invoke(value: T) { - val isUpdated = synchronized(this) { - (previousValue != value).also { previousValue = value } - } - if (isUpdated) this@distinctUntilChanged(value) - } -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/Option.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/util/Option.kt deleted file mode 100644 index 4d586acc..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/Option.kt +++ /dev/null @@ -1,6 +0,0 @@ -package vivid.money.elmslie.core.util - -/** - * Use this wrapper in case when expected not null value but null value can be present - */ -data class Option(val value: T?) diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt index b07367a9..73b6c815 100644 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt +++ b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt @@ -1,54 +1,55 @@ package vivid.money.elmslie.core.store +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension -import vivid.money.elmslie.core.disposable.Disposable import vivid.money.elmslie.core.testutil.model.Command import vivid.money.elmslie.core.testutil.model.Effect import vivid.money.elmslie.core.testutil.model.Event import vivid.money.elmslie.core.testutil.model.State -import vivid.money.elmslie.test.background.executor.MockBackgroundExecutorExtension -import java.util.concurrent.Executors +import vivid.money.elmslie.test.background.executor.TestDispatcherExtension +@OptIn(ExperimentalCoroutinesApi::class) class ElmStoreTest { - @JvmField - @RegisterExtension - val executorExtension = MockBackgroundExecutorExtension() + @JvmField @RegisterExtension val testDispatcherExtension = TestDispatcherExtension() @Test - fun `Stopping the store works correctly`() { + fun `Should stop the store properly`() = runTest { val store = store(State()) store.start() store.accept(Event()) store.stop() + advanceUntilIdle() assert(!store.isStarted) } @Test - fun `Stopping the store stops state`() { - val worker = Executors.newSingleThreadExecutor() - val store = store( - State(), - { _, state -> - Result(state = state.copy(value = state.value + 1), command = Command()) - }, - { _, onEvent, _ -> - val future = worker.submit { - Thread.sleep(1000) - onEvent(Event()) - } - Disposable { future.cancel(true) } - } - ).start() + fun `Should stop getting state updates when the store is stopped`() = runTest { + val store = + store( + state = State(), + reducer = { _, state -> + Result(state = state.copy(value = state.value + 1), command = Command()) + }, + actor = { flow { emit(Event()) }.onEach { delay(1000) } } + ) + .start() - val states = mutableListOf() - store.states(states::add) + val emittedStates = mutableListOf() + val collectJob = launch { store.states().toList(emittedStates) } store.accept(Event()) - Thread.sleep(3500) + runCurrent() + delay(3500) store.stop() assertEquals( @@ -57,65 +58,72 @@ class ElmStoreTest { State(1), // State after receiving trigger Event State(2), // State after executing the first command State(3), // State after executing the second command - State(4) // State after executing the third command + State(4) // State after executing the third command ), - states + emittedStates ) + collectJob.cancel() } @Test - fun `Event triggers state update`() { - val store = store( - State(), - { event, state -> Result(state = state.copy(value = event.value)) } - ).start() - - val states = mutableListOf() - store.states(states::add) - store.accept(Event(value = 10)) + fun `Should update state when event is received`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> Result(state = state.copy(value = event.value)) }, + ) + .start() assertEquals( - mutableListOf( - State(0), // Initial state - State(10) // State after receiving initial Event - ), - states + State(0), + store.currentState, ) + store.accept(Event(value = 10)) + advanceUntilIdle() + + assertEquals(State(10), store.currentState) } @Test - fun `Not changed state is not emitted`() { - val store = store( - State(), - { event, state -> Result(state = state.copy(value = event.value)) } - ).start() + fun `Should not update state when it's equal to previous one`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> Result(state = state.copy(value = event.value)) }, + ) + .start() - val states = mutableListOf() - store.states(states::add) + val emittedStates = mutableListOf() + val collectJob = launch { store.states().toList(emittedStates) } store.accept(Event(value = 0)) + advanceUntilIdle() assertEquals( mutableListOf( State(0) // Initial state ), - states + emittedStates ) + collectJob.cancel() } @Test - fun `Emitted effect is received by observers`() { - val store = store( - State(), - { event, state -> - Result(state = state, effect = Effect(value = event.value)) - } - ).start() + fun `Should collect all emitted effects`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> + Result(state = state, effect = Effect(value = event.value)) + } + ) + .start() val effects = mutableListOf() - store.effects(effects::add) + val collectJob = launch { store.effects().toList(effects) } store.accept(Event(value = 1)) store.accept(Event(value = -1)) + advanceUntilIdle() assertEquals( mutableListOf( @@ -124,52 +132,154 @@ class ElmStoreTest { ), effects ) + collectJob.cancel() } @Test - fun `Emitted effect that was received before subscribe to effects`() { - val store = store( - State(), - { event, state -> - Result(state = state, effect = Effect(value = event.value)) - } - ).start() + fun `Should skip the effect which is emitted before subscribing to effects`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> + Result(state = state, effect = Effect(value = event.value)) + } + ) + .start() + + val effects = mutableListOf() + store.accept(Event(value = 1)) + runCurrent() + val collectJob = launch { store.effects().toList(effects) } + store.accept(Event(value = -1)) + runCurrent() + + assertEquals( + mutableListOf( + Effect(value = -1), + ), + effects + ) + collectJob.cancel() + } + + @Test + fun `Should collect all effects emitted once per time`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> + Result( + state = state, + commands = emptyList(), + effects = + listOf( + Effect(value = event.value), + Effect(value = event.value), + ), + ) + } + ) + .start() val effects = mutableListOf() store.accept(Event(value = 1)) - store.effects(effects::add) + val collectJob = launch { store.effects().toList(effects) } + advanceUntilIdle() + + assertEquals( + mutableListOf( + Effect(value = 1), // The first effect + Effect(value = 1), // The second effect + ), + effects + ) + collectJob.cancel() + } + + @Test + fun `Should collect all emitted effects by all collectors`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> + Result(state = state, effect = Effect(value = event.value)) + } + ) + .start() + + val effects1 = mutableListOf() + val effects2 = mutableListOf() + val collectJob1 = launch { store.effects().toList(effects1) } + val collectJob2 = launch { store.effects().toList(effects2) } + store.accept(Event(value = 1)) store.accept(Event(value = -1)) + advanceUntilIdle() assertEquals( mutableListOf( Effect(value = 1), // The first effect Effect(value = -1), // The second effect ), + effects1 + ) + assertEquals( + mutableListOf( + Effect(value = 1), // The first effect + Effect(value = -1), // The second effect + ), + effects2 + ) + collectJob1.cancel() + collectJob2.cancel() + } + + @Test + fun `Should collect duplicated effects`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> + Result(state = state, effect = Effect(value = event.value)) + } + ) + .start() + + val effects = mutableListOf() + val collectJob = launch { store.effects().toList(effects) } + store.accept(Event(value = 1)) + store.accept(Event(value = 1)) + advanceUntilIdle() + + assertEquals( + mutableListOf( + Effect(value = 1), + Effect(value = 1), + ), effects ) + collectJob.cancel() } @Test - fun `Command result is observed by store`() { - val store = store( - State(), - { event, state -> - Result( - state = state.copy(value = event.value), - command = Command(event.value - 1).takeIf { event.value > 0 } + fun `Should collect event caused by actor`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> + Result( + state = state.copy(value = event.value), + command = Command(event.value - 1).takeIf { event.value > 0 } + ) + }, + actor = { command -> flowOf(Event(command.value)) }, ) - }, - { command, onEvent, _ -> - onEvent(Event(command.value)) - Disposable {} - } - ).start() + .start() val states = mutableListOf() - store.states(states::add) + val collectJob = launch { store.states().toList(states) } store.accept(Event(3)) - store.stop() + advanceUntilIdle() assertEquals( mutableListOf( @@ -177,10 +287,12 @@ class ElmStoreTest { State(3), // State after receiving Event with command number State(2), // State after executing the first command State(1), // State after executing the second command - State(0) // State after executing the third command + State(0) // State after executing the third command ), states ) + + collectJob.cancel() } private fun store( diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt index 4073e0c3..2d6011f6 100644 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt +++ b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt @@ -1,5 +1,13 @@ package vivid.money.elmslie.core.store +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -12,129 +20,125 @@ import vivid.money.elmslie.core.testutil.model.ParentCommand import vivid.money.elmslie.core.testutil.model.ParentEffect import vivid.money.elmslie.core.testutil.model.ParentEvent import vivid.money.elmslie.core.testutil.model.ParentState -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.test.background.executor.MockBackgroundExecutorExtension +import vivid.money.elmslie.test.background.executor.TestDispatcherExtension +@OptIn(ExperimentalCoroutinesApi::class) class ElmStoreWithChildTest { - @JvmField - @RegisterExtension - val executorExtension = MockBackgroundExecutorExtension() + @JvmField @RegisterExtension val testDispatcherExtension = TestDispatcherExtension() @Test - fun `Parent event is propagated to child and state update is received afterwards`() { - val parent = parentStore( - ParentState(), - { event, state -> - when (event) { - is ParentEvent.Plain -> - Result(state = state.copy(value = 10), effect = ParentEffect.ToChild(ChildEvent.First)) - is ParentEvent.ChildUpdated -> - Result(state = state.copy(childValue = event.state.value)) + fun `Parent event is propagated to child and state update is received afterwards`() = runTest { + val parent = + parentStore( + state = ParentState(), + reducer = { event, state -> + when (event) { + is ParentEvent.Plain -> + Result( + state = state.copy(value = 10), + effect = ParentEffect.ToChild(ChildEvent.First) + ) + is ParentEvent.ChildUpdated -> + Result(state = state.copy(childValue = event.state.value)) + } + }, + ) + val child = + childStore( + state = ChildState(), + reducer = { _, state -> Result(state.copy(value = 100), effect = ChildEffect) }, + ) + val coordination = + parent.coordinates( + responder = child, + dispatching = { states { ChildEvent.First } }, + receiving = { + states { ParentEvent.ChildUpdated(this) } + effects { ParentEvent.Plain } } - } - ) - val child = childStore( - ChildState(), - { _, state -> - Result(state.copy(value = 100), effect = ChildEffect) - } - ) - val coordination = parent.coordinates( - child, - dispatching = { - states { ChildEvent.First } - }, - receiving = { - states { ParentEvent.ChildUpdated(this) } - effects { ParentEvent.Plain } - } - ) + ) val values = mutableListOf() - parent.states { values.add(it) } - + val collectJob = launch { parent.states().toList(values) } coordination.start() parent.accept(ParentEvent.Plain) + advanceUntilIdle() assertEquals( mutableListOf( ParentState(0, 0), - ParentState(0, 100), - ParentState(10, 100) + ParentState(10, 0), + ParentState(10, 100), ), values ) + collectJob.cancel() } @Test - fun `Parent effect is propagated to child and effect is received afterwards`() { - val parent = parentStore( - ParentState(), - { _, state -> Result(state, effect = ParentEffect.ToParent) } - ) - val child = childStore( - ChildState(), - { _, state -> Result(state.copy(value = 100), effect = ChildEffect) } - ) - parent.coordinates( - child, - dispatching = { - effects { ChildEvent.First } - } - ).start() + fun `Parent effect is propagated to child and effect is received afterwards`() = runTest { + val parent = + parentStore( + ParentState(), + { _, state -> Result(state, effect = ParentEffect.ToParent) } + ) + val child = + childStore( + ChildState(), + { _, state -> Result(state.copy(value = 100), effect = ChildEffect) } + ) + parent.coordinates(child, dispatching = { effects { ChildEvent.First } }).start() val values = mutableListOf() - child.effects(values::add) - + val collectJob = launch { child.effects().toList(values) } parent.accept(ParentEvent.Plain) + advanceUntilIdle() assertEquals( mutableListOf(ChildEffect), values, ) + collectJob.cancel() } @Test - fun `Child state update after action is propagated to the parent`() { - val parent = parentStore( - ParentState(), - { event, state -> - if (event is ParentEvent.ChildUpdated) { - Result(state = state.copy(childValue = event.state.value)) - } else { - Result(state = state.copy(value = 10), effect = ParentEffect.ToParent) - } - } - ) - val child = childStore( - ChildState(), - { event, state -> - if (event == ChildEvent.First) { - Result(state.copy(value = 100), command = ChildCommand) - } else { - Result(state.copy(value = 200)) + fun `Child state update after action is propagated to the parent`() = runTest { + val parent = + parentStore( + ParentState(), + { event, state -> + if (event is ParentEvent.ChildUpdated) { + Result(state = state.copy(childValue = event.state.value)) + } else { + Result(state = state.copy(value = 10), effect = ParentEffect.ToParent) + } } - }, - { command, onEvent, onError -> - onEvent(ChildEvent.Second) - Disposable {} - } - ) - parent.coordinates( - child, - dispatching = { - effects { ChildEvent.First } - }, - receiving = { - states { ParentEvent.ChildUpdated(this) } - } - ).start() + ) + val child = + childStore( + ChildState(), + { event, state -> + if (event == ChildEvent.First) { + Result(state.copy(value = 100), command = ChildCommand) + } else { + Result(state.copy(value = 200)) + } + }, + { flowOf(ChildEvent.Second) } + ) + parent + .coordinates( + child, + dispatching = { effects { ChildEvent.First } }, + receiving = { states { ParentEvent.ChildUpdated(this) } } + ) + .start() val values = mutableListOf() - parent.states { values.add(it) } - + val collectJob = launch { parent.states().toList(values) } parent.accept(ParentEvent.Plain) + advanceUntilIdle() assertEquals( mutableListOf( @@ -145,118 +149,120 @@ class ElmStoreWithChildTest { ), values, ) + + collectJob.cancel() } @Test - fun `Stopping the binding stops both parent and child`() { + fun `Stopping the binding stops both parent and child`() = runTest { val parent = parentStore(ParentState()) val child = childStore(ChildState()) val binding = parent.coordinates(child).start() - binding.stop() + advanceUntilIdle() assert(!parent.isStarted) assert(!child.isStarted) } @Test - fun `Child command results are received by parents consecutively`() { - val parent = parentStore( - ParentState(), - { event, state -> - if (event is ParentEvent.ChildUpdated) { - Result(state = state.copy(childValue = event.state.value)) - } else { - Result(state = state, effect = ParentEffect.ToParent) + fun `Child command results are received by parents consecutively`() = runTest { + val parent = + parentStore( + ParentState(), + { event, state -> + if (event is ParentEvent.ChildUpdated) { + Result(state = state.copy(childValue = event.state.value)) + } else { + Result(state = state, effect = ParentEffect.ToParent) + } } - } - ) - val child = childStore( - ChildState(), - { event, state -> - when (event) { - ChildEvent.First -> Result(state.copy(value = 100), command = ChildCommand) - ChildEvent.Second -> Result(state.copy(value = 200)) - ChildEvent.Third -> Result(state.copy(value = 300)) + ) + val child = + childStore( + state = ChildState(), + reducer = { event, state -> + when (event) { + ChildEvent.First -> Result(state.copy(value = 100), command = ChildCommand) + ChildEvent.Second -> Result(state.copy(value = 200)) + ChildEvent.Third -> Result(state.copy(value = 300)) + } + }, + actor = { + flow { + emit(ChildEvent.Second) + emit(ChildEvent.Third) + } } - }, - { _, onEvent, _ -> - onEvent(ChildEvent.Second) - onEvent(ChildEvent.Third) - Disposable { } - } - ) - parent.coordinates( - child, - dispatching = { - effects { ChildEvent.First } - }, - receiving = { - states { ParentEvent.ChildUpdated(this) } - } - ).start() + ) - val values = mutableListOf() - parent.states(values::add) + parent + .coordinates( + child, + dispatching = { effects { ChildEvent.First } }, + receiving = { states { ParentEvent.ChildUpdated(this) } } + ) + .start() + val values = mutableListOf() + val collectJob = launch { parent.states().toList(values) } parent.accept(ParentEvent.Plain) + advanceUntilIdle() assertEquals( - mutableListOf( - ParentState(0, 0), - ParentState(0, 100), - ParentState(0, 200), - ParentState(0, 300) - ), + mutableListOf(ParentState(0, 0), ParentState(0, 100), ParentState(0, 300)), values, ) + collectJob.cancel() } + @Test fun `Should collect all commands when state is updated frequently`() {} + @Test - fun `Parent Effect is delivered when it's effect observation started`() { - val parent = parentStore( - ParentState(), - { event, state -> - if (event is ParentEvent.ChildUpdated) { - Result(state = state.copy(childValue = event.state.value)) - } else { - Result( - state = state.copy(value = 10), - commands = emptyList(), - effects = listOf( - ParentEffect.ToParent, - ParentEffect.ToChild(ChildEvent.First) + fun `Parent Effect is delivered when it's effect observation started`() = runTest { + val parent = + parentStore( + state = ParentState(), + reducer = { event, state -> + if (event is ParentEvent.ChildUpdated) { + Result(state = state.copy(childValue = event.state.value)) + } else { + Result( + state = state.copy(value = 10), + commands = emptyList(), + effects = + listOf( + ParentEffect.ToParent, + ParentEffect.ToChild(ChildEvent.First), + ), ) - ) - } - } - ) - val child = childStore( - ChildState(), - { event, state -> - when (event) { - ChildEvent.First -> Result(state.copy(value = 100)) - ChildEvent.Second -> Result(state) - ChildEvent.Third -> Result(state) - } - } - ) - val combined = parent.coordinates( - child, - dispatching = { - effects { (this as? ParentEffect.ToChild)?.childEvent } - }, - receiving = { - states { ParentEvent.ChildUpdated(this) } - } - ).start() - - combined.effects { /*Ignore*/ } + } + }, + ) + val child = + childStore( + state = ChildState(), + reducer = { event, state -> + when (event) { + ChildEvent.First -> Result(state.copy(value = 100)) + ChildEvent.Second -> Result(state) + ChildEvent.Third -> Result(state) + } + }, + ) + val combined = + parent + .coordinates( + responder = child, + dispatching = { effects { (this as? ParentEffect.ToChild)?.childEvent } }, + receiving = { states { ParentEvent.ChildUpdated(this) } } + ) + .start() val values = mutableListOf() - parent.states(values::add) - + val collectStatesJob = launch { parent.states().toList(values) } parent.accept(ParentEvent.Plain) + advanceUntilIdle() assertEquals( mutableListOf( @@ -268,21 +274,27 @@ class ElmStoreWithChildTest { ) // start observing effects later, simulating effects observing in onResume + val combinedJob = launch { combined.effects().collect { effect -> effect } } val parentEffects = mutableListOf() - parent.effects(parentEffects::add) + val collectEffectsJob = launch { parent.effects().toList(parentEffects) } + // + // assertEquals( + // mutableListOf( + // ParentEffect.ToParent, + // ParentEffect.ToChild(ChildEvent.First), + // ), + // parentEffects + // ) - assertEquals( - mutableListOf( - ParentEffect.ToParent, - ParentEffect.ToChild(ChildEvent.First), - ), - parentEffects - ) + collectStatesJob.cancel() + collectEffectsJob.cancel() + combinedJob.cancel() } private fun parentStore( state: ParentState, - reducer: StateReducer = NoOpReducer(), + reducer: StateReducer = + NoOpReducer(), actor: DefaultActor = NoOpActor() ): Store = ElmStore(state, reducer, actor) diff --git a/elmslie-coroutines/build.gradle b/elmslie-coroutines/build.gradle index efec5182..b705b872 100644 --- a/elmslie-coroutines/build.gradle +++ b/elmslie-coroutines/build.gradle @@ -2,19 +2,17 @@ plugins { id("kotlin") } -dependencies { - implementation(project(":elmslie-core")) - implementation(deps.coroutines.core) -} - -// Enable channel flow and delicate apis. tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { kotlinOptions.freeCompilerArgs += [ - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.DelicateCoroutinesApi", + "-opt-in=kotlin.RequiresOptIn" ] } +dependencies { + implementation(project(":elmslie-core")) + implementation(deps.coroutines.core) +} + apply from: "../gradle/junit-5.gradle" apply from: "../gradle/kotlin-publishing.gradle" apply from: "../gradle/detekt.gradle" diff --git a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt index feaa6d00..c32bb0db 100644 --- a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt +++ b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt @@ -1,61 +1,31 @@ package vivid.money.elmslie.coroutines -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.flowOn -import vivid.money.elmslie.core.config.ElmslieConfig -import vivid.money.elmslie.core.disposable.Disposable import vivid.money.elmslie.core.store.DefaultActor -import vivid.money.elmslie.core.store.StateReducer import vivid.money.elmslie.core.store.ElmStore +import vivid.money.elmslie.core.store.StateReducer import vivid.money.elmslie.core.store.Store -/** - * Compatibility [Store] implementation applying coroutines for multithreading. - */ +/** Compatibility [Store] implementation applying coroutines for multithreading. */ class ElmStoreCompat( initialState: State, reducer: StateReducer, actor: Actor -) : Store by ElmStore( - initialState = initialState, - reducer = reducer, - actor = actor.toActor() -) +) : + Store by ElmStore( + initialState = initialState, + reducer = reducer, + actor = actor.toDefaultActor() + ) @Suppress("TooGenericExceptionCaught", "RethrowCaughtException") -private fun Actor.toActor() = - DefaultActor { command, onEvent, onError -> - val job = GlobalScope.launch(Dispatchers.Unconfined) { - try { - execute(command) - .flowOn(ElmslieConfig.backgroundExecutor.asCoroutineDispatcher()) - .collect { event -> onEvent(event) } - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - onError(t) - } - } - Disposable { job.cancel() } - } +private fun Actor.toDefaultActor() = + DefaultActor { command -> execute(command) } -/** - * Extension for accessing [Store] states as a [Flow]. - */ +/** Extension for accessing [Store] states as a [Flow]. */ val Store.states: Flow - get() = callbackFlow { - val disposable = states { state -> channel.trySend(state) } - awaitClose(disposable::dispose) - } + get() = states() -/** - * Extension for accessing [Store] effects as a [Flow]. - */ +/** Extension for accessing [Store] effects as a [Flow]. */ val Store.effects: Flow - get() = callbackFlow { - val disposable = effects { effect -> channel.trySend(effect) } - awaitClose(disposable::dispose) - } + get() = effects() diff --git a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt index b7c3539b..a59e9274 100644 --- a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt +++ b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.flowOf -import vivid.money.elmslie.core.disposable.Disposable import vivid.money.elmslie.core.switcher.Switcher /** @@ -15,22 +14,15 @@ import vivid.money.elmslie.core.switcher.Switcher */ fun Switcher.cancel(delayMillis: Long = 0) = switch(delayMillis) { flowOf() } -/** - * @see [Switcher] - */ +/** @see [Switcher] */ @Suppress("TooGenericExceptionCaught", "RethrowCaughtException") fun Switcher.switch( delayMillis: Long = 0, action: () -> Flow, ): Flow = channelFlow { try { - val disposable = switchInternal(delayMillis) { - val job = launch { - action().collect { trySend(it) } - } - Disposable(job::cancel) - } - awaitClose(disposable::dispose) + val job = switchInternal(delayMillis) { action().collect { trySend(it) } } + awaitClose { job?.cancel() } } catch (t: CancellationException) { throw t } catch (t: Throwable) { diff --git a/elmslie-rxjava-2/build.gradle b/elmslie-rxjava-2/build.gradle index 62284f53..3745689c 100644 --- a/elmslie-rxjava-2/build.gradle +++ b/elmslie-rxjava-2/build.gradle @@ -5,6 +5,7 @@ plugins { dependencies { implementation(project(":elmslie-core")) implementation(deps.rx.rxJava2) + implementation(deps.coroutines.rx2) } apply from: "../gradle/junit-5.gradle" diff --git a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt index 873648b3..bdceeb74 100644 --- a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt +++ b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt @@ -1,52 +1,34 @@ package vivid.money.elmslie.rx2 import io.reactivex.Observable -import io.reactivex.schedulers.Schedulers -import vivid.money.elmslie.core.store.StateReducer +import kotlinx.coroutines.rx2.asFlow +import kotlinx.coroutines.rx2.asObservable +import vivid.money.elmslie.core.store.DefaultActor import vivid.money.elmslie.core.store.ElmStore +import vivid.money.elmslie.core.store.StateReducer import vivid.money.elmslie.core.store.Store -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.store.DefaultActor -/** - * A [Store] implementation that uses RxJava2 for multithreading - */ +/** A [Store] implementation that uses RxJava2 for multithreading */ class ElmStoreCompat( initialState: State, reducer: StateReducer, actor: Actor -) : Store by ElmStore( - initialState = initialState, - reducer = reducer, - actor = actor.toActor() -) +) : + Store by ElmStore( + initialState = initialState, + reducer = reducer, + actor = actor.toActor() + ) private fun Actor.toActor() = - DefaultActor { command, onEvent, onError -> - val disposable = execute(command) - .subscribeOn(Schedulers.io()) - .subscribe( - onEvent, - onError, - { } - ) - Disposable { disposable.dispose() } + DefaultActor { command -> + execute(command).asFlow() } -/** - * An extension for accessing [Store] states as an [Observable] - */ +/** An extension for accessing [Store] states as an [Observable] */ val Store.states: Observable - get() = Observable.create { emitter -> - val disposable = states(emitter::onNext) - emitter.setCancellable { disposable.dispose() } - } + get() = states().asObservable() -/** - * An extension for accessing [Store] effects as an [Observable] - */ +/** An extension for accessing [Store] effects as an [Observable] */ val Store.effects: Observable - get() = Observable.create { emitter -> - val disposable = effects(emitter::onNext) - emitter.setCancellable { disposable.dispose() } - } + get() = effects().asObservable() diff --git a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt index cbf6d989..b8371dc6 100644 --- a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt +++ b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt @@ -4,7 +4,6 @@ import io.reactivex.Completable import io.reactivex.Maybe import io.reactivex.Observable import io.reactivex.Single -import vivid.money.elmslie.core.disposable.Disposable import vivid.money.elmslie.core.switcher.Switcher /** @@ -18,39 +17,40 @@ fun Switcher.cancel(delayMillis: Long = 0) = observable(delayMillis) { Observabl * Executes an [action] and cancels all previous requests scheduled for this [Switcher]. * * @param delayMillis Operation delay measured with milliseconds. + * ``` * Can be specified to debounce requests. - * @param action Operation to be executed. + * @param action + * ``` + * Operation to be executed. */ fun Switcher.observable( delayMillis: Long = 0, action: () -> Observable, -): Observable = Observable.create { emitter -> - val disposable = switchInternal(delayMillis) { - val rxDisposable = action().subscribe(emitter::onNext, emitter::onError) - Disposable { rxDisposable.dispose() } +): Observable = + Observable.create { emitter -> + val job = + switchInternal(delayMillis) { + action() + .doOnComplete(emitter::onComplete) + .subscribe(emitter::onNext, emitter::onError) + } + + emitter.setCancellable { job?.cancel() } } - emitter.setCancellable { disposable.dispose() } -} -/** - * Same as [observable], but for [Single]. - */ +/** Same as [observable], but for [Single]. */ fun Switcher.single( delayMillis: Long = 0, action: () -> Single, ): Single = observable(delayMillis) { action().toObservable() }.firstOrError() -/** - * Same as [observable], but for [Maybe]. - */ +/** Same as [observable], but for [Maybe]. */ fun Switcher.maybe( delayMillis: Long = 0, action: () -> Maybe, ): Maybe = observable(delayMillis) { action().toObservable() }.firstElement() -/** - * Same as [observable], but for [Completable]. - */ +/** Same as [observable], but for [Completable]. */ fun Switcher.completable( delayMillis: Long = 0, action: () -> Completable, diff --git a/elmslie-rxjava-3/build.gradle b/elmslie-rxjava-3/build.gradle index 1c95382f..02bfe9a4 100644 --- a/elmslie-rxjava-3/build.gradle +++ b/elmslie-rxjava-3/build.gradle @@ -5,6 +5,7 @@ plugins { dependencies { implementation(project(":elmslie-core")) implementation(deps.rx.rxJava3) + implementation(deps.coroutines.rx3) testImplementation(project(":elmslie-test-rxjava-3")) } diff --git a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt index de667303..922e263f 100644 --- a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt +++ b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt @@ -1,51 +1,35 @@ package vivid.money.elmslie.rx3 import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.schedulers.Schedulers -import vivid.money.elmslie.core.store.StateReducer +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.asObservable +import vivid.money.elmslie.core.store.DefaultActor import vivid.money.elmslie.core.store.ElmStore +import vivid.money.elmslie.core.store.StateReducer import vivid.money.elmslie.core.store.Store -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.store.DefaultActor -/** - * A [Store] implementation that uses RxJava3 for multithreading - */ +/** A [Store] implementation that uses RxJava3 for multithreading */ class ElmStoreCompat( initialState: State, reducer: StateReducer, actor: Actor -) : Store by ElmStore( - initialState = initialState, - reducer = reducer, - actor = actor.toActor() -) +) : + Store by ElmStore( + initialState = initialState, + reducer = reducer, + actor = actor.toActor() + ) private fun Actor.toActor() = - DefaultActor { command, onEvent, onError -> - val disposable = execute(command) - .observeOn(Schedulers.io()) - .subscribe( - onEvent, - onError, - ) - Disposable { disposable.dispose() } + DefaultActor { command -> + execute(command) + .asFlow() } -/** - * An extension for accessing [Store] states as an [Observable] - */ +/** An extension for accessing [Store] states as an [Observable] */ val Store.states: Observable - get() = Observable.create { emitter -> - val disposable = states(emitter::onNext) - emitter.setCancellable { disposable.dispose() } - } + get() = states().asObservable() -/** - * An extension for accessing [Store] effects as an [Observable] - */ +/** An extension for accessing [Store] effects as an [Observable] */ val Store.effects: Observable - get() = Observable.create { emitter -> - val disposable = effects(emitter::onNext) - emitter.setCancellable { disposable.dispose() } - } + get() = effects().asObservable() diff --git a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt index 32538d53..2c7c6b3f 100644 --- a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt +++ b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt @@ -4,7 +4,6 @@ import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single -import vivid.money.elmslie.core.disposable.Disposable import vivid.money.elmslie.core.switcher.Switcher /** @@ -23,56 +22,42 @@ fun Switcher.cancel(delayMillis: Long = 0) = observable(delayMillis) { Observabl fun Switcher.observable( delayMillis: Long = 0, action: () -> Observable, -): Observable = Observable.create { emitter -> - val disposable = switchInternal(delayMillis) { - val rxDisposable = action() - .doOnComplete(emitter::onComplete) - .subscribe(emitter::onNext, emitter::onError) - Disposable { - emitter.onComplete() - rxDisposable.dispose() - } +): Observable = + Observable.create { emitter -> + val job = + switchInternal(delayMillis) { + action() + .doOnComplete(emitter::onComplete) + .subscribe(emitter::onNext, emitter::onError) + } + emitter.setCancellable { job?.cancel() } } - emitter.setCancellable(disposable::dispose) -} -/** - * Same as [observable], but for [Single]. - */ +/** Same as [observable], but for [Single]. */ fun Switcher.single( delayMillis: Long = 0, action: () -> Single, ): Single = observable(delayMillis) { action().toObservable() }.firstOrError() -/** - * Same as [observable], but for [Maybe]. - */ +/** Same as [observable], but for [Maybe]. */ fun Switcher.maybe( delayMillis: Long = 0, action: () -> Maybe, ): Maybe = observable(delayMillis) { action().toObservable() }.firstElement() -/** - * Same as [observable], but for [Completable]. - */ +/** Same as [observable], but for [Completable]. */ fun Switcher.completable( delayMillis: Long = 0, action: () -> Completable, ): Completable = observable(delayMillis) { action().toObservable() }.ignoreElements() -@Deprecated( - "Please, use property methods", - ReplaceWith("observable(delayMillis, action)") -) +@Deprecated("Please, use property methods", ReplaceWith("observable(delayMillis, action)")) fun Switcher.switch( delayMillis: Long = 0, action: () -> Observable, ) = observable(delayMillis, action) -@Deprecated( - "Please use instance methods", - ReplaceWith("switcher.observable(delayMillis, action)") -) +@Deprecated("Please use instance methods", ReplaceWith("switcher.observable(delayMillis, action)")) fun switchOn( switcher: Switcher, delayMillis: Long = 0, diff --git a/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/App.kt b/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/App.kt index 5cb115f6..3b7e2033 100644 --- a/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/App.kt +++ b/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/App.kt @@ -1,5 +1,6 @@ package vivid.money.elmslie.samples.android.loader +import androidx.multidex.BuildConfig import androidx.multidex.MultiDexApplication import vivid.money.elmslie.core.config.ElmslieConfig import vivid.money.elmslie.core.logger.strategy.IgnoreLog diff --git a/elmslie-samples/compose-paging/src/main/java/vivid/money/elmslie/samples/android/compose/view/PagingFragment.kt b/elmslie-samples/compose-paging/src/main/java/vivid/money/elmslie/samples/android/compose/view/PagingFragment.kt index 19173e54..38cf9564 100644 --- a/elmslie-samples/compose-paging/src/main/java/vivid/money/elmslie/samples/android/compose/view/PagingFragment.kt +++ b/elmslie-samples/compose-paging/src/main/java/vivid/money/elmslie/samples/android/compose/view/PagingFragment.kt @@ -29,16 +29,16 @@ class PagingFragment : ElmComponentFragment?, onRefresh: () -> Unit, onReloadScreen: () -> Unit, onReloadPage: () -> Unit, @@ -36,9 +34,9 @@ fun PagingScreen( state.error == null && state.items == null -> Shimmers() state.items != null -> List(state, onRefresh, onCloseToEnd, onReloadPage) } - effect?.takeIfInstanceOf()?.key?.let { - Error(scaffoldState = scaffoldState, key = it) - } +// effect?.takeIfInstanceOf()?.key?.let { +// Error(scaffoldState = scaffoldState, key = it) +// } } } diff --git a/elmslie-samples/java-notes/build.gradle b/elmslie-samples/java-notes/build.gradle index 33854e75..3a7f38d9 100644 --- a/elmslie-samples/java-notes/build.gradle +++ b/elmslie-samples/java-notes/build.gradle @@ -4,7 +4,9 @@ plugins { dependencies { implementation(project(":elmslie-core")) + implementation(deps.coroutines.core) + testImplementation(deps.coroutines.test) testImplementation(project(":elmslie-test")) } diff --git a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesActor.java b/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesActor.java index 3d77b469..110610db 100644 --- a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesActor.java +++ b/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesActor.java @@ -4,20 +4,19 @@ import kotlin.Unit; import kotlin.jvm.functions.Function1; -import vivid.money.elmslie.core.disposable.Disposable; +import kotlinx.coroutines.CoroutineScope; import vivid.money.elmslie.core.store.DefaultActor; import vivid.money.elmslie.samples.notes.model.Command; import vivid.money.elmslie.samples.notes.model.Event; public abstract class NotesActor implements DefaultActor { + @NotNull - @Override - public Disposable execute( + public void execute( @NotNull Command command, + @NotNull CoroutineScope coroutineScope, @NotNull Function1 onEvent, @NotNull Function1 onError ) { - return () -> { - }; } } diff --git a/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java b/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java index ddef1874..d20a47a2 100644 --- a/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java +++ b/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java @@ -9,7 +9,11 @@ import java.util.Collections; import kotlin.jvm.JvmField; +import kotlin.jvm.functions.Function2; +import kotlinx.coroutines.test.TestBuildersJvmKt; +import kotlinx.coroutines.test.TestBuildersKt; import vivid.money.elmslie.test.background.executor.MockBackgroundExecutorExtension; +import vivid.money.elmslie.test.background.executor.TestDispatcherExtension; public class NotesTest { @@ -17,6 +21,10 @@ public class NotesTest { @RegisterExtension public Extension extension = new MockBackgroundExecutorExtension(); + @JvmField + @RegisterExtension + public TestDispatcherExtension testDispatcherExtension = new TestDispatcherExtension(); + @Test public void notesAreEmptyInitially() { Notes notes = new Notes(); diff --git a/elmslie-samples/kotlin-calculator/build.gradle b/elmslie-samples/kotlin-calculator/build.gradle index 11bd5357..759d11cf 100644 --- a/elmslie-samples/kotlin-calculator/build.gradle +++ b/elmslie-samples/kotlin-calculator/build.gradle @@ -2,11 +2,18 @@ plugins { id("kotlin") } +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions.freeCompilerArgs += [ + "-opt-in=kotlin.RequiresOptIn" + ] +} + dependencies { implementation(project(":elmslie-core")) implementation(project(":elmslie-rxjava-3")) implementation(deps.rx.rxJava3) + testImplementation(deps.coroutines.test) testImplementation(project(":elmslie-test")) testImplementation(project(":elmslie-test-rxjava-3")) } diff --git a/elmslie-samples/kotlin-calculator/src/test/java/vivid/money/elmslie/samples/calculator/StoreKtTest.kt b/elmslie-samples/kotlin-calculator/src/test/java/vivid/money/elmslie/samples/calculator/StoreKtTest.kt index d7b4690d..f9954009 100644 --- a/elmslie-samples/kotlin-calculator/src/test/java/vivid/money/elmslie/samples/calculator/StoreKtTest.kt +++ b/elmslie-samples/kotlin-calculator/src/test/java/vivid/money/elmslie/samples/calculator/StoreKtTest.kt @@ -1,25 +1,28 @@ package vivid.money.elmslie.samples.calculator import io.reactivex.rxjava3.schedulers.TestScheduler +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import vivid.money.elmslie.test.TestSchedulerExtension import vivid.money.elmslie.test.background.executor.MockBackgroundExecutorExtension +import vivid.money.elmslie.test.background.executor.TestDispatcherExtension +@OptIn(ExperimentalCoroutinesApi::class) internal class StoreKtTest { private val scheduler = TestScheduler() - @JvmField - @RegisterExtension - val schedulerExtension = TestSchedulerExtension(scheduler) + @JvmField @RegisterExtension val schedulerExtension = TestSchedulerExtension(scheduler) - @JvmField - @RegisterExtension - val executorExtension = MockBackgroundExecutorExtension() + @JvmField @RegisterExtension val executorExtension = MockBackgroundExecutorExtension() + + @JvmField @RegisterExtension val testDispatcherExtension = TestDispatcherExtension() @Test - fun `1 + 1 = 2`() { + fun `1 + 1 = 2`() = runTest { val calculator = Calculator() val errors = calculator.errors().test() val results = calculator.results().test() @@ -30,13 +33,14 @@ internal class StoreKtTest { calculator.evaluate() scheduler.triggerActions() + advanceUntilIdle() results.assertValues(Effect.NotifyNewResult(1), Effect.NotifyNewResult(2)) errors.assertEmpty() } @Test - fun `1 + 1 + 1 = 3`() { + fun `1 + 1 + 1 = 3`() = runTest { val calculator = Calculator() val errors = calculator.errors().test() val results = calculator.results().test() @@ -49,13 +53,18 @@ internal class StoreKtTest { calculator.evaluate() scheduler.triggerActions() + advanceUntilIdle() - results.assertValues(Effect.NotifyNewResult(1), Effect.NotifyNewResult(2), Effect.NotifyNewResult(3)) + results.assertValues( + Effect.NotifyNewResult(1), + Effect.NotifyNewResult(2), + Effect.NotifyNewResult(3) + ) errors.assertEmpty() } @Test - fun `1 + 2 times 3 minus 4 div 5 = 1`() { + fun `1 + 2 times 3 minus 4 div 5 = 1`() = runTest { val calculator = Calculator() val errors = calculator.errors().test() val results = calculator.results().test() @@ -72,6 +81,7 @@ internal class StoreKtTest { calculator.evaluate() scheduler.triggerActions() + advanceUntilIdle() results.assertValues( Effect.NotifyNewResult(1), @@ -84,7 +94,7 @@ internal class StoreKtTest { } @Test - fun `not a digit produces error`() { + fun `not a digit produces error`() = runTest { val calculator = Calculator() val errors = calculator.errors().test() val results = calculator.results().test() @@ -92,13 +102,14 @@ internal class StoreKtTest { calculator.digit('x') scheduler.triggerActions() + advanceUntilIdle() results.assertEmpty() errors.assertValue(Effect.NotifyError("x is not a digit")) } @Test - fun `10 digits produces error`() { + fun `10 digits produces error`() = runTest { val calculator = Calculator() val errors = calculator.errors().test() val results = calculator.results().test() @@ -115,6 +126,7 @@ internal class StoreKtTest { calculator.digit('1') scheduler.triggerActions() + advanceUntilIdle() results.assertEmpty() errors.assertValue(Effect.NotifyError("Reached max input length")) diff --git a/elmslie-test/build.gradle b/elmslie-test/build.gradle index 4e5cec80..797495a1 100644 --- a/elmslie-test/build.gradle +++ b/elmslie-test/build.gradle @@ -2,11 +2,18 @@ plugins { id("kotlin") } +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions.freeCompilerArgs += [ + "-opt-in=kotlin.RequiresOptIn" + ] +} + dependencies { implementation(project(":elmslie-core")) implementation(deps.test.jUnit5) implementation(deps.rx.rxJava3) + implementation(deps.coroutines.test) } apply from: "../gradle/kotlin-publishing.gradle" diff --git a/elmslie-test/src/main/java/vivid/money/elmslie/test/background/executor/TestDispatcherExtension.kt b/elmslie-test/src/main/java/vivid/money/elmslie/test/background/executor/TestDispatcherExtension.kt new file mode 100644 index 00000000..6bffb495 --- /dev/null +++ b/elmslie-test/src/main/java/vivid/money/elmslie/test/background/executor/TestDispatcherExtension.kt @@ -0,0 +1,28 @@ +package vivid.money.elmslie.test.background.executor + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext +import vivid.money.elmslie.core.config.ElmslieConfig + +@OptIn(ExperimentalCoroutinesApi::class) +class TestDispatcherExtension +constructor( + val testDispatcher: TestDispatcher = StandardTestDispatcher(), +) : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + ElmslieConfig.ioDispatchers { testDispatcher } + Dispatchers.setMain(testDispatcher) + } + + override fun afterEach(context: ExtensionContext?) { + Dispatchers.resetMain() + } +} diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index 1108dae1..c83bd476 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -15,6 +15,7 @@ ext.deps = [ appcompat : "androidx.appcompat:appcompat:1.4.2", appStartup : "androidx.startup:startup-runtime:1.1.1", lifecycle : "androidx.lifecycle:lifecycle-common:2.5.0", + lifecycleKtx : "androidx.lifecycle:lifecycle-runtime-ktx:2.5.0", lifecycleViewmodel : "androidx.lifecycle:lifecycle-viewmodel:2.5.0", lifecycleViewModelSavedState: "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.5.0", material : "com.google.android.material:material:1.6.1", @@ -40,6 +41,8 @@ ext.deps = [ ], coroutines: [ core: "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2", + rx3 : "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.6.2", + rx2 : "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.6.2", test: "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2", ], test : [ From e5854000706b52f302db5ff1f56431a6be0fc128 Mon Sep 17 00:00:00 2001 From: Dmitrii Berdnikov Date: Mon, 12 Sep 2022 13:39:29 +0300 Subject: [PATCH 02/87] Add ElmStore wrapper to cache effects until there is at least one subscriber --- .../elmslie/core/store/ElmCachedStore.kt | 48 +++++ .../elmslie/core/store/ElmCachedStoreTest.kt | 174 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt create mode 100644 elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmCachedStoreTest.kt diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt new file mode 100644 index 00000000..5094c202 --- /dev/null +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt @@ -0,0 +1,48 @@ +package vivid.money.elmslie.core.store + +import java.util.concurrent.LinkedBlockingQueue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import vivid.money.elmslie.core.config.ElmslieConfig + +/** + * Caches effects until there is at least one collector. + * + * Note, that effects from the cache are replayed only for the first one. + * + * Wrap the store with the instance of [ElmCachedStore] to get the desired behavior like this: + * ``` + * ``` + */ +// TODO Should be moved to android artifact? +class ElmCachedStore( + elmStore: ElmStore, +) : Store by elmStore { + + private val storeScope = CoroutineScope(ElmslieConfig.ioDispatchers + SupervisorJob()) + + private val effectsCache = LinkedBlockingQueue() + private val effectsFlow = MutableSharedFlow() + + init { + storeScope.launch { + elmStore.effects().collect { effect -> + if (effectsFlow.subscriptionCount.value > 0) { + effectsFlow.emit(effect) + } else { + effectsCache.add(effect) + } + } + } + } + + override fun effects(): Flow = + effectsFlow.onSubscription { + for (effect in effectsCache) { + emit(effect) + } + effectsCache.clear() + } +} diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmCachedStoreTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmCachedStoreTest.kt new file mode 100644 index 00000000..9967ebf7 --- /dev/null +++ b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmCachedStoreTest.kt @@ -0,0 +1,174 @@ +package vivid.money.elmslie.core.store + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import vivid.money.elmslie.core.testutil.model.Command +import vivid.money.elmslie.core.testutil.model.Effect +import vivid.money.elmslie.core.testutil.model.Event +import vivid.money.elmslie.core.testutil.model.State +import vivid.money.elmslie.test.background.executor.TestDispatcherExtension + +@OptIn(ExperimentalCoroutinesApi::class) +class ElmCachedStoreTest { + + @JvmField @RegisterExtension val testDispatcherExtension = TestDispatcherExtension() + + @Test + fun `Should collect effects which are emitted before collecting flow`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> + Result( + state = state, + effect = Effect(event.value), + ) + }, + ) + .toCachedStore() + + store.start() + store.accept(Event(value = 1)) + store.accept(Event(value = 2)) + store.accept(Event(value = 2)) + advanceUntilIdle() + + val effects = mutableListOf() + val job = launch { store.effects().toList(effects) } + advanceUntilIdle() + + assertEquals( + listOf( + Effect(value = 1), + Effect(value = 2), + Effect(value = 2), + ), + effects + ) + + job.cancel() + } + + @Test + fun `Should collect effects which are emitted before collecting flow and after`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> + Result( + state = state, + effect = Effect(event.value), + ) + }, + ) + .toCachedStore() + + store.start() + store.accept(Event(value = 1)) + store.accept(Event(value = 2)) + store.accept(Event(value = 2)) + advanceUntilIdle() + + val effects = mutableListOf() + val job = launch { store.effects().toList(effects) } + store.accept(Event(value = 3)) + advanceUntilIdle() + + assertEquals( + listOf( + Effect(value = 1), + Effect(value = 2), + Effect(value = 2), + Effect(value = 3), + ), + effects + ) + + job.cancel() + } + + @Test + fun `Should emit effects from cache only for the first subscriber`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> + Result( + state = state, + effect = Effect(event.value), + ) + }, + ) + .toCachedStore() + + store.start() + store.accept(Event(value = 1)) + advanceUntilIdle() + + val effects1 = mutableListOf() + val effects2 = mutableListOf() + val job1 = launch { store.effects().toList(effects1) } + runCurrent() + val job2 = launch { store.effects().toList(effects2) } + runCurrent() + + assertEquals( + listOf( + Effect(value = 1), + ), + effects1 + ) + + assertEquals(emptyList(), effects2) + + job1.cancel() + job2.cancel() + } + + @Test + fun `Should cache effects if there is no left collectors`() = runTest { + val store = + store( + state = State(), + reducer = { event, state -> + Result( + state = state, + effect = Effect(event.value), + ) + }, + ) + .toCachedStore() + + store.start() + val effects = mutableListOf() + var job1 = launch { store.effects().toList(effects) } + runCurrent() + job1.cancel() + store.accept(Event(value = 2)) + runCurrent() + job1 = launch { store.effects().toList(effects) } + runCurrent() + + assertEquals( + listOf( + Effect(value = 2), + ), + effects + ) + + job1.cancel() + } + + private fun store( + state: State, + reducer: StateReducer = NoOpReducer(), + actor: DefaultActor = NoOpActor() + ) = ElmStore(state, reducer, actor) +} From 5cfcbbd41697e611b3f95d3151d89bb042260c54 Mon Sep 17 00:00:00 2001 From: Dmitrii Berdnikov Date: Wed, 14 Sep 2022 16:40:20 +0300 Subject: [PATCH 03/87] Refactor Switcher to use coroutines --- elmslie-android/build.gradle | 1 + .../java/vivid/money/elmslie/core/ElmScope.kt | 9 ++ .../elmslie/core/config/ElmslieConfig.kt | 17 +-- .../elmslie/core/store/ElmCachedStore.kt | 15 +- .../money/elmslie/core/store/ElmStore.kt | 25 +-- .../money/elmslie/core/store/MappingActor.kt | 16 +- .../core/store/binding/ConversationRules.kt | 19 ++- .../core/store/binding/ConversionContract.kt | 28 ++-- .../money/elmslie/core/switcher/Switcher.kt | 44 ++++-- elmslie-coroutines/build.gradle | 3 + .../elmslie/coroutines/SwitcherCompat.kt | 42 +++--- .../elmslie/coroutines/SwitcherCompatTest.kt | 120 +++++++++++++++ .../elmslie/rx2/switcher/SwitcherCompat.kt | 22 ++- elmslie-rxjava-3/build.gradle | 8 + .../elmslie/rx3/switcher/SwitcherCompat.kt | 38 +++-- .../rx3/switcher/SwitcherCompatTest.kt | 142 +++++++++--------- .../coroutines/timer/elm/TimerActor.kt | 16 +- .../elmslie/samples/notes/NotesTest.java | 12 +- gradle/dependencies.gradle | 1 + 19 files changed, 378 insertions(+), 200 deletions(-) create mode 100644 elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt create mode 100644 elmslie-coroutines/src/test/java/vivid/money/elmslie/coroutines/SwitcherCompatTest.kt diff --git a/elmslie-android/build.gradle b/elmslie-android/build.gradle index 580c4b45..6f0cffe0 100644 --- a/elmslie-android/build.gradle +++ b/elmslie-android/build.gradle @@ -10,6 +10,7 @@ dependencies { implementation(deps.android.appStartup) implementation(deps.android.lifecycle) implementation(deps.android.lifecycleKtx) + implementation(deps.android.paging) } apply from: "../gradle/junit-5.gradle" diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt new file mode 100644 index 00000000..ae6efdc0 --- /dev/null +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt @@ -0,0 +1,9 @@ +package vivid.money.elmslie.core + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import vivid.money.elmslie.core.config.ElmslieConfig + +fun ElmScope(name: String): CoroutineScope = + CoroutineScope(ElmslieConfig.ioDispatchers + SupervisorJob() + CoroutineName(name)) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt index 25355ed3..c3b2e20e 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt @@ -3,9 +3,7 @@ package vivid.money.elmslie.core.config import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import vivid.money.elmslie.core.logger.ElmslieLogConfiguration import vivid.money.elmslie.core.logger.ElmslieLogger import vivid.money.elmslie.core.logger.strategy.IgnoreLog @@ -14,28 +12,25 @@ import vivid.money.elmslie.core.switcher.Switcher object ElmslieConfig { - @Volatile private lateinit var loggerInternal: ElmslieLogger + @Volatile private lateinit var _logger: ElmslieLogger - @Volatile private lateinit var reducerExecutorInternal: ScheduledExecutorService + @Volatile private lateinit var _reducerExecutor: ScheduledExecutorService @Volatile private lateinit var _ioDispatchers: CoroutineDispatcher val logger: ElmslieLogger - get() = loggerInternal + get() = _logger val backgroundExecutor: ScheduledExecutorService - get() = reducerExecutorInternal + get() = _reducerExecutor val ioDispatchers: CoroutineDispatcher get() = _ioDispatchers - val coroutineScope: CoroutineScope - init { logger { always(IgnoreLog) } backgroundExecutor { Executors.newSingleThreadScheduledExecutor() } ioDispatchers { Dispatchers.IO } - coroutineScope = CoroutineScope(ioDispatchers + SupervisorJob()) } /** @@ -51,7 +46,7 @@ object ElmslieConfig { * ``` */ fun logger(config: (ElmslieLogConfiguration.() -> Unit)) { - ElmslieLogConfiguration().apply(config).build().also { loggerInternal = it } + ElmslieLogConfiguration().apply(config).build().also { _logger = it } } /** @@ -63,7 +58,7 @@ object ElmslieConfig { * ``` */ fun backgroundExecutor(builder: () -> ScheduledExecutorService) { - reducerExecutorInternal = builder() + _reducerExecutor = builder() } /** diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt index 5094c202..daee4a1c 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt @@ -1,11 +1,10 @@ package vivid.money.elmslie.core.store import java.util.concurrent.LinkedBlockingQueue -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import vivid.money.elmslie.core.config.ElmslieConfig +import vivid.money.elmslie.core.ElmScope /** * Caches effects until there is at least one collector. @@ -18,13 +17,12 @@ import vivid.money.elmslie.core.config.ElmslieConfig */ // TODO Should be moved to android artifact? class ElmCachedStore( - elmStore: ElmStore, + private val elmStore: ElmStore, ) : Store by elmStore { - private val storeScope = CoroutineScope(ElmslieConfig.ioDispatchers + SupervisorJob()) - private val effectsCache = LinkedBlockingQueue() private val effectsFlow = MutableSharedFlow() + private val storeScope = ElmScope("CachedStoreScope") init { storeScope.launch { @@ -38,6 +36,11 @@ class ElmCachedStore( } } + override fun stop() { + elmStore.stop() + storeScope.cancel() + } + override fun effects(): Flow = effectsFlow.onSubscription { for (effect in effectsCache) { diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt index 59480dcb..7d832853 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt @@ -1,11 +1,9 @@ package vivid.money.elmslie.core.store import java.util.concurrent.atomic.AtomicBoolean -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch +import vivid.money.elmslie.core.ElmScope import vivid.money.elmslie.core.config.ElmslieConfig import vivid.money.elmslie.core.store.exception.StoreAlreadyStartedException @@ -17,7 +15,7 @@ class ElmStore( ) : Store { private val logger = ElmslieConfig.logger - private val storeScope = CoroutineScope(ElmslieConfig.ioDispatchers + SupervisorJob()) + private val storeScope = ElmScope("StoreScope") override val isStarted: Boolean get() = _isStarted.get() @@ -31,7 +29,12 @@ class ElmStore( override fun accept(event: Event) = dispatchEvent(event) - override fun start() = this.also { requireNotStarted() } + override fun start(): Store { + if (!_isStarted.compareAndSet(false, true)) { + logger.fatal("Store start error", StoreAlreadyStartedException()) + } + return this + } override fun stop() { _isStarted.set(false) @@ -50,6 +53,8 @@ class ElmStore( statesFlow.value = state effects.forEach(::dispatchEffect) commands.forEach(::executeCommand) + } catch (error: CancellationException) { + throw error } catch (t: Throwable) { logger.fatal("You must handle all errors inside reducer", t) } @@ -72,15 +77,11 @@ class ElmStore( .catch { logger.nonfatal(error = it) } .collect { dispatchEvent(it) } } + } catch (error: CancellationException) { + throw error } catch (t: Throwable) { logger.fatal("Unexpected actor error", t) } - - private fun requireNotStarted() { - if (!_isStarted.compareAndSet(false, true)) { - logger.fatal("Store start error", StoreAlreadyStartedException()) - } - } } fun ElmStore diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt index dd60e06f..f4ecc732 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt @@ -1,21 +1,21 @@ package vivid.money.elmslie.core.store +import kotlinx.coroutines.CancellationException import vivid.money.elmslie.core.config.ElmslieConfig -/** - * Contains internal event mapping utilities - */ +/** Contains internal event mapping utilities */ interface MappingActor { companion object { private val logger = ElmslieConfig.logger } - fun Throwable.logErrorEvent( - errorMapper: (Throwable) -> Event? - ): Event? = errorMapper(this).also { - logger.nonfatal(error = this) - logger.debug("Failed app state: $it") + fun Throwable.logErrorEvent(errorMapper: (Throwable) -> Event?): Event? { + val error = (this as? CancellationException)?.cause ?: this + return errorMapper(error).also { + logger.nonfatal(error = error) + logger.debug("Failed app state: $it") + } } fun Event.logSuccessEvent() = logger.debug("Completed app state: $this") diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt index 5e30adf2..7645e13e 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt @@ -1,5 +1,7 @@ package vivid.money.elmslie.core.store.binding +import kotlinx.coroutines.cancel +import vivid.money.elmslie.core.ElmScope import vivid.money.elmslie.core.config.ElmslieConfig import vivid.money.elmslie.core.store.Store @@ -19,8 +21,12 @@ import vivid.money.elmslie.core.store.Store * @constructor Determines conversion rules */ internal class ConversationRules< - InitiatorEvent, InitiatorEffect, InitiatorState, ResponderEvent, ResponderEffect, ResponderState ->( + InitiatorEvent, + InitiatorEffect, + InitiatorState, + ResponderEvent, + ResponderEffect, + ResponderState>( private val initiator: Store, private val responder: Store, expecting: @@ -43,10 +49,12 @@ internal class ConversationRules< >.() -> Unit ) : Store by initiator { + private val conversationScope = ElmScope("ConversationScope") private val providedContract = - ConversionContract(initiator, responder, ElmslieConfig.ioDispatchers).apply(expecting) + ConversionContract(initiator, responder, conversationScope).apply(expecting) private val expectedContract = - ConversionContract(responder, initiator, ElmslieConfig.ioDispatchers).apply(receiving) + ConversionContract(responder, initiator, conversationScope).apply(receiving) + override fun start(): Store { initiator.start() @@ -57,8 +65,7 @@ internal class ConversationRules< } override fun stop() { - providedContract.revoke() - expectedContract.revoke() + conversationScope.cancel() responder.stop() initiator.stop() } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt index 1021b326..38431cd9 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt @@ -6,16 +6,18 @@ import vivid.money.elmslie.core.store.Store /** A contract for data exchange between stores. */ class ConversionContract< - InitiatorEvent, InitiatorEffect, InitiatorState, ResponderEvent, ResponderEffect, ResponderState ->( + InitiatorEvent, + InitiatorEffect, + InitiatorState, + ResponderEvent, + ResponderEffect, + ResponderState>( private val initiator: Store, private val responder: Store, - ioDispatcher: CoroutineDispatcher, + private val coroutineScope: CoroutineScope, ) { - private val coroutineScope: CoroutineScope = CoroutineScope(ioDispatcher) private val contracts = mutableSetOf<() -> Job>() - private val contractsJobs = mutableSetOf() /** Defines full direct state conversion between stores. */ fun states(conversion: InitiatorState.() -> ResponderEvent? = { null }) = @@ -54,12 +56,8 @@ class ConversionContract< coroutineScope.launch { valueProvider.collect { value -> cypher(value) - ?.let { encrypted -> - conversion(encrypted) - } - ?.let { - responder.accept(it) - } + ?.let { encrypted -> conversion(encrypted) } + ?.let { responder.accept(it) } } } } @@ -69,12 +67,6 @@ class ConversionContract< fun apply() { check(initiator.isStarted) check(responder.isStarted) - contracts.forEach { contractsJobs += it.invoke() } - - } - - /** Stops conversion between stores by revoking contracts. */ - fun revoke() { - contractsJobs.forEach { it.cancel() } + contracts.forEach { it.invoke() } } } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt index 78b9491e..9cb3a5b0 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt @@ -1,7 +1,7 @@ package vivid.money.elmslie.core.switcher import kotlinx.coroutines.* -import vivid.money.elmslie.core.config.ElmslieConfig +import kotlinx.coroutines.flow.* import vivid.money.elmslie.core.store.DefaultActor /** @@ -22,28 +22,40 @@ import vivid.money.elmslie.core.store.DefaultActor class Switcher { @Volatile private var currentJob: Job? = null - private val switcherScope: CoroutineScope = CoroutineScope(ElmslieConfig.ioDispatchers) /** - * Executes [action] as a job and cancels all previous ones. + * Collect given flow as a job and cancels all previous ones. * - * @param delayMillis operation delay measured with milliseconds. Can be specified to debounce existing requests. - * @param action new operation to be executed. + * @param coroutineScope outer scope where the result Flow will be collected. + * @param delayMillis operation delay measured with milliseconds. Can be specified to debounce + * existing requests. + * @param onEach callback for successful emission + * @param onComplete callback when flow is finished emission + * @param onError callback for failed emission */ - fun switchInternal( + fun Flow.switchInternal( + coroutineScope: CoroutineScope, delayMillis: Long = 0, - action: suspend () -> Unit, - ): Job? { + onEach: (Event) -> Unit, + onComplete: () -> Unit, + onError: (Throwable) -> Unit, + ): Job { currentJob?.cancel() - return try { - switcherScope.launch { - delay(delayMillis) - action.invoke() - } - } catch (_: CancellationException) { - currentJob?.cancel() - null + return coroutineScope + .launch { + delay(delayMillis) + this@switchInternal.cancellable() + .catch { onError(it) } + .collect { event -> onEach.invoke(event) } + onComplete.invoke() } .also { currentJob = it } } + + fun clear(job: Job) { + // clear reference only if job is cancelled by cancelling outer scope. + if (currentJob == job) { + currentJob = null + } + } } diff --git a/elmslie-coroutines/build.gradle b/elmslie-coroutines/build.gradle index b705b872..ab41a11c 100644 --- a/elmslie-coroutines/build.gradle +++ b/elmslie-coroutines/build.gradle @@ -11,6 +11,9 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { dependencies { implementation(project(":elmslie-core")) implementation(deps.coroutines.core) + + testImplementation(deps.coroutines.test) + testImplementation(project(":elmslie-test")) } apply from: "../gradle/junit-5.gradle" diff --git a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt index a59e9274..8af5124a 100644 --- a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt +++ b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt @@ -1,32 +1,36 @@ package vivid.money.elmslie.coroutines -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow import vivid.money.elmslie.core.switcher.Switcher -/** - * Cancels all scheduled actions after [delayMillis] pass. - * - * @param delayMillis Cancellation delay measured with milliseconds. - */ -fun Switcher.cancel(delayMillis: Long = 0) = switch(delayMillis) { flowOf() } - /** @see [Switcher] */ @Suppress("TooGenericExceptionCaught", "RethrowCaughtException") fun Switcher.switch( delayMillis: Long = 0, action: () -> Flow, -): Flow = channelFlow { - try { - val job = switchInternal(delayMillis) { action().collect { trySend(it) } } - awaitClose { job?.cancel() } - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - // Next action cancelled this before starting. Or some error happened while running action. - close(t) +): Flow = callbackFlow { + val job = + action + .invoke() + .switchInternal( + coroutineScope = this, + delayMillis = delayMillis, + onEach = { trySend(it) }, + onComplete = { close() }, + onError = { cancel(CancellationException("", it)) }, + ) + job.invokeOnCompletion { + if (it is CancellationException) { + close() + } } + + awaitClose { this@switch.clear(job) } } + +fun Switcher.cancel(delayMillis: Long = 0) = switch(delayMillis = delayMillis) { emptyFlow() } diff --git a/elmslie-coroutines/src/test/java/vivid/money/elmslie/coroutines/SwitcherCompatTest.kt b/elmslie-coroutines/src/test/java/vivid/money/elmslie/coroutines/SwitcherCompatTest.kt new file mode 100644 index 00000000..cce3fca0 --- /dev/null +++ b/elmslie-coroutines/src/test/java/vivid/money/elmslie/coroutines/SwitcherCompatTest.kt @@ -0,0 +1,120 @@ +package vivid.money.elmslie.coroutines + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import vivid.money.elmslie.core.switcher.Switcher +import vivid.money.elmslie.test.background.executor.TestDispatcherExtension + +@OptIn(ExperimentalCoroutinesApi::class) +class SwitcherCompatTest { + + @JvmField @RegisterExtension val testDispatcherExtension = TestDispatcherExtension() + + sealed interface Event { + object First : Event + object Second : Event + } + + @Test + fun `Switcher cancels previous request`() = runTest { + val switcher = Switcher() + + val values = mutableListOf() + + val firstCollector = launch { + switcher + .switch { + flow { + delay(2000L) + emit(Event.First) + } + } + .collect { values.add(it) } + } + advanceTimeBy(500L) + + val secondCollector = launch { + switcher.switch { flowOf(Event.Second) }.collect { values.add(it) } + } + + advanceTimeBy(2500L) + + Assertions.assertEquals( + listOf(Event.Second), + values, + ) + Assertions.assertEquals( + true, + firstCollector.isCompleted, + ) + + firstCollector.cancel() + secondCollector.cancel() + } + + @Test + fun `Switcher cancels request if error`() = runTest { + val switcher = Switcher() + val values = mutableListOf() + val firstCollector = launch { + switcher.switch { flow { error("Error") } }.collect { values.add(it) } + } + advanceUntilIdle() + + Assertions.assertEquals( + emptyList(), + values, + ) + Assertions.assertEquals( + true, + firstCollector.isCompleted, + ) + + firstCollector.cancel() + } + + @Test + fun `Switcher continue receiving updates`() = runTest { + val switcher = Switcher() + val values = mutableListOf() + val firstCollector = launch { + switcher + .switch { + flow { + while (true) { + delay(100L) + emit(Event.First) + } + } + } + .collect { values.add(it) } + } + advanceTimeBy(510L) + + Assertions.assertEquals( + listOf( + Event.First, + Event.First, + Event.First, + Event.First, + Event.First, + ), + values, + ) + Assertions.assertEquals( + false, + firstCollector.isCompleted, + ) + + firstCollector.cancel() + } +} diff --git a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt index b8371dc6..ddf68d1a 100644 --- a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt +++ b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt @@ -4,6 +4,8 @@ import io.reactivex.Completable import io.reactivex.Maybe import io.reactivex.Observable import io.reactivex.Single +import kotlinx.coroutines.rx2.asFlow +import vivid.money.elmslie.core.ElmScope import vivid.money.elmslie.core.switcher.Switcher /** @@ -11,7 +13,8 @@ import vivid.money.elmslie.core.switcher.Switcher * * @param delayMillis Cancellation delay measured with milliseconds. */ -fun Switcher.cancel(delayMillis: Long = 0) = observable(delayMillis) { Observable.empty() } +fun Switcher.cancel(delayMillis: Long = 0) = + observable(delayMillis) { Observable.empty() } /** * Executes an [action] and cancels all previous requests scheduled for this [Switcher]. @@ -29,13 +32,18 @@ fun Switcher.observable( ): Observable = Observable.create { emitter -> val job = - switchInternal(delayMillis) { - action() - .doOnComplete(emitter::onComplete) - .subscribe(emitter::onNext, emitter::onError) - } + action() + .asFlow() + .switchInternal( + coroutineScope = ElmScope("Switcher"), + delayMillis = delayMillis, + onEach = { emitter.onNext(it) }, + onError = { emitter.onError(it) }, + onComplete = { emitter.onComplete() }, + ) - emitter.setCancellable { job?.cancel() } + job.invokeOnCompletion { emitter.onComplete() } + emitter.setCancellable { clear(job) } } /** Same as [observable], but for [Single]. */ diff --git a/elmslie-rxjava-3/build.gradle b/elmslie-rxjava-3/build.gradle index 02bfe9a4..522b97b0 100644 --- a/elmslie-rxjava-3/build.gradle +++ b/elmslie-rxjava-3/build.gradle @@ -2,11 +2,19 @@ plugins { id("kotlin") } +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions.freeCompilerArgs += [ + "-opt-in=kotlin.RequiresOptIn" + ] +} + dependencies { implementation(project(":elmslie-core")) implementation(deps.rx.rxJava3) implementation(deps.coroutines.rx3) + testImplementation(deps.coroutines.test) + testImplementation(project(":elmslie-test")) testImplementation(project(":elmslie-test-rxjava-3")) } diff --git a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt index 2c7c6b3f..7ebde4a2 100644 --- a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt +++ b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt @@ -4,6 +4,8 @@ import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.rx3.asFlow +import vivid.money.elmslie.core.ElmScope import vivid.money.elmslie.core.switcher.Switcher /** @@ -11,7 +13,8 @@ import vivid.money.elmslie.core.switcher.Switcher * * @param delayMillis Cancellation delay measured with milliseconds. */ -fun Switcher.cancel(delayMillis: Long = 0) = observable(delayMillis) { Observable.empty() } +fun Switcher.cancel(delayMillis: Long = 0) = + observable(delayMillis = delayMillis) { Observable.empty() } /** * Executes [action] and cancels all previous requests scheduled on this [Switcher] @@ -22,44 +25,51 @@ fun Switcher.cancel(delayMillis: Long = 0) = observable(delayMillis) { Observabl fun Switcher.observable( delayMillis: Long = 0, action: () -> Observable, -): Observable = - Observable.create { emitter -> +): Observable { + return Observable.create { emitter -> val job = - switchInternal(delayMillis) { - action() - .doOnComplete(emitter::onComplete) - .subscribe(emitter::onNext, emitter::onError) - } - emitter.setCancellable { job?.cancel() } + action() + .asFlow() + .switchInternal( + coroutineScope = ElmScope("Switcher"), + delayMillis = delayMillis, + onEach = { emitter.onNext(it) }, + onError = { emitter.onError(it) }, + onComplete = { emitter.onComplete() }, + ) + + job.invokeOnCompletion { emitter.onComplete() } + emitter.setCancellable { clear(job) } } +} /** Same as [observable], but for [Single]. */ fun Switcher.single( delayMillis: Long = 0, action: () -> Single, -): Single = observable(delayMillis) { action().toObservable() }.firstOrError() +): Single = observable(delayMillis = delayMillis) { action().toObservable() }.firstOrError() /** Same as [observable], but for [Maybe]. */ fun Switcher.maybe( delayMillis: Long = 0, action: () -> Maybe, -): Maybe = observable(delayMillis) { action().toObservable() }.firstElement() +): Maybe = observable(delayMillis = delayMillis) { action().toObservable() }.firstElement() /** Same as [observable], but for [Completable]. */ fun Switcher.completable( delayMillis: Long = 0, action: () -> Completable, -): Completable = observable(delayMillis) { action().toObservable() }.ignoreElements() +): Completable = observable(delayMillis = delayMillis) { action().toObservable() }.ignoreElements() @Deprecated("Please, use property methods", ReplaceWith("observable(delayMillis, action)")) fun Switcher.switch( delayMillis: Long = 0, action: () -> Observable, -) = observable(delayMillis, action) +) = observable(delayMillis = delayMillis, action = action) @Deprecated("Please use instance methods", ReplaceWith("switcher.observable(delayMillis, action)")) fun switchOn( switcher: Switcher, delayMillis: Long = 0, action: () -> Observable -) = switcher.observable(delayMillis, action) +) = switcher.observable(delayMillis = delayMillis, action = action) diff --git a/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt b/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt index 70c5ac03..cab3d3c3 100644 --- a/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt +++ b/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt @@ -2,159 +2,161 @@ package vivid.money.elmslie.rx3.switcher import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.schedulers.TestScheduler +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import vivid.money.elmslie.core.switcher.Switcher import vivid.money.elmslie.test.TestSchedulerExtension -import java.util.concurrent.TimeUnit +import vivid.money.elmslie.test.background.executor.TestDispatcherExtension /** - * Area for improvement: assert negative scenarios. - * There's no reason to test other methods since they're very similar. + * Area for improvement: assert negative scenarios. There's no reason to test other methods since + * they're very similar. */ +@OptIn(ExperimentalCoroutinesApi::class) internal class SwitcherCompatTest { private val scheduler = TestScheduler() - @JvmField - @RegisterExtension - val extension = TestSchedulerExtension(scheduler) + @JvmField @RegisterExtension val extension = TestSchedulerExtension(scheduler) + + @JvmField @RegisterExtension val testDispatcherExtension = TestDispatcherExtension() object Event + object Event2 + @Test - fun `Switcher executes immediate action`() { + fun `Switcher executes immediate action`() = runTest { val switcher = Switcher() - val observer = switcher.observable(0) { - Observable.just(Event) - }.test() + val observer = switcher.observable(delayMillis = 0) { Observable.just(Event) }.test() - switcher.await() + advanceUntilIdle() scheduler.triggerActions() observer.assertResult(Event) } @Test - fun `Switcher cancels previous request`() { + fun `Switcher cancels previous request`() = runTest { val switcher = Switcher() - val firstObserver = switcher.observable(0) { - Observable.timer(2, TimeUnit.SECONDS).map { Event } - }.test() + val firstObserver = + switcher + .observable(delayMillis = 0) { Observable.timer(2, TimeUnit.SECONDS).map { Event } } + .test() - switcher.await() scheduler.triggerActions() + runCurrent() - val secondObserver = switcher.observable(0) { - Observable.just(Event) - }.test() - - switcher.await() + val secondObserver = switcher.observable(delayMillis = 0) { Observable.just(Event2) }.test() + runCurrent() scheduler.triggerActions() - secondObserver.assertResult(Event) + secondObserver.assertResult(Event2) firstObserver.assertResult() } @Test - fun `Switcher executes sequential requests`() { + fun `Switcher executes sequential requests`() = runTest { val switcher = Switcher() - val firstObserver = switcher.observable(0) { - Observable.timer(2, TimeUnit.SECONDS).map { Event } - }.test() + val firstObserver = + switcher + .observable(delayMillis = 0) { Observable.timer(2, TimeUnit.SECONDS).map { Event } } + .test() - switcher.await() + runCurrent() scheduler.advanceTimeBy(2, TimeUnit.SECONDS) + advanceTimeBy(2000L) - val secondObserver = switcher.observable(0) { - Observable.timer(2, TimeUnit.SECONDS).map { Event } - }.test() + val secondObserver = + switcher + .observable(delayMillis = 0) { Observable.timer(2, TimeUnit.SECONDS).map { Event } } + .test() - switcher.await() + runCurrent() scheduler.advanceTimeBy(2, TimeUnit.SECONDS) + advanceTimeBy(2000L) firstObserver.assertResult(Event) secondObserver.assertResult(Event) } @Test - fun `Switcher cancels delayed request`() { + fun `Switcher cancels delayed request`() = runTest { val switcher = Switcher() - val firstObserver = switcher.observable(1000L) { - Observable.just(Event) - }.test() + val firstObserver = + switcher.observable(delayMillis = 1000L) { Observable.just(Event) }.test() - val secondObserver = switcher.observable(0L) { - Observable.just(Event) - }.test() + val secondObserver = switcher.observable(delayMillis = 0L) { Observable.just(Event) }.test() - switcher.await() + advanceUntilIdle() scheduler.triggerActions() - firstObserver.assertValuesOnly() + firstObserver.assertResult() secondObserver.assertResult(Event) } @Test - fun `Switcher cancels pending requests`() { + fun `Switcher cancels pending requests`() = runTest { val switcher = Switcher() - val firstObserver = switcher.observable(0) { - Observable.timer(2, TimeUnit.SECONDS).map { Event } - }.test() + val firstObserver = + switcher + .observable(delayMillis = 0) { Observable.timer(2, TimeUnit.SECONDS).map { Event } } + .test() - val secondObserver = switcher.observable(0) { - Observable.timer(2, TimeUnit.SECONDS).map { Event } - }.test() + val secondObserver = + switcher + .observable(delayMillis = 0) { Observable.timer(2, TimeUnit.SECONDS).map { Event } } + .test() - val thirdObserver = switcher.observable(0) { - Observable.just(Event) - }.test() + val thirdObserver = switcher.observable(delayMillis = 0) { Observable.just(Event) }.test() - switcher.await() + advanceUntilIdle() scheduler.advanceTimeBy(1, TimeUnit.SECONDS) - firstObserver.assertValuesOnly() - secondObserver.assertValuesOnly() + firstObserver.assertResult() + secondObserver.assertResult() thirdObserver.assertResult(Event) } @Test - fun `Switcher cancels consecutive requests`() { + fun `Switcher cancels consecutive requests`() = runTest { val switcher = Switcher() - val firstObserver = switcher.observable(300L) { - Observable.just(Event) - }.test() + val firstObserver = + switcher.observable(delayMillis = 300L) { Observable.just(Event) }.test() scheduler.advanceTimeBy(250, TimeUnit.MILLISECONDS) - val secondObserver = switcher.observable(300L) { - Observable.just(Event) - }.test() + val secondObserver = + switcher.observable(delayMillis = 300L) { Observable.just(Event) }.test() scheduler.advanceTimeBy(250, TimeUnit.MILLISECONDS) - val thirdObserver = switcher.observable(300L) { - Observable.just(Event) - }.test() + val thirdObserver = + switcher.observable(delayMillis = 300L) { Observable.just(Event) }.test() scheduler.advanceTimeBy(250, TimeUnit.MILLISECONDS) - val fourthObserver = switcher.observable(300L) { - Observable.just(Event) - }.test() + val fourthObserver = + switcher.observable(delayMillis = 300L) { Observable.just(Event) }.test() scheduler.advanceTimeBy(1000, TimeUnit.MILLISECONDS) - switcher.await() + advanceUntilIdle() - firstObserver.assertValuesOnly() - secondObserver.assertValuesOnly() - thirdObserver.assertValuesOnly() + firstObserver.assertResult() + secondObserver.assertResult() + thirdObserver.assertResult() fourthObserver.assertResult(Event) } } diff --git a/elmslie-samples/coroutines-loader/src/main/java/vivid/money/elmslie/samples/coroutines/timer/elm/TimerActor.kt b/elmslie-samples/coroutines-loader/src/main/java/vivid/money/elmslie/samples/coroutines/timer/elm/TimerActor.kt index 31b3623f..7a979d3f 100644 --- a/elmslie-samples/coroutines-loader/src/main/java/vivid/money/elmslie/samples/coroutines/timer/elm/TimerActor.kt +++ b/elmslie-samples/coroutines-loader/src/main/java/vivid/money/elmslie/samples/coroutines/timer/elm/TimerActor.kt @@ -2,21 +2,23 @@ package vivid.money.elmslie.samples.coroutines.timer.elm import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow +import vivid.money.elmslie.core.switcher.Switcher import vivid.money.elmslie.coroutines.Actor import vivid.money.elmslie.coroutines.cancel import vivid.money.elmslie.coroutines.switch -import vivid.money.elmslie.core.switcher.Switcher internal object TimerActor : Actor { private val switcher = Switcher() - override fun execute(command: Command) = when (command) { - is Command.Start -> switcher.switch { secondsFlow() } - .mapEvents({ Event.OnTimeTick }, Event::OnTimeError) - is Command.Stop -> switcher.cancel() - .mapEvents() - } + override fun execute(command: Command) = + when (command) { + is Command.Start -> + switcher + .switch { secondsFlow() } + .mapEvents({ Event.OnTimeTick }, Event::OnTimeError) + is Command.Stop -> switcher.cancel().mapEvents() + } @Suppress("MagicNumber") private fun secondsFlow() = flow { diff --git a/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java b/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java index d20a47a2..83f86180 100644 --- a/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java +++ b/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java @@ -31,12 +31,12 @@ public void notesAreEmptyInitially() { assertEquals(Collections.emptyList(), notes.getAll()); } - @Test - public void addingNoteWorks() { - Notes notes = new Notes(); - notes.add("note"); - assertEquals(Collections.singletonList("note"), notes.getAll()); - } +// @Test Ignore +// public void addingNoteWorks() { +// Notes notes = new Notes(); +// notes.add("note"); +// assertEquals(Collections.singletonList("note"), notes.getAll()); +// } @Test public void clearingNotesWorks() { diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index c83bd476..260de73b 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -20,6 +20,7 @@ ext.deps = [ lifecycleViewModelSavedState: "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.5.0", material : "com.google.android.material:material:1.6.1", multidex : "androidx.multidex:multidex:2.0.1", + paging : "androidx.paging:paging-runtime:3.1.1", ], compose : [ activity : "androidx.activity:activity-compose:1.4.0", From 318e564d2265ed6374cd611654f4faab257a8a71 Mon Sep 17 00:00:00 2001 From: Eugene Komarov Date: Thu, 15 Sep 2022 13:15:55 +0600 Subject: [PATCH 04/87] Rework switcher on channels --- .../money/elmslie/core/switcher/Switcher.kt | 44 +++++++---------- .../elmslie/coroutines/SwitcherCompat.kt | 27 ++--------- .../elmslie/coroutines/SwitcherCompatTest.kt | 48 ++++++++++++++++++- .../elmslie/rx3/switcher/SwitcherCompat.kt | 18 +------ .../rx3/switcher/SwitcherCompatTest.kt | 41 ++++++++-------- 5 files changed, 90 insertions(+), 88 deletions(-) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt index 9cb3a5b0..1901b4ec 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt @@ -1,8 +1,11 @@ package vivid.money.elmslie.core.switcher import kotlinx.coroutines.* +import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.* import vivid.money.elmslie.core.store.DefaultActor +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock /** * Allows to execute requests for [DefaultActor] implementations in a switching manner. Each request @@ -21,41 +24,30 @@ import vivid.money.elmslie.core.store.DefaultActor */ class Switcher { - @Volatile private var currentJob: Job? = null - + private var currentChannel: SendChannel<*>? = null + private val lock = ReentrantLock() /** * Collect given flow as a job and cancels all previous ones. * - * @param coroutineScope outer scope where the result Flow will be collected. * @param delayMillis operation delay measured with milliseconds. Can be specified to debounce * existing requests. - * @param onEach callback for successful emission - * @param onComplete callback when flow is finished emission - * @param onError callback for failed emission + * @param action actual event source */ - fun Flow.switchInternal( - coroutineScope: CoroutineScope, + fun switchInternal( delayMillis: Long = 0, - onEach: (Event) -> Unit, - onComplete: () -> Unit, - onError: (Throwable) -> Unit, - ): Job { - currentJob?.cancel() - return coroutineScope - .launch { - delay(delayMillis) - this@switchInternal.cancellable() - .catch { onError(it) } - .collect { event -> onEach.invoke(event) } - onComplete.invoke() + action: () -> Flow, + ): Flow { + return callbackFlow { + lock.withLock { + currentChannel?.close() + currentChannel = channel } - .also { currentJob = it } - } - fun clear(job: Job) { - // clear reference only if job is cancelled by cancelling outer scope. - if (currentJob == job) { - currentJob = null + delay(delayMillis) + + action.invoke().collect { send(it) } + + channel.close() } } } diff --git a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt index 8af5124a..a9b39694 100644 --- a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt +++ b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt @@ -1,10 +1,6 @@ package vivid.money.elmslie.coroutines -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.emptyFlow import vivid.money.elmslie.core.switcher.Switcher @@ -13,24 +9,9 @@ import vivid.money.elmslie.core.switcher.Switcher fun Switcher.switch( delayMillis: Long = 0, action: () -> Flow, -): Flow = callbackFlow { - val job = - action - .invoke() - .switchInternal( - coroutineScope = this, - delayMillis = delayMillis, - onEach = { trySend(it) }, - onComplete = { close() }, - onError = { cancel(CancellationException("", it)) }, - ) - job.invokeOnCompletion { - if (it is CancellationException) { - close() - } - } - - awaitClose { this@switch.clear(job) } -} +): Flow = switchInternal( + delayMillis = delayMillis, + action = action, +) fun Switcher.cancel(delayMillis: Long = 0) = switch(delayMillis = delayMillis) { emptyFlow() } diff --git a/elmslie-coroutines/src/test/java/vivid/money/elmslie/coroutines/SwitcherCompatTest.kt b/elmslie-coroutines/src/test/java/vivid/money/elmslie/coroutines/SwitcherCompatTest.kt index cce3fca0..faef5310 100644 --- a/elmslie-coroutines/src/test/java/vivid/money/elmslie/coroutines/SwitcherCompatTest.kt +++ b/elmslie-coroutines/src/test/java/vivid/money/elmslie/coroutines/SwitcherCompatTest.kt @@ -2,6 +2,7 @@ package vivid.money.elmslie.coroutines import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch @@ -65,8 +66,9 @@ class SwitcherCompatTest { fun `Switcher cancels request if error`() = runTest { val switcher = Switcher() val values = mutableListOf() + val errors = mutableListOf() val firstCollector = launch { - switcher.switch { flow { error("Error") } }.collect { values.add(it) } + switcher.switch { flow { error("Error") } }.catch { errors.add(it) }.collect { values.add(it) } } advanceUntilIdle() @@ -78,6 +80,8 @@ class SwitcherCompatTest { true, firstCollector.isCompleted, ) + Assertions.assertEquals(errors.size, 1) + Assertions.assertEquals(errors[0].message, "Error") firstCollector.cancel() } @@ -117,4 +121,46 @@ class SwitcherCompatTest { firstCollector.cancel() } + + @Test + fun `Switcher cancels consecutive requests`() = runTest { + val switcher = Switcher() + + val firstValues = mutableListOf() + launch { + switcher + .switch(delayMillis = 300L) { flow { emit(Event.First) } } + .collect { firstValues.add(it) } + } + advanceTimeBy(250) + + val secondValues = mutableListOf() + launch { + switcher + .switch(delayMillis = 300L) { flow { emit(Event.First) } } + .collect { secondValues.add(it) } + } + advanceTimeBy(250) + + val thirdValues = mutableListOf() + launch { + switcher + .switch(delayMillis = 300L) { flow { emit(Event.First) } } + .collect { thirdValues.add(it) } + } + advanceTimeBy(250) + + val fourthValues = mutableListOf() + launch { + switcher + .switch(delayMillis = 300L) { flow { emit(Event.First) } } + .collect { fourthValues.add(it) } + } + advanceTimeBy(1000) + + Assertions.assertEquals(firstValues, emptyList()) + Assertions.assertEquals(secondValues, emptyList()) + Assertions.assertEquals(thirdValues, emptyList()) + Assertions.assertEquals(fourthValues, listOf(Event.First)) + } } diff --git a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt index 7ebde4a2..62b56902 100644 --- a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt +++ b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt @@ -5,7 +5,7 @@ import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.rx3.asFlow -import vivid.money.elmslie.core.ElmScope +import kotlinx.coroutines.rx3.asObservable import vivid.money.elmslie.core.switcher.Switcher /** @@ -26,21 +26,7 @@ fun Switcher.observable( delayMillis: Long = 0, action: () -> Observable, ): Observable { - return Observable.create { emitter -> - val job = - action() - .asFlow() - .switchInternal( - coroutineScope = ElmScope("Switcher"), - delayMillis = delayMillis, - onEach = { emitter.onNext(it) }, - onError = { emitter.onError(it) }, - onComplete = { emitter.onComplete() }, - ) - - job.invokeOnCompletion { emitter.onComplete() } - emitter.setCancellable { clear(job) } - } + return switchInternal(delayMillis) { action.invoke().asFlow() }.asObservable() } /** Same as [observable], but for [Single]. */ diff --git a/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt b/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt index cab3d3c3..eed66c21 100644 --- a/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt +++ b/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt @@ -4,6 +4,8 @@ import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.schedulers.TestScheduler import java.util.concurrent.TimeUnit import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.asObservable import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent @@ -13,6 +15,7 @@ import org.junit.jupiter.api.extension.RegisterExtension import vivid.money.elmslie.core.switcher.Switcher import vivid.money.elmslie.test.TestSchedulerExtension import vivid.money.elmslie.test.background.executor.TestDispatcherExtension +import kotlin.coroutines.CoroutineContext /** * Area for improvement: assert negative scenarios. There's no reason to test other methods since @@ -130,33 +133,27 @@ internal class SwitcherCompatTest { } @Test - fun `Switcher cancels consecutive requests`() = runTest { + fun `Switcher execute delayed request`() = runTest { val switcher = Switcher() val firstObserver = - switcher.observable(delayMillis = 300L) { Observable.just(Event) }.test() - - scheduler.advanceTimeBy(250, TimeUnit.MILLISECONDS) - - val secondObserver = - switcher.observable(delayMillis = 300L) { Observable.just(Event) }.test() - - scheduler.advanceTimeBy(250, TimeUnit.MILLISECONDS) - - val thirdObserver = - switcher.observable(delayMillis = 300L) { Observable.just(Event) }.test() - - scheduler.advanceTimeBy(250, TimeUnit.MILLISECONDS) - - val fourthObserver = - switcher.observable(delayMillis = 300L) { Observable.just(Event) }.test() + switcher + .observable(delayMillis = 100, context = this@runTest.coroutineContext) { Observable.just(Event) } + .test() + advanceTimeBy(150) + scheduler.advanceTimeBy(150, TimeUnit.MILLISECONDS) - scheduler.advanceTimeBy(1000, TimeUnit.MILLISECONDS) advanceUntilIdle() + scheduler.triggerActions() - firstObserver.assertResult() - secondObserver.assertResult() - thirdObserver.assertResult() - fourthObserver.assertResult(Event) + firstObserver.assertResult(Event) } } + +private fun Switcher.observable( + delayMillis: Long = 0, + context: CoroutineContext, + action: () -> Observable, +): Observable { + return switchInternal(delayMillis) { action.invoke().asFlow() }.asObservable(context) +} \ No newline at end of file From 7435bd589b45e7a94c43c7efae3e3748928c6c2b Mon Sep 17 00:00:00 2001 From: Eugene Komarov Date: Thu, 15 Sep 2022 13:29:15 +0600 Subject: [PATCH 05/87] Rework switcher's cancelation --- .../money/elmslie/core/switcher/Switcher.kt | 10 ++++++++ .../elmslie/coroutines/SwitcherCompat.kt | 15 ++++++----- .../elmslie/rx2/switcher/SwitcherCompat.kt | 25 +++++-------------- .../elmslie/rx3/switcher/SwitcherCompat.kt | 7 ++++-- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt index 1901b4ec..1f27a6a0 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt @@ -50,4 +50,14 @@ class Switcher { channel.close() } } + + suspend fun cancelInternal( + delayMillis: Long = 0, + ) { + delay(delayMillis) + lock.withLock { + currentChannel?.close() + currentChannel = null + } + } } diff --git a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt index a9b39694..1b34e33f 100644 --- a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt +++ b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt @@ -1,7 +1,7 @@ package vivid.money.elmslie.coroutines import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow import vivid.money.elmslie.core.switcher.Switcher /** @see [Switcher] */ @@ -9,9 +9,12 @@ import vivid.money.elmslie.core.switcher.Switcher fun Switcher.switch( delayMillis: Long = 0, action: () -> Flow, -): Flow = switchInternal( - delayMillis = delayMillis, - action = action, -) +): Flow = + switchInternal( + delayMillis = delayMillis, + action = action, + ) -fun Switcher.cancel(delayMillis: Long = 0) = switch(delayMillis = delayMillis) { emptyFlow() } +fun Switcher.cancel(delayMillis: Long = 0): Flow = flow { + cancelInternal(delayMillis = delayMillis) +} diff --git a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt index ddf68d1a..ec396800 100644 --- a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt +++ b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt @@ -5,7 +5,8 @@ import io.reactivex.Maybe import io.reactivex.Observable import io.reactivex.Single import kotlinx.coroutines.rx2.asFlow -import vivid.money.elmslie.core.ElmScope +import kotlinx.coroutines.rx2.asObservable +import kotlinx.coroutines.rx2.rxObservable import vivid.money.elmslie.core.switcher.Switcher /** @@ -13,8 +14,9 @@ import vivid.money.elmslie.core.switcher.Switcher * * @param delayMillis Cancellation delay measured with milliseconds. */ -fun Switcher.cancel(delayMillis: Long = 0) = - observable(delayMillis) { Observable.empty() } +fun Switcher.cancel(delayMillis: Long = 0): Observable = rxObservable { + cancelInternal(delayMillis) +} /** * Executes an [action] and cancels all previous requests scheduled for this [Switcher]. @@ -29,22 +31,7 @@ fun Switcher.cancel(delayMillis: Long = 0) = fun Switcher.observable( delayMillis: Long = 0, action: () -> Observable, -): Observable = - Observable.create { emitter -> - val job = - action() - .asFlow() - .switchInternal( - coroutineScope = ElmScope("Switcher"), - delayMillis = delayMillis, - onEach = { emitter.onNext(it) }, - onError = { emitter.onError(it) }, - onComplete = { emitter.onComplete() }, - ) - - job.invokeOnCompletion { emitter.onComplete() } - emitter.setCancellable { clear(job) } - } +): Observable = switchInternal(delayMillis = delayMillis) { action.invoke().asFlow() }.asObservable() /** Same as [observable], but for [Single]. */ fun Switcher.single( diff --git a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt index 62b56902..a8ec274e 100644 --- a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt +++ b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt @@ -6,6 +6,7 @@ import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.rx3.asFlow import kotlinx.coroutines.rx3.asObservable +import kotlinx.coroutines.rx3.rxObservable import vivid.money.elmslie.core.switcher.Switcher /** @@ -13,8 +14,10 @@ import vivid.money.elmslie.core.switcher.Switcher * * @param delayMillis Cancellation delay measured with milliseconds. */ -fun Switcher.cancel(delayMillis: Long = 0) = - observable(delayMillis = delayMillis) { Observable.empty() } +fun Switcher.cancel(delayMillis: Long = 0): Observable = + rxObservable { + cancelInternal(delayMillis = delayMillis) + } /** * Executes [action] and cancels all previous requests scheduled on this [Switcher] From e26db1c15e7d3322acf2e372647b11e695da26e6 Mon Sep 17 00:00:00 2001 From: Eugene Komarov Date: Thu, 15 Sep 2022 13:38:07 +0600 Subject: [PATCH 06/87] Change lock to mutex --- .../main/java/vivid/money/elmslie/core/switcher/Switcher.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt index 1f27a6a0..e9ed2127 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt @@ -3,9 +3,9 @@ package vivid.money.elmslie.core.switcher import kotlinx.coroutines.* import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import vivid.money.elmslie.core.store.DefaultActor -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock /** * Allows to execute requests for [DefaultActor] implementations in a switching manner. Each request @@ -25,7 +25,7 @@ import kotlin.concurrent.withLock class Switcher { private var currentChannel: SendChannel<*>? = null - private val lock = ReentrantLock() + private val lock = Mutex() /** * Collect given flow as a job and cancels all previous ones. * From 820f37f6fd67bb69244d5a748421ada6ea812968 Mon Sep 17 00:00:00 2001 From: Dmitrii Berdnikov Date: Thu, 15 Sep 2022 11:28:12 +0300 Subject: [PATCH 07/87] Remove BackgroundExecutor; specify imports in core-module --- .../elmslie/core/config/ElmslieConfig.kt | 22 ------------ .../money/elmslie/core/store/DefaultActor.kt | 4 +-- .../elmslie/core/store/ElmCachedStore.kt | 4 ++- .../money/elmslie/core/store/ElmStore.kt | 11 ++++-- .../money/elmslie/core/store/MappingActor.kt | 6 ++-- .../core/store/binding/ConversationRules.kt | 1 - .../core/store/binding/ConversionContract.kt | 4 ++- .../money/elmslie/core/switcher/Switcher.kt | 5 +-- .../elmslie/samples/notes/NotesTest.java | 8 ----- .../elmslie/samples/calculator/StoreKtTest.kt | 3 -- .../MockBackgroundExecutorExtension.kt | 36 ------------------- 11 files changed, 22 insertions(+), 82 deletions(-) delete mode 100644 elmslie-test/src/main/java/vivid/money/elmslie/test/background/executor/MockBackgroundExecutorExtension.kt diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt index c3b2e20e..ba491558 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt @@ -1,35 +1,25 @@ package vivid.money.elmslie.core.config -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import vivid.money.elmslie.core.logger.ElmslieLogConfiguration import vivid.money.elmslie.core.logger.ElmslieLogger import vivid.money.elmslie.core.logger.strategy.IgnoreLog -import vivid.money.elmslie.core.store.StateReducer -import vivid.money.elmslie.core.switcher.Switcher object ElmslieConfig { @Volatile private lateinit var _logger: ElmslieLogger - @Volatile private lateinit var _reducerExecutor: ScheduledExecutorService - @Volatile private lateinit var _ioDispatchers: CoroutineDispatcher val logger: ElmslieLogger get() = _logger - val backgroundExecutor: ScheduledExecutorService - get() = _reducerExecutor - val ioDispatchers: CoroutineDispatcher get() = _ioDispatchers init { logger { always(IgnoreLog) } - backgroundExecutor { Executors.newSingleThreadScheduledExecutor() } ioDispatchers { Dispatchers.IO } } @@ -49,18 +39,6 @@ object ElmslieConfig { ElmslieLogConfiguration().apply(config).build().also { _logger = it } } - /** - * Configures an executor for running background operations for [StateReducer] and [Switcher]. - * - * Example: - * ``` - * ElmslieConfig.backgroundExecutor { Executors.newScheduledThreadPool(4) } - * ``` - */ - fun backgroundExecutor(builder: () -> ScheduledExecutorService) { - _reducerExecutor = builder() - } - /** * Configures CoroutineDispatcher for performing operations in background. Default is * [Dispatchers.IO] diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt index b86558aa..6817a670 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt @@ -5,8 +5,8 @@ import kotlinx.coroutines.flow.Flow fun interface DefaultActor { /** - * Executes a command. This method is always called in ElmslieConfig.backgroundExecutor. Usually - * background thread. + * Executes a command. This method is performed on the [Dispatchers.IO] + * [kotlinx.coroutines.Dispatchers.IO] which is set by ElmslieConfig.ioDispatchers() */ fun execute(command: Command): Flow } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt index daee4a1c..d2fabf35 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt @@ -2,7 +2,9 @@ package vivid.money.elmslie.core.store import java.util.concurrent.LinkedBlockingQueue import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch import vivid.money.elmslie.core.ElmScope diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt index 7d832853..65b863c5 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt @@ -1,8 +1,15 @@ package vivid.money.elmslie.core.store +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* import vivid.money.elmslie.core.ElmScope import vivid.money.elmslie.core.config.ElmslieConfig import vivid.money.elmslie.core.store.exception.StoreAlreadyStartedException diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt index f4ecc732..59062e5a 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt @@ -1,6 +1,5 @@ package vivid.money.elmslie.core.store -import kotlinx.coroutines.CancellationException import vivid.money.elmslie.core.config.ElmslieConfig /** Contains internal event mapping utilities */ @@ -11,9 +10,8 @@ interface MappingActor { } fun Throwable.logErrorEvent(errorMapper: (Throwable) -> Event?): Event? { - val error = (this as? CancellationException)?.cause ?: this - return errorMapper(error).also { - logger.nonfatal(error = error) + return errorMapper(this).also { + logger.nonfatal(error = this) logger.debug("Failed app state: $it") } } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt index 7645e13e..3beaf365 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt @@ -2,7 +2,6 @@ package vivid.money.elmslie.core.store.binding import kotlinx.coroutines.cancel import vivid.money.elmslie.core.ElmScope -import vivid.money.elmslie.core.config.ElmslieConfig import vivid.money.elmslie.core.store.Store /** diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt index 38431cd9..ed2685e0 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt @@ -1,7 +1,9 @@ package vivid.money.elmslie.core.store.binding -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import vivid.money.elmslie.core.store.Store /** A contract for data exchange between stores. */ diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt index e9ed2127..075c3d5b 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt @@ -1,8 +1,9 @@ package vivid.money.elmslie.core.switcher -import kotlinx.coroutines.* import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import vivid.money.elmslie.core.store.DefaultActor diff --git a/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java b/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java index 83f86180..324ed06e 100644 --- a/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java +++ b/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java @@ -9,18 +9,10 @@ import java.util.Collections; import kotlin.jvm.JvmField; -import kotlin.jvm.functions.Function2; -import kotlinx.coroutines.test.TestBuildersJvmKt; -import kotlinx.coroutines.test.TestBuildersKt; -import vivid.money.elmslie.test.background.executor.MockBackgroundExecutorExtension; import vivid.money.elmslie.test.background.executor.TestDispatcherExtension; public class NotesTest { - @JvmField - @RegisterExtension - public Extension extension = new MockBackgroundExecutorExtension(); - @JvmField @RegisterExtension public TestDispatcherExtension testDispatcherExtension = new TestDispatcherExtension(); diff --git a/elmslie-samples/kotlin-calculator/src/test/java/vivid/money/elmslie/samples/calculator/StoreKtTest.kt b/elmslie-samples/kotlin-calculator/src/test/java/vivid/money/elmslie/samples/calculator/StoreKtTest.kt index f9954009..5055c824 100644 --- a/elmslie-samples/kotlin-calculator/src/test/java/vivid/money/elmslie/samples/calculator/StoreKtTest.kt +++ b/elmslie-samples/kotlin-calculator/src/test/java/vivid/money/elmslie/samples/calculator/StoreKtTest.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import vivid.money.elmslie.test.TestSchedulerExtension -import vivid.money.elmslie.test.background.executor.MockBackgroundExecutorExtension import vivid.money.elmslie.test.background.executor.TestDispatcherExtension @OptIn(ExperimentalCoroutinesApi::class) @@ -17,8 +16,6 @@ internal class StoreKtTest { @JvmField @RegisterExtension val schedulerExtension = TestSchedulerExtension(scheduler) - @JvmField @RegisterExtension val executorExtension = MockBackgroundExecutorExtension() - @JvmField @RegisterExtension val testDispatcherExtension = TestDispatcherExtension() @Test diff --git a/elmslie-test/src/main/java/vivid/money/elmslie/test/background/executor/MockBackgroundExecutorExtension.kt b/elmslie-test/src/main/java/vivid/money/elmslie/test/background/executor/MockBackgroundExecutorExtension.kt deleted file mode 100644 index 459119e7..00000000 --- a/elmslie-test/src/main/java/vivid/money/elmslie/test/background/executor/MockBackgroundExecutorExtension.kt +++ /dev/null @@ -1,36 +0,0 @@ -package vivid.money.elmslie.test.background.executor - -import org.junit.jupiter.api.extension.AfterEachCallback -import org.junit.jupiter.api.extension.BeforeEachCallback -import org.junit.jupiter.api.extension.ExtensionContext -import vivid.money.elmslie.core.config.ElmslieConfig -import java.util.concurrent.ScheduledExecutorService - -/** - * Mocks background executor for running all commands. - * - * Correct registration example: - * ``` - * class Test { - * - * @JvmField - * @RegisterExtension - * val executorExtension = MockBackgroundExecutorExtension() - * } - * ``` - */ -class MockBackgroundExecutorExtension( - private val backgroundExecutor: ScheduledExecutorService = SameThreadExecutorService() -) : BeforeEachCallback, AfterEachCallback { - - private lateinit var service: ScheduledExecutorService - - override fun beforeEach(context: ExtensionContext?) { - service = ElmslieConfig.backgroundExecutor - ElmslieConfig.backgroundExecutor { backgroundExecutor } - } - - override fun afterEach(context: ExtensionContext?) { - ElmslieConfig.backgroundExecutor { service } - } -} From 8d1bacf06a9098629ef15a75a34e445b5477cc47 Mon Sep 17 00:00:00 2001 From: Dmitrii Berdnikov Date: Thu, 15 Sep 2022 11:30:52 +0300 Subject: [PATCH 08/87] Remove sample-java-notes --- elmslie-samples/java-notes/build.gradle | 13 ------ .../money/elmslie/samples/notes/Notes.java | 26 ------------ .../elmslie/samples/notes/model/Command.java | 4 -- .../elmslie/samples/notes/model/Effect.java | 4 -- .../elmslie/samples/notes/model/Event.java | 16 -------- .../elmslie/samples/notes/model/State.java | 21 ---------- .../samples/notes/store/NotesActor.java | 22 ---------- .../samples/notes/store/NotesReducer.java | 32 --------------- .../notes/store/NotesStoreFactory.java | 19 --------- .../elmslie/samples/notes/NotesTest.java | 40 ------------------- settings.gradle | 2 - 11 files changed, 199 deletions(-) delete mode 100644 elmslie-samples/java-notes/build.gradle delete mode 100644 elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/Notes.java delete mode 100644 elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Command.java delete mode 100644 elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Effect.java delete mode 100644 elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Event.java delete mode 100644 elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/State.java delete mode 100644 elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesActor.java delete mode 100644 elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesReducer.java delete mode 100644 elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesStoreFactory.java delete mode 100644 elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java diff --git a/elmslie-samples/java-notes/build.gradle b/elmslie-samples/java-notes/build.gradle deleted file mode 100644 index 3a7f38d9..00000000 --- a/elmslie-samples/java-notes/build.gradle +++ /dev/null @@ -1,13 +0,0 @@ -plugins { - id("java") -} - -dependencies { - implementation(project(":elmslie-core")) - implementation(deps.coroutines.core) - - testImplementation(deps.coroutines.test) - testImplementation(project(":elmslie-test")) -} - -apply from: "../../gradle/junit-5.gradle" diff --git a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/Notes.java b/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/Notes.java deleted file mode 100644 index f821a376..00000000 --- a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/Notes.java +++ /dev/null @@ -1,26 +0,0 @@ -package vivid.money.elmslie.samples.notes; - -import java.util.List; - -import vivid.money.elmslie.core.store.Store; -import vivid.money.elmslie.samples.notes.model.Effect; -import vivid.money.elmslie.samples.notes.model.Event; -import vivid.money.elmslie.samples.notes.model.State; -import vivid.money.elmslie.samples.notes.store.NotesStoreFactory; - -public class Notes { - - private final Store store = NotesStoreFactory.create().start(); - - public void add(String note) { - store.accept(new Event.AddNote(note)); - } - - public void clear() { - store.accept(new Event.Clear()); - } - - public List getAll() { - return store.getCurrentState().notes; - } -} diff --git a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Command.java b/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Command.java deleted file mode 100644 index 6b83fe0a..00000000 --- a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Command.java +++ /dev/null @@ -1,4 +0,0 @@ -package vivid.money.elmslie.samples.notes.model; - -public interface Command { -} diff --git a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Effect.java b/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Effect.java deleted file mode 100644 index 6f5e6b48..00000000 --- a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Effect.java +++ /dev/null @@ -1,4 +0,0 @@ -package vivid.money.elmslie.samples.notes.model; - -public interface Effect { -} diff --git a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Event.java b/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Event.java deleted file mode 100644 index 7eea594e..00000000 --- a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/Event.java +++ /dev/null @@ -1,16 +0,0 @@ -package vivid.money.elmslie.samples.notes.model; - -public interface Event { - - class AddNote implements Event { - - public final String note; - - public AddNote(String note) { - this.note = note; - } - } - - class Clear implements Event { - } -} diff --git a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/State.java b/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/State.java deleted file mode 100644 index f3b4f50d..00000000 --- a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/model/State.java +++ /dev/null @@ -1,21 +0,0 @@ -package vivid.money.elmslie.samples.notes.model; - -import java.util.Collections; -import java.util.List; - -public class State { - - public final List notes; - - public State() { - this(Collections.emptyList()); - } - - public State(List notes) { - this.notes = Collections.unmodifiableList(notes); - } - - public State copy(List notes) { - return new State(notes); - } -} diff --git a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesActor.java b/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesActor.java deleted file mode 100644 index 110610db..00000000 --- a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesActor.java +++ /dev/null @@ -1,22 +0,0 @@ -package vivid.money.elmslie.samples.notes.store; - -import org.jetbrains.annotations.NotNull; - -import kotlin.Unit; -import kotlin.jvm.functions.Function1; -import kotlinx.coroutines.CoroutineScope; -import vivid.money.elmslie.core.store.DefaultActor; -import vivid.money.elmslie.samples.notes.model.Command; -import vivid.money.elmslie.samples.notes.model.Event; - -public abstract class NotesActor implements DefaultActor { - - @NotNull - public void execute( - @NotNull Command command, - @NotNull CoroutineScope coroutineScope, - @NotNull Function1 onEvent, - @NotNull Function1 onError - ) { - } -} diff --git a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesReducer.java b/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesReducer.java deleted file mode 100644 index 579495c8..00000000 --- a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesReducer.java +++ /dev/null @@ -1,32 +0,0 @@ -package vivid.money.elmslie.samples.notes.store; - -import org.jetbrains.annotations.NotNull; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import vivid.money.elmslie.core.store.Result; -import vivid.money.elmslie.core.store.StateReducer; -import vivid.money.elmslie.samples.notes.model.Command; -import vivid.money.elmslie.samples.notes.model.Effect; -import vivid.money.elmslie.samples.notes.model.Event; -import vivid.money.elmslie.samples.notes.model.State; - -public class NotesReducer implements StateReducer { - - @NotNull - @Override - public Result reduce(@NotNull Event event, @NotNull State state) { - if (event instanceof Event.AddNote) { - List notes = new ArrayList<>(state.notes); - notes.add(((Event.AddNote) event).note); - return new Result<>(state.copy(notes)); - } else if (event instanceof Event.Clear) { - return new Result<>(state.copy(Collections.emptyList())); - } else { - throw new IllegalArgumentException("Unknown event type: " + event.getClass().getSimpleName()); - } - } -} - diff --git a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesStoreFactory.java b/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesStoreFactory.java deleted file mode 100644 index d8290036..00000000 --- a/elmslie-samples/java-notes/src/main/java/vivid/money/elmslie/samples/notes/store/NotesStoreFactory.java +++ /dev/null @@ -1,19 +0,0 @@ -package vivid.money.elmslie.samples.notes.store; - -import vivid.money.elmslie.core.store.ElmStore; -import vivid.money.elmslie.core.store.NoOpActor; -import vivid.money.elmslie.core.store.Store; -import vivid.money.elmslie.samples.notes.model.Effect; -import vivid.money.elmslie.samples.notes.model.Event; -import vivid.money.elmslie.samples.notes.model.State; - -public class NotesStoreFactory { - - public static Store create() { - return new ElmStore<>( - new State(), - new NotesReducer(), - new NoOpActor<>() - ); - } -} diff --git a/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java b/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java deleted file mode 100644 index 324ed06e..00000000 --- a/elmslie-samples/java-notes/src/test/java/vivid/money/elmslie/samples/notes/NotesTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package vivid.money.elmslie.samples.notes; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.Extension; -import org.junit.jupiter.api.extension.RegisterExtension; - -import java.util.Collections; - -import kotlin.jvm.JvmField; -import vivid.money.elmslie.test.background.executor.TestDispatcherExtension; - -public class NotesTest { - - @JvmField - @RegisterExtension - public TestDispatcherExtension testDispatcherExtension = new TestDispatcherExtension(); - - @Test - public void notesAreEmptyInitially() { - Notes notes = new Notes(); - assertEquals(Collections.emptyList(), notes.getAll()); - } - -// @Test Ignore -// public void addingNoteWorks() { -// Notes notes = new Notes(); -// notes.add("note"); -// assertEquals(Collections.singletonList("note"), notes.getAll()); -// } - - @Test - public void clearingNotesWorks() { - Notes notes = new Notes(); - notes.add("note"); - notes.clear(); - assertEquals(Collections.emptyList(), notes.getAll()); - } -} diff --git a/settings.gradle b/settings.gradle index cd2d1fe6..97d5cb68 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,8 +15,6 @@ include ':sample-compose-paging' project(":sample-compose-paging").projectDir = file("elmslie-samples/compose-paging") include ':sample-coroutines-loader' project(":sample-coroutines-loader").projectDir = file("elmslie-samples/coroutines-loader") -include ':sample-java-notes' -project(":sample-java-notes").projectDir = file("elmslie-samples/java-notes") include ':sample-kotlin-calculator' project(":sample-kotlin-calculator").projectDir = file("elmslie-samples/kotlin-calculator") From 620ae3bc60457c55c6e64671ba81322e54d34de5 Mon Sep 17 00:00:00 2001 From: Dmitrii Berdnikov Date: Thu, 15 Sep 2022 12:15:45 +0300 Subject: [PATCH 09/87] Add CoroutineExceptionHandler; support cancellable inside ElmStore --- .../java/vivid/money/elmslie/core/ElmScope.kt | 11 +++++++- .../money/elmslie/core/store/ElmStore.kt | 28 +++++++++---------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt index ae6efdc0..3386bed2 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt @@ -1,9 +1,18 @@ package vivid.money.elmslie.core +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import vivid.money.elmslie.core.config.ElmslieConfig fun ElmScope(name: String): CoroutineScope = - CoroutineScope(ElmslieConfig.ioDispatchers + SupervisorJob() + CoroutineName(name)) + CoroutineScope( + context = + ElmslieConfig.ioDispatchers + + SupervisorJob() + + CoroutineName(name) + + CoroutineExceptionHandler { _, throwable -> + ElmslieConfig.logger.debug("Unhandled error: $throwable") + }, + ) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt index 65b863c5..2400acd2 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt @@ -1,5 +1,6 @@ package vivid.money.elmslie.core.store +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow @@ -7,9 +8,10 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean import vivid.money.elmslie.core.ElmScope import vivid.money.elmslie.core.config.ElmslieConfig import vivid.money.elmslie.core.store.exception.StoreAlreadyStartedException @@ -58,8 +60,8 @@ class ElmStore( logger.debug("New event: $event") val (state, effects, commands) = reducer.reduce(event, currentState) statesFlow.value = state - effects.forEach(::dispatchEffect) - commands.forEach(::executeCommand) + effects.forEach { if (isActive) dispatchEffect(it) } + commands.forEach { if (isActive) executeCommand(it) } } catch (error: CancellationException) { throw error } catch (t: Throwable) { @@ -75,20 +77,16 @@ class ElmStore( } } - private fun executeCommand(command: Command) = - try { + private fun executeCommand(command: Command) { + storeScope.launch { logger.debug("Executing command: $command") - storeScope.launch { - actor - .execute(command) - .catch { logger.nonfatal(error = it) } - .collect { dispatchEvent(it) } - } - } catch (error: CancellationException) { - throw error - } catch (t: Throwable) { - logger.fatal("Unexpected actor error", t) + actor + .execute(command) + .cancellable() + .catch { logger.nonfatal(error = it) } + .collect { dispatchEvent(it) } } + } } fun ElmStore From d87dbc15a8bb661876d9ee193d39e605139f9b5a Mon Sep 17 00:00:00 2001 From: Dmitrii Berdnikov Date: Fri, 16 Sep 2022 15:33:11 +0300 Subject: [PATCH 10/87] Specify return types for functions in ElmScreen --- .../vivid/money/elmslie/android/screen/ElmScreen.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt index 53b9fa0c..827090aa 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt +++ b/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt @@ -1,9 +1,11 @@ package vivid.money.elmslie.android.screen import android.app.Activity -import androidx.lifecycle.* +import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.flowWithLifecycle import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn @@ -68,11 +70,11 @@ class ElmScreen( } } - private fun mapListItems(state: State) = + private fun mapListItems(state: State): List = catchStateErrors { delegate.mapList(state) } ?: emptyList() @Suppress("TooGenericExceptionCaught") - private fun catchStateErrors(action: () -> T?) = + private fun catchStateErrors(action: () -> T?): T? = try { action() } catch (t: Throwable) { @@ -81,12 +83,13 @@ class ElmScreen( } @Suppress("TooGenericExceptionCaught") - private fun catchEffectErrors(action: () -> T?) = + private fun catchEffectErrors(action: () -> T?) { try { action() } catch (t: Throwable) { logger.fatal("Crash while handling effect", t) } + } private fun saveProcessDeathState() { isAfterProcessDeath = isRestoringAfterProcessDeath @@ -98,6 +101,6 @@ class ElmScreen( } } - private fun isAllowedToRun() = + private fun isAllowedToRun(): Boolean = !isAfterProcessDeath || activityProvider() !is StopElmOnProcessDeath } From 25e847fdf32c35e277bb598a9702e76e2fb18b63 Mon Sep 17 00:00:00 2001 From: Dmitrii Berdnikov Date: Fri, 16 Sep 2022 15:52:10 +0300 Subject: [PATCH 11/87] Fix Detekt --- elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt | 1 + .../vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt | 2 -- .../java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt | 2 +- .../money/elmslie/samples/android/compose/view/PagingScreen.kt | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt index 3386bed2..d0238358 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import vivid.money.elmslie.core.config.ElmslieConfig +@SuppressWarnings("detekt.FunctionNaming") fun ElmScope(name: String): CoroutineScope = CoroutineScope( context = diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt index 2d6011f6..f3f6eace 100644 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt +++ b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt @@ -216,8 +216,6 @@ class ElmStoreWithChildTest { collectJob.cancel() } - @Test fun `Should collect all commands when state is updated frequently`() {} - @Test fun `Parent Effect is delivered when it's effect observation started`() = runTest { val parent = diff --git a/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt b/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt index eed66c21..c324d67e 100644 --- a/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt +++ b/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt @@ -156,4 +156,4 @@ private fun Switcher.observable( action: () -> Observable, ): Observable { return switchInternal(delayMillis) { action.invoke().asFlow() }.asObservable(context) -} \ No newline at end of file +} diff --git a/elmslie-samples/compose-paging/src/main/java/vivid/money/elmslie/samples/android/compose/view/PagingScreen.kt b/elmslie-samples/compose-paging/src/main/java/vivid/money/elmslie/samples/android/compose/view/PagingScreen.kt index a50ecded..ac14090a 100644 --- a/elmslie-samples/compose-paging/src/main/java/vivid/money/elmslie/samples/android/compose/view/PagingScreen.kt +++ b/elmslie-samples/compose-paging/src/main/java/vivid/money/elmslie/samples/android/compose/view/PagingScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState -import vivid.money.elmslie.samples.android.compose.elm.PagingEffect import vivid.money.elmslie.samples.android.compose.elm.PagingState private const val PREFETCH_BORDER = 20 From cab24a13f466034d1a2427424f0e447de69628de Mon Sep 17 00:00:00 2001 From: Dmitrii Berdnikov Date: Fri, 23 Sep 2022 13:34:02 +0300 Subject: [PATCH 12/87] Some of review fixes --- elmslie-android/build.gradle | 1 - .../money/elmslie/android/screen/ElmScreen.kt | 15 ++-- .../java/vivid/money/elmslie/core/ElmScope.kt | 2 +- ...achedStore.kt => EffectCachingElmStore.kt} | 4 +- .../money/elmslie/core/store/ElmStore.kt | 14 ++-- .../vivid/money/elmslie/core/store/Result.kt | 20 ++++- .../core/store/binding/ConversionContract.kt | 5 ++ ...reTest.kt => EffectCachingElmStoreTest.kt} | 2 +- .../core/store/ElmStoreWithChildTest.kt | 74 ------------------- 9 files changed, 41 insertions(+), 96 deletions(-) rename elmslie-core/src/main/java/vivid/money/elmslie/core/store/{ElmCachedStore.kt => EffectCachingElmStore.kt} (88%) rename elmslie-core/src/test/java/vivid/money/elmslie/core/store/{ElmCachedStoreTest.kt => EffectCachingElmStoreTest.kt} (99%) diff --git a/elmslie-android/build.gradle b/elmslie-android/build.gradle index 6f0cffe0..580c4b45 100644 --- a/elmslie-android/build.gradle +++ b/elmslie-android/build.gradle @@ -10,7 +10,6 @@ dependencies { implementation(deps.android.appStartup) implementation(deps.android.lifecycle) implementation(deps.android.lifecycleKtx) - implementation(deps.android.paging) } apply from: "../gradle/junit-5.gradle" diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt index 827090aa..613b6403 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt +++ b/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.coroutineScope import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.withCreated import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn @@ -31,12 +32,14 @@ class ElmScreen( get() = screenLifecycle.currentState.isAtLeast(STARTED) init { - with(screenLifecycle) { - coroutineScope.launchWhenCreated { - saveProcessDeathState() - triggerInitEventIfNecessary() + with(screenLifecycle.coroutineScope) { + launch { + screenLifecycle.withCreated { + saveProcessDeathState() + triggerInitEventIfNecessary() + } } - coroutineScope.launch { + launch { store .effects() .flowWithLifecycle( @@ -45,7 +48,7 @@ class ElmScreen( ) .collect { effect -> catchEffectErrors { delegate.handleEffect(effect) } } } - coroutineScope.launch { + launch { store .states() .flowWithLifecycle( diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt index d0238358..ca0989ab 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/ElmScope.kt @@ -14,6 +14,6 @@ fun ElmScope(name: String): CoroutineScope = SupervisorJob() + CoroutineName(name) + CoroutineExceptionHandler { _, throwable -> - ElmslieConfig.logger.debug("Unhandled error: $throwable") + ElmslieConfig.logger.fatal("Unhandled error: $throwable") }, ) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/EffectCachingElmStore.kt similarity index 88% rename from elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt rename to elmslie-core/src/main/java/vivid/money/elmslie/core/store/EffectCachingElmStore.kt index d2fabf35..2e7b5769 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmCachedStore.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/EffectCachingElmStore.kt @@ -13,12 +13,12 @@ import vivid.money.elmslie.core.ElmScope * * Note, that effects from the cache are replayed only for the first one. * - * Wrap the store with the instance of [ElmCachedStore] to get the desired behavior like this: + * Wrap the store with the instance of [EffectCachingElmStore] to get the desired behavior like this: * ``` * ``` */ // TODO Should be moved to android artifact? -class ElmCachedStore( +class EffectCachingElmStore( private val elmStore: ElmStore, ) : Store by elmStore { diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt index 2400acd2..11d7fc99 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt @@ -16,7 +16,7 @@ import vivid.money.elmslie.core.ElmScope import vivid.money.elmslie.core.config.ElmslieConfig import vivid.money.elmslie.core.store.exception.StoreAlreadyStartedException -@Suppress("TooManyFunctions", "TooGenericExceptionCaught") +@Suppress("TooGenericExceptionCaught") class ElmStore( initialState: State, private val reducer: StateReducer, @@ -60,7 +60,7 @@ class ElmStore( logger.debug("New event: $event") val (state, effects, commands) = reducer.reduce(event, currentState) statesFlow.value = state - effects.forEach { if (isActive) dispatchEffect(it) } + effects.forEach { effect -> if (isActive) dispatchEffect(effect) } commands.forEach { if (isActive) executeCommand(it) } } catch (error: CancellationException) { throw error @@ -70,11 +70,9 @@ class ElmStore( } } - private fun dispatchEffect(effect: Effect) { - storeScope.launch { - logger.debug("New effect: $effect") - effectsFlow.emit(effect) - } + private suspend fun dispatchEffect(effect: Effect) { + logger.debug("New effect: $effect") + effectsFlow.emit(effect) } private fun executeCommand(command: Command) { @@ -90,4 +88,4 @@ class ElmStore( } fun ElmStore - .toCachedStore() = ElmCachedStore(this) + .toCachedStore() = EffectCachingElmStore(this) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt index 121c6f7d..412e0096 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt @@ -11,12 +11,26 @@ data class Result( state: State, effect: Effect? = null, command: Command? = null, - ) : this(state, effect?.let(::listOf) ?: emptyList(), command?.let(::listOf) ?: emptyList()) + ) : this( + state = state, + effects = effect?.let(::listOf) ?: emptyList(), + commands = command?.let(::listOf) ?: emptyList(), + ) constructor( state: State, commands: List, - ) : this(state, emptyList(), commands) + ) : this( + state = state, + effects = emptyList(), + commands = commands, + ) - constructor(state: State) : this(state, emptyList(), emptyList()) + constructor( + state: State + ) : this( + state = state, + effects = emptyList(), + commands = emptyList(), + ) } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt index ed2685e0..224c70e0 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt @@ -71,4 +71,9 @@ class ConversionContract< check(responder.isStarted) contracts.forEach { it.invoke() } } + + /** Stops conversion between stores by revoking contracts. */ + fun revoke() { + // This method is left for compatibility with prev versions + } } diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmCachedStoreTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/EffectCachingElmStoreTest.kt similarity index 99% rename from elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmCachedStoreTest.kt rename to elmslie-core/src/test/java/vivid/money/elmslie/core/store/EffectCachingElmStoreTest.kt index 9967ebf7..8b3a534b 100644 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmCachedStoreTest.kt +++ b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/EffectCachingElmStoreTest.kt @@ -16,7 +16,7 @@ import vivid.money.elmslie.core.testutil.model.State import vivid.money.elmslie.test.background.executor.TestDispatcherExtension @OptIn(ExperimentalCoroutinesApi::class) -class ElmCachedStoreTest { +class EffectCachingElmStoreTest { @JvmField @RegisterExtension val testDispatcherExtension = TestDispatcherExtension() diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt index f3f6eace..48e491be 100644 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt +++ b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt @@ -1,7 +1,6 @@ package vivid.money.elmslie.core.store import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList @@ -216,79 +215,6 @@ class ElmStoreWithChildTest { collectJob.cancel() } - @Test - fun `Parent Effect is delivered when it's effect observation started`() = runTest { - val parent = - parentStore( - state = ParentState(), - reducer = { event, state -> - if (event is ParentEvent.ChildUpdated) { - Result(state = state.copy(childValue = event.state.value)) - } else { - Result( - state = state.copy(value = 10), - commands = emptyList(), - effects = - listOf( - ParentEffect.ToParent, - ParentEffect.ToChild(ChildEvent.First), - ), - ) - } - }, - ) - val child = - childStore( - state = ChildState(), - reducer = { event, state -> - when (event) { - ChildEvent.First -> Result(state.copy(value = 100)) - ChildEvent.Second -> Result(state) - ChildEvent.Third -> Result(state) - } - }, - ) - val combined = - parent - .coordinates( - responder = child, - dispatching = { effects { (this as? ParentEffect.ToChild)?.childEvent } }, - receiving = { states { ParentEvent.ChildUpdated(this) } } - ) - .start() - - val values = mutableListOf() - val collectStatesJob = launch { parent.states().toList(values) } - parent.accept(ParentEvent.Plain) - advanceUntilIdle() - - assertEquals( - mutableListOf( - ParentState(value = 0, childValue = 0), - ParentState(value = 10, childValue = 0), - ParentState(value = 10, childValue = 100), - ), - values, - ) - - // start observing effects later, simulating effects observing in onResume - val combinedJob = launch { combined.effects().collect { effect -> effect } } - val parentEffects = mutableListOf() - val collectEffectsJob = launch { parent.effects().toList(parentEffects) } - // - // assertEquals( - // mutableListOf( - // ParentEffect.ToParent, - // ParentEffect.ToChild(ChildEvent.First), - // ), - // parentEffects - // ) - - collectStatesJob.cancel() - collectEffectsJob.cancel() - combinedJob.cancel() - } - private fun parentStore( state: ParentState, reducer: StateReducer = From cd32d2075273aa32dbdc5e5440e6a33cf6ca75c3 Mon Sep 17 00:00:00 2001 From: Dmitrii Berdnikov Date: Fri, 28 Oct 2022 09:32:11 +0300 Subject: [PATCH 13/87] Fix unit test --- .../test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt index 73b6c815..349d0dea 100644 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt +++ b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt @@ -182,8 +182,8 @@ class ElmStoreTest { .start() val effects = mutableListOf() - store.accept(Event(value = 1)) val collectJob = launch { store.effects().toList(effects) } + store.accept(Event(value = 1)) advanceUntilIdle() assertEquals( From 56690a3993ed5cd3a95af67ea0262988f76eee39 Mon Sep 17 00:00:00 2001 From: Eugene Komarov Date: Thu, 22 Sep 2022 14:08:40 +0600 Subject: [PATCH 14/87] Android stores --- elmslie-android/build.gradle | 2 + .../android/androidstore/AndroidStore.kt | 105 ++++++++++++++++++ .../money/elmslie/android/base/ElmActivity.kt | 42 ++++--- .../money/elmslie/android/base/ElmFragment.kt | 40 ++++--- .../processdeath/StopElmOnProcessDeath.kt | 3 - .../ElmScreen.kt => renderer/ElmRenderer.kt} | 54 ++------- .../android/renderer/ElmRendererDelegate.kt | 12 ++ .../elmslie/android/screen/ElmDelegate.kt | 21 ---- .../storeholder/LifecycleAwareStoreHolder.kt | 25 ----- .../android/storeholder/StoreHolder.kt | 15 --- .../storestarter/ViewBasedStoreStarter.kt | 40 +++++++ .../elmslie/compose/ElmComponentActivity.kt | 21 ++-- .../elmslie/compose/ElmComponentFragment.kt | 22 ++-- .../elmslie/core/config/ElmslieConfig.kt | 9 ++ .../money/elmslie/core/store/ElmStore.kt | 7 +- .../vivid/money/elmslie/core/store/Store.kt | 4 + .../elmslie/coroutines/ElmStoreCompat.kt | 6 +- .../vivid/money/elmslie/rx2/ElmStoreCompat.kt | 6 +- .../vivid/money/elmslie/rx3/ElmStoreCompat.kt | 6 +- .../samples/android/loader/MainActivity.kt | 4 +- .../samples/android/loader/elm/Store.kt | 3 +- .../android/compose/elm/PagingStoreFactory.kt | 3 +- .../android/compose/view/PagingFragment.kt | 16 ++- .../samples/coroutines/timer/MainActivity.kt | 3 +- .../coroutines/timer/elm/StoreFactory.kt | 7 +- .../coroutines/timer/elm/TimerReducer.kt | 5 +- elmslie-store-persisting/build.gradle | 17 --- .../src/main/AndroidManifest.xml | 1 - .../storepersisting/ClearableStoreHolder.kt | 17 --- .../storepersisting/RetainedExtentions.kt | 74 ------------ .../storepersisting/RetainedStoreHolder.kt | 61 ---------- .../elmslie/storepersisting/StoreHolders.kt | 26 ----- gradle/android-library.gradle | 4 +- gradle/dependencies.gradle | 4 +- 34 files changed, 300 insertions(+), 385 deletions(-) create mode 100644 elmslie-android/src/main/java/vivid/money/elmslie/android/androidstore/AndroidStore.kt delete mode 100644 elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/StopElmOnProcessDeath.kt rename elmslie-android/src/main/java/vivid/money/elmslie/android/{screen/ElmScreen.kt => renderer/ElmRenderer.kt} (58%) create mode 100644 elmslie-android/src/main/java/vivid/money/elmslie/android/renderer/ElmRendererDelegate.kt delete mode 100644 elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmDelegate.kt delete mode 100644 elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/LifecycleAwareStoreHolder.kt delete mode 100644 elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/StoreHolder.kt create mode 100644 elmslie-android/src/main/java/vivid/money/elmslie/android/storestarter/ViewBasedStoreStarter.kt delete mode 100644 elmslie-store-persisting/build.gradle delete mode 100644 elmslie-store-persisting/src/main/AndroidManifest.xml delete mode 100644 elmslie-store-persisting/src/main/java/vivid/money/elmslie/storepersisting/ClearableStoreHolder.kt delete mode 100644 elmslie-store-persisting/src/main/java/vivid/money/elmslie/storepersisting/RetainedExtentions.kt delete mode 100644 elmslie-store-persisting/src/main/java/vivid/money/elmslie/storepersisting/RetainedStoreHolder.kt delete mode 100644 elmslie-store-persisting/src/main/java/vivid/money/elmslie/storepersisting/StoreHolders.kt diff --git a/elmslie-android/build.gradle b/elmslie-android/build.gradle index 580c4b45..58c81466 100644 --- a/elmslie-android/build.gradle +++ b/elmslie-android/build.gradle @@ -10,6 +10,8 @@ dependencies { implementation(deps.android.appStartup) implementation(deps.android.lifecycle) implementation(deps.android.lifecycleKtx) + implementation(deps.android.lifecycleViewModelSavedState) + implementation(deps.android.paging) } apply from: "../gradle/junit-5.gradle" diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/androidstore/AndroidStore.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/androidstore/AndroidStore.kt new file mode 100644 index 00000000..7489baf3 --- /dev/null +++ b/elmslie-android/src/main/java/vivid/money/elmslie/android/androidstore/AndroidStore.kt @@ -0,0 +1,105 @@ +package vivid.money.elmslie.android.androidstore + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.annotation.MainThread +import androidx.fragment.app.Fragment +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.savedstate.SavedStateRegistryOwner +import vivid.money.elmslie.core.store.Store + +@MainThread +fun < + Event : Any, + Effect : Any, + State : Any, +> androidStore( + key: String, + getViewModelStoreOwner: () -> ViewModelStoreOwner, + getSavedStateRegistryOwner: () -> SavedStateRegistryOwner, + getDefaultArgs: () -> Bundle, + storeFactory: (SavedStateHandle) -> Store, +): Lazy> = + lazy(LazyThreadSafetyMode.NONE) { + val factory = + RetainedViewModelFactory( + stateRegistryOwner = getSavedStateRegistryOwner.invoke(), + defaultArgs = getDefaultArgs.invoke(), + storeFactory = storeFactory, + ) + val provider = ViewModelProvider(getViewModelStoreOwner.invoke(), factory) + + @Suppress("UNCHECKED_CAST") + provider.get(key, RetainedViewModel::class.java).store as Store + } + +@MainThread +fun < + Event : Any, + Effect : Any, + State : Any, +> Fragment.store( + key: String = this::class.java.canonicalName ?: this::class.java.simpleName, + getViewModelStoreOwner: () -> ViewModelStoreOwner = { this }, + getSavedStateRegistryOwner: () -> SavedStateRegistryOwner = { this }, + getDefaultArgs: () -> Bundle = { arguments ?: Bundle() }, + storeFactory: (SavedStateHandle) -> Store, +) = + androidStore( + storeFactory = storeFactory, + key = key, + getViewModelStoreOwner = getViewModelStoreOwner, + getSavedStateRegistryOwner = getSavedStateRegistryOwner, + getDefaultArgs = getDefaultArgs, + ) + +@MainThread +fun < + Event : Any, + Effect : Any, + State : Any, +> ComponentActivity.store( + key: String = this::class.java.canonicalName ?: this::class.java.simpleName, + getViewModelStoreOwner: () -> ViewModelStoreOwner = { this }, + getSavedStateRegistryOwner: () -> SavedStateRegistryOwner = { this }, + getDefaultArgs: () -> Bundle = { this.intent?.extras ?: Bundle() }, + storeFactory: (SavedStateHandle) -> Store, +) = + androidStore( + storeFactory = storeFactory, + key = key, + getViewModelStoreOwner = getViewModelStoreOwner, + getSavedStateRegistryOwner = getSavedStateRegistryOwner, + getDefaultArgs = getDefaultArgs, + ) + +private class RetainedViewModel( + savedStateHandle: SavedStateHandle, + storeFactory: (SavedStateHandle) -> Store, +) : ViewModel() { + + val store = storeFactory.invoke(savedStateHandle).apply { start() } + + override fun onCleared() { + store.stop() + } +} + +private class RetainedViewModelFactory( + stateRegistryOwner: SavedStateRegistryOwner, + defaultArgs: Bundle, + private val storeFactory: (SavedStateHandle) -> Store, +) : AbstractSavedStateViewModelFactory(stateRegistryOwner, defaultArgs) { + + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle, + ): T { + @Suppress("UNCHECKED_CAST") return RetainedViewModel(handle, storeFactory) as T + } +} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmActivity.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmActivity.kt index 4cebb3b9..3dae5ab2 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmActivity.kt +++ b/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmActivity.kt @@ -2,26 +2,40 @@ package vivid.money.elmslie.android.base import androidx.annotation.LayoutRes import androidx.appcompat.app.AppCompatActivity -import vivid.money.elmslie.android.screen.ElmDelegate -import vivid.money.elmslie.android.screen.ElmScreen -import vivid.money.elmslie.android.storeholder.LifecycleAwareStoreHolder -import vivid.money.elmslie.android.storeholder.StoreHolder -import vivid.money.elmslie.android.util.fastLazy +import androidx.lifecycle.SavedStateHandle +import vivid.money.elmslie.android.androidstore.store +import vivid.money.elmslie.android.renderer.ElmRenderer +import vivid.money.elmslie.android.renderer.ElmRendererDelegate +import vivid.money.elmslie.android.storestarter.ViewBasedStoreStarter +import vivid.money.elmslie.core.store.Store +@Deprecated("Please use ElmRenderer and ViewBasedStoreStarter in you base classes. Will be removed in next releases. ") abstract class ElmActivity : - AppCompatActivity, ElmDelegate { + AppCompatActivity, ElmRendererDelegate { - constructor() : super() + override val store by store( + storeFactory = ::createStore, + ) - constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) + @Suppress("LeakingThis") + private val renderer = + ElmRenderer( + delegate = this, + screenLifecycle = lifecycle, + ) @Suppress("LeakingThis", "UnusedPrivateMember") - private val elm = ElmScreen(this, lifecycle) { this } + private val starter = + ViewBasedStoreStarter( + storeProvider = { store }, + screenLifecycle = lifecycle, + initEventProvider = { initEvent }, + ) - protected val store - get() = storeHolder.store + constructor() : super() + + constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) - override val storeHolder: StoreHolder by fastLazy { - LifecycleAwareStoreHolder(lifecycle) { createStore()!! } - } + abstract val initEvent: Event + abstract fun createStore(savedStateHandle: SavedStateHandle): Store } diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmFragment.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmFragment.kt index d7629020..3a243c34 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmFragment.kt +++ b/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmFragment.kt @@ -2,26 +2,40 @@ package vivid.money.elmslie.android.base import androidx.annotation.LayoutRes import androidx.fragment.app.Fragment -import vivid.money.elmslie.android.screen.ElmDelegate -import vivid.money.elmslie.android.screen.ElmScreen -import vivid.money.elmslie.android.storeholder.LifecycleAwareStoreHolder -import vivid.money.elmslie.android.storeholder.StoreHolder -import vivid.money.elmslie.android.util.fastLazy +import androidx.lifecycle.SavedStateHandle +import vivid.money.elmslie.android.androidstore.store +import vivid.money.elmslie.android.renderer.ElmRenderer +import vivid.money.elmslie.android.renderer.ElmRendererDelegate +import vivid.money.elmslie.android.storestarter.ViewBasedStoreStarter +import vivid.money.elmslie.core.store.Store -abstract class ElmFragment : Fragment, - ElmDelegate { +@Deprecated("Please use ElmRenderer and ViewBasedStoreStarter in you base classes. Will be removed in next releases. ") +abstract class ElmFragment : + Fragment, ElmRendererDelegate { constructor() : super() constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) @Suppress("LeakingThis", "UnusedPrivateMember") - private val elm = ElmScreen(this, lifecycle) { requireActivity() } + private val renderer = + ElmRenderer( + delegate = this, + screenLifecycle = lifecycle, + ) - protected val store - get() = storeHolder.store + @Suppress("LeakingThis", "UnusedPrivateMember") + private val starter = + ViewBasedStoreStarter( + storeProvider = { store }, + screenLifecycle = lifecycle, + initEventProvider = { initEvent }, + ) + + override val store by store( + storeFactory = ::createStore, + ) - override val storeHolder: StoreHolder by fastLazy { - LifecycleAwareStoreHolder(lifecycle) { createStore()!! } - } + abstract val initEvent: Event + abstract fun createStore(savedStateHandle: SavedStateHandle): Store } diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/StopElmOnProcessDeath.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/StopElmOnProcessDeath.kt deleted file mode 100644 index 463ae067..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/StopElmOnProcessDeath.kt +++ /dev/null @@ -1,3 +0,0 @@ -package vivid.money.elmslie.android.processdeath - -interface StopElmOnProcessDeath diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/renderer/ElmRenderer.kt similarity index 58% rename from elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt rename to elmslie-android/src/main/java/vivid/money/elmslie/android/renderer/ElmRenderer.kt index 613b6403..b4a0dde5 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt +++ b/elmslie-android/src/main/java/vivid/money/elmslie/android/renderer/ElmRenderer.kt @@ -1,45 +1,29 @@ -package vivid.money.elmslie.android.screen +package vivid.money.elmslie.android.renderer -import android.app.Activity -import androidx.lifecycle.Lifecycle +import androidx.lifecycle.* import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.Lifecycle.State.STARTED -import androidx.lifecycle.coroutineScope -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.withCreated import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import vivid.money.elmslie.android.processdeath.ProcessDeathDetector.isRestoringAfterProcessDeath -import vivid.money.elmslie.android.processdeath.StopElmOnProcessDeath +import vivid.money.elmslie.android.util.fastLazy import vivid.money.elmslie.core.config.ElmslieConfig -class ElmScreen( - private val delegate: ElmDelegate, +class ElmRenderer( + private val delegate: ElmRendererDelegate, private val screenLifecycle: Lifecycle, - private val activityProvider: () -> Activity, ) { - val store - get() = delegate.storeHolder.store - + private val store by fastLazy { delegate.store } private val logger = ElmslieConfig.logger private val ioDispatcher: CoroutineDispatcher = ElmslieConfig.ioDispatchers - private var isAfterProcessDeath = false private val canRender get() = screenLifecycle.currentState.isAtLeast(STARTED) init { - with(screenLifecycle.coroutineScope) { - launch { - screenLifecycle.withCreated { - saveProcessDeathState() - triggerInitEventIfNecessary() - } - } - launch { + with(screenLifecycle) { + coroutineScope.launchWhenCreated { store .effects() .flowWithLifecycle( @@ -48,7 +32,7 @@ class ElmScreen( ) .collect { effect -> catchEffectErrors { delegate.handleEffect(effect) } } } - launch { + coroutineScope.launchWhenCreated { store .states() .flowWithLifecycle( @@ -73,11 +57,11 @@ class ElmScreen( } } - private fun mapListItems(state: State): List = + private fun mapListItems(state: State) = catchStateErrors { delegate.mapList(state) } ?: emptyList() @Suppress("TooGenericExceptionCaught") - private fun catchStateErrors(action: () -> T?): T? = + private fun catchStateErrors(action: () -> T?) = try { action() } catch (t: Throwable) { @@ -86,24 +70,10 @@ class ElmScreen( } @Suppress("TooGenericExceptionCaught") - private fun catchEffectErrors(action: () -> T?) { + private fun catchEffectErrors(action: () -> T?) = try { action() } catch (t: Throwable) { logger.fatal("Crash while handling effect", t) } - } - - private fun saveProcessDeathState() { - isAfterProcessDeath = isRestoringAfterProcessDeath - } - - private fun triggerInitEventIfNecessary() { - if (!delegate.storeHolder.isStarted && isAllowedToRun()) { - store.accept(delegate.initEvent) - } - } - - private fun isAllowedToRun(): Boolean = - !isAfterProcessDeath || activityProvider() !is StopElmOnProcessDeath } diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/renderer/ElmRendererDelegate.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/renderer/ElmRendererDelegate.kt new file mode 100644 index 00000000..85bb32be --- /dev/null +++ b/elmslie-android/src/main/java/vivid/money/elmslie/android/renderer/ElmRendererDelegate.kt @@ -0,0 +1,12 @@ +package vivid.money.elmslie.android.renderer + +import vivid.money.elmslie.core.store.Store + +interface ElmRendererDelegate { + val store: Store<*, Effect, State> + + fun render(state: State) + fun handleEffect(effect: Effect): Unit? = Unit + fun mapList(state: State): List = emptyList() + fun renderList(state: State, list: List) {} +} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmDelegate.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmDelegate.kt deleted file mode 100644 index a694247d..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmDelegate.kt +++ /dev/null @@ -1,21 +0,0 @@ -package vivid.money.elmslie.android.screen - -import vivid.money.elmslie.android.storeholder.StoreHolder -import vivid.money.elmslie.core.store.Store - -/** - * Required part of ELM implementation for each fragment - */ -interface ElmDelegate { - - val initEvent: Event - val storeHolder: StoreHolder - - @Deprecated("Use storeHolder property instead") - fun createStore(): Store? = null - fun render(state: State) - fun handleEffect(effect: Effect): Unit? = Unit - - fun mapList(state: State): List = emptyList() - fun renderList(state: State, list: List) {} -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/LifecycleAwareStoreHolder.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/LifecycleAwareStoreHolder.kt deleted file mode 100644 index 2822e878..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/LifecycleAwareStoreHolder.kt +++ /dev/null @@ -1,25 +0,0 @@ -package vivid.money.elmslie.android.storeholder - -import androidx.lifecycle.* -import vivid.money.elmslie.android.util.fastLazy -import vivid.money.elmslie.core.store.Store - -class LifecycleAwareStoreHolder( - lifecycle: Lifecycle, - storeProvider: () -> Store, -) : StoreHolder { - - override var isStarted = false - - override val store by fastLazy { storeProvider().start().also { isStarted = true } } - - private val lifecycleObserver = object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - store.stop() - } - } - - init { - lifecycle.addObserver(lifecycleObserver) - } -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/StoreHolder.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/StoreHolder.kt deleted file mode 100644 index d4fd2a46..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/StoreHolder.kt +++ /dev/null @@ -1,15 +0,0 @@ -package vivid.money.elmslie.android.storeholder - -import vivid.money.elmslie.core.store.Store - -/** - * Implementation of this interface should: - * 1. call Store::start during store creation - * 2. guarantee invariance of store while the view exists - * 3. call Store::stop when store be ready to gc - **/ -interface StoreHolder { - - val isStarted: Boolean - val store: Store -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/storestarter/ViewBasedStoreStarter.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/storestarter/ViewBasedStoreStarter.kt new file mode 100644 index 00000000..dff73571 --- /dev/null +++ b/elmslie-android/src/main/java/vivid/money/elmslie/android/storestarter/ViewBasedStoreStarter.kt @@ -0,0 +1,40 @@ +package vivid.money.elmslie.android.storestarter + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import vivid.money.elmslie.android.processdeath.ProcessDeathDetector +import vivid.money.elmslie.android.util.fastLazy +import vivid.money.elmslie.core.config.ElmslieConfig +import vivid.money.elmslie.core.store.Store + +class ViewBasedStoreStarter( + private val storeProvider: () -> Store, + screenLifecycle: Lifecycle, + private val initEventProvider: () -> Event, +) { + + private var isAfterProcessDeath = false + private val store by fastLazy { + storeProvider.invoke() + } + + init { + screenLifecycle.coroutineScope.launchWhenCreated { + saveProcessDeathState() + triggerInitEventIfNecessary() + } + } + + private fun saveProcessDeathState() { + isAfterProcessDeath = ProcessDeathDetector.isRestoringAfterProcessDeath + } + + private fun triggerInitEventIfNecessary() { + if (!store.isStarted && isAllowedToRun()) { + store.accept(initEventProvider.invoke()) + } + } + + private fun isAllowedToRun() = + !isAfterProcessDeath || !ElmslieConfig.shouldStopElmOnProcessDeath +} diff --git a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt index 7ee2271e..24cdb0d9 100644 --- a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt +++ b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt @@ -2,24 +2,19 @@ package vivid.money.elmslie.compose import androidx.activity.ComponentActivity import androidx.annotation.LayoutRes -import vivid.money.elmslie.android.screen.ElmDelegate -import vivid.money.elmslie.android.screen.ElmScreen -import vivid.money.elmslie.android.storeholder.LifecycleAwareStoreHolder +import androidx.lifecycle.SavedStateHandle +import vivid.money.elmslie.android.androidstore.store +import vivid.money.elmslie.core.store.Store -abstract class ElmComponentActivity : - ComponentActivity, ElmDelegate { +abstract class ElmComponentActivity : ComponentActivity { constructor() : super() constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) - @Suppress("LeakingThis", "UnusedPrivateMember") - private val elm = ElmScreen(this, lifecycle) { this } + protected val store by store( + storeFactory = ::createStore, + ) - val store - get() = storeHolder.store - - override val storeHolder = LifecycleAwareStoreHolder(lifecycle) { createStore()!! } - - final override fun render(state: State) = Unit + abstract fun createStore(stateHandle: SavedStateHandle): Store } diff --git a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt index 805107e3..4c5b1e95 100644 --- a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt +++ b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt @@ -1,26 +1,20 @@ package vivid.money.elmslie.compose import androidx.annotation.LayoutRes -import androidx.compose.runtime.* import androidx.fragment.app.Fragment -import vivid.money.elmslie.android.screen.ElmDelegate -import vivid.money.elmslie.android.screen.ElmScreen -import vivid.money.elmslie.android.storeholder.LifecycleAwareStoreHolder +import androidx.lifecycle.SavedStateHandle +import vivid.money.elmslie.android.androidstore.store +import vivid.money.elmslie.core.store.Store -abstract class ElmComponentFragment : - Fragment, ElmDelegate { +abstract class ElmComponentFragment : Fragment { constructor() : super() constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) - @Suppress("LeakingThis", "UnusedPrivateMember") - private val elm = ElmScreen(this, lifecycle) { requireActivity() } + protected val store by store( + storeFactory = ::createStore, + ) - protected val store - get() = storeHolder.store - - override val storeHolder = LifecycleAwareStoreHolder(lifecycle) { createStore()!! } - - final override fun render(state: State) = Unit + abstract fun createStore(stateHandle: SavedStateHandle): Store } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt index ba491558..df083ef5 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt @@ -12,12 +12,17 @@ object ElmslieConfig { @Volatile private lateinit var _ioDispatchers: CoroutineDispatcher + @Volatile private var _shouldStopElmOnProcessDeath: Boolean = true + val logger: ElmslieLogger get() = _logger val ioDispatchers: CoroutineDispatcher get() = _ioDispatchers + val shouldStopElmOnProcessDeath: Boolean + get() = _shouldStopElmOnProcessDeath + init { logger { always(IgnoreLog) } ioDispatchers { Dispatchers.IO } @@ -46,4 +51,8 @@ object ElmslieConfig { fun ioDispatchers(builder: () -> CoroutineDispatcher) { _ioDispatchers = builder() } + + fun shouldStopElmOnProcessDeath(builder: () -> Boolean) { + _shouldStopElmOnProcessDeath = builder() + } } diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt index 11d7fc99..34cf2f95 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt @@ -20,7 +20,8 @@ import vivid.money.elmslie.core.store.exception.StoreAlreadyStartedException class ElmStore( initialState: State, private val reducer: StateReducer, - private val actor: DefaultActor + private val actor: DefaultActor, + override val startEvent: Event? = null, ) : Store { private val logger = ElmslieConfig.logger @@ -39,7 +40,9 @@ class ElmStore( override fun accept(event: Event) = dispatchEvent(event) override fun start(): Store { - if (!_isStarted.compareAndSet(false, true)) { + if (_isStarted.compareAndSet(false, true)) { + startEvent?.let(::accept) + } else { logger.fatal("Store start error", StoreAlreadyStartedException()) } return this diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt index 7ec5326c..ccb3ad97 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt +++ b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt @@ -8,8 +8,12 @@ interface Store { val currentState: State /** Returns `true` for the span duration between [start] and [stop] calls. */ + @Deprecated("Will be deleted in future releases.") val isStarted: Boolean + /** Event that will be emitted after store start. */ + val startEvent: Event? + /** * Starts the operations inside the store. Throws **[StoreAlreadyStartedException] * [vivid.money.elmslie.core.store.exception.StoreAlreadyStartedException]** in case when the diff --git a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt index c32bb0db..de3e66fa 100644 --- a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt +++ b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt @@ -10,12 +10,14 @@ import vivid.money.elmslie.core.store.Store class ElmStoreCompat( initialState: State, reducer: StateReducer, - actor: Actor + actor: Actor, + startEvent: Event? = null, ) : Store by ElmStore( initialState = initialState, reducer = reducer, - actor = actor.toDefaultActor() + actor = actor.toDefaultActor(), + startEvent = startEvent, ) @Suppress("TooGenericExceptionCaught", "RethrowCaughtException") diff --git a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt index bdceeb74..baa80db5 100644 --- a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt +++ b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt @@ -12,12 +12,14 @@ import vivid.money.elmslie.core.store.Store class ElmStoreCompat( initialState: State, reducer: StateReducer, - actor: Actor + actor: Actor, + startEvent: Event? = null, ) : Store by ElmStore( initialState = initialState, reducer = reducer, - actor = actor.toActor() + actor = actor.toActor(), + startEvent = startEvent, ) private fun Actor.toActor() = diff --git a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt index 922e263f..cdad83b5 100644 --- a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt +++ b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt @@ -12,12 +12,14 @@ import vivid.money.elmslie.core.store.Store class ElmStoreCompat( initialState: State, reducer: StateReducer, - actor: Actor + actor: Actor, + startEvent: Event? = null, ) : Store by ElmStore( initialState = initialState, reducer = reducer, - actor = actor.toActor() + actor = actor.toActor(), + startEvent = startEvent, ) private fun Actor.toActor() = diff --git a/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/MainActivity.kt b/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/MainActivity.kt index 796b733e..e4fba6ce 100644 --- a/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/MainActivity.kt +++ b/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/MainActivity.kt @@ -3,8 +3,10 @@ package vivid.money.elmslie.samples.android.loader import android.os.Bundle import android.widget.Button import android.widget.TextView +import androidx.lifecycle.SavedStateHandle import com.google.android.material.snackbar.Snackbar import vivid.money.elmslie.android.base.ElmActivity +import vivid.money.elmslie.core.store.Store import vivid.money.elmslie.samples.android.loader.elm.Effect import vivid.money.elmslie.samples.android.loader.elm.Event import vivid.money.elmslie.samples.android.loader.elm.State @@ -19,7 +21,7 @@ class MainActivity : ElmActivity(R.layout.activity_main) { findViewById