diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 656e1241c..eaa019554 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -3,6 +3,7 @@ * [**Setting Up Mavericks**](setup.md) * [**What's new in Mavericks 2.0**](new-2x.md) * [**Upgrading from MvRx 1.x**](new-2x.md#upgrading) +* [**What's new in Mavericks 3.0**](new-3x.md) --- * [**Async/Network/Db Operations**](async.md) * [**Debug Checks**](debug-checks.md) diff --git a/docs/new-3x.md b/docs/new-3x.md new file mode 100644 index 000000000..d7d80f20c --- /dev/null +++ b/docs/new-3x.md @@ -0,0 +1,80 @@ +# What's new in Mavericks 3.0 + +### Experimental MavericksRepository + +Mavericks 3.0 introduces an experimental module `mvrx-core` with new abstraction `MavericksRepository` designed to provide a base class for any statefull repository implementation that owns and manages its state. This module is pure Kotlin and has no Android dependencies. Primary goal of this module is to provide the same API and behaviour as `MavericksViewModel` in Android modules. + + +### API Changes + +Mavericks 3.0 introduces one breaking change: `MavericksViewModelConfig.BlockExecutions` is extracted into `MavericksBlockExecutions`. In order to migrate, you need to update your code to use `MavericksBlockExecutions` instead of `MavericksViewModelConfig.BlockExecutions`. + +### New Functionality + +#### `MavericksRepository` + +`MavericksRepository` behaves exactly like `MavericksViewModel` except it doesn't have any Android dependencies. Even more, under the hood `MavericksViewModel` uses `MavericksRepository` to manage its state. You can find that `MavericksRepository` and `MavericksViewModel` are very similar in terms of API and behaviour. +As this is experimental module you have to opt in to use it by `-Xopt-in=com.airbnb.mvrx.InternalMavericksApi` compilation argument. + +```kotlin +data class Forecast( + val temperature: Int, + val precipitation: Int, + val wind: Int, +) + +data class WeatherForecastState( + val forecasts: Async> = Uninitialized, +) : MavericksState + +interface WeatherApi { + suspend fun getForecasts(): List +} + +class WeatherForecastRepository( + scope: CoroutineScope, + private val api: WeatherApi, +) : MavericksRepository( + initialState = WeatherForecastState(), + coroutineScope = scope, + performCorrectnessValidations = BuildConfig.DEBUG, +) { + init { + suspend { api.getForecasts() }.execute { copy(forecasts = it) } + } + + fun refresh() { + suspend { api.getForecasts() }.execute { copy(forecasts = it) } + } +} +``` + +#### `MavericksRepositoryConfig` + +In order to construct an instance of `MavericksRepository` you have to provide some configuration parameters, you can do that by: + +1. providing instance of `MavericksRepositoryConfig` +```kotlin +class WeatherForecastRepository( + scope: CoroutineScope, + private val api: WeatherApi, +) : MavericksRepository( + MavericksRepositoryConfig(...) +``` + +2. or via constructor arguments +```kotlin +class WeatherForecastRepository( + scope: CoroutineScope, + private val api: WeatherApi, +) : MavericksRepository( + initialState = WeatherForecastState(), + coroutineScope = scope, + performCorrectnessValidations = BuildConfig.DEBUG, +) +``` + +**Note:** `performCorrectnessValidations` should be enabled in debug build only as it applies runtime checks to ensure the repository is used correctly. +To avoid extra overhead this flag should be disabled in production build. + +Checkout out [integrate Mavericks into your app](/debug-checks) or docs for [MavericksRepositoryConfig](https://github.com/airbnb/mavericks/blob/main/mvrx-core/src/main/kotlin/com/airbnb/mvrx/MavericksRepositoryConfig.kt) for more info. diff --git a/gradle.properties b/gradle.properties index 9c8dbd788..5b2daf1b8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -VERSION_NAME=2.7.0 +VERSION_NAME=3.0.0 GROUP=com.airbnb.android POM_DESCRIPTION=Mavericks is an Android application framework that makes product development fast and fun. POM_URL=https://github.com/airbnb/mavericks diff --git a/launcher/src/main/java/com/airbnb/mvrx/launcher/MavericksLauncherMockActivity.kt b/launcher/src/main/java/com/airbnb/mvrx/launcher/MavericksLauncherMockActivity.kt index 6a20c19c1..99d8b282d 100644 --- a/launcher/src/main/java/com/airbnb/mvrx/launcher/MavericksLauncherMockActivity.kt +++ b/launcher/src/main/java/com/airbnb/mvrx/launcher/MavericksLauncherMockActivity.kt @@ -9,8 +9,8 @@ import android.os.Parcelable import android.util.Log import androidx.core.os.postDelayed import androidx.fragment.app.Fragment +import com.airbnb.mvrx.MavericksBlockExecutions import com.airbnb.mvrx.MavericksView -import com.airbnb.mvrx.MavericksViewModelConfig import com.airbnb.mvrx.launcher.utils.toastLong import com.airbnb.mvrx.mocking.MockBehavior import com.airbnb.mvrx.mocking.MockedView @@ -190,7 +190,7 @@ class MavericksLauncherMockActivity : MavericksBaseLauncherActivity() { mock.forInitialization || mock.isForProcessRecreation -> MockBehavior( initialStateMocking = MockBehavior.InitialStateMocking.Partial, stateStoreBehavior = MockBehavior.StateStoreBehavior.Normal, - blockExecutions = MavericksViewModelConfig.BlockExecutions.No + blockExecutions = MavericksBlockExecutions.No ) // The Fragment is fully mocked out and we prevent initialization code from overriding the mock. // However, our ViewModelEnabler will later toggle executions to be allowed, once initialization is over, @@ -198,7 +198,7 @@ class MavericksLauncherMockActivity : MavericksBaseLauncherActivity() { else -> MockBehavior( initialStateMocking = MockBehavior.InitialStateMocking.Full, stateStoreBehavior = MockBehavior.StateStoreBehavior.Scriptable, - blockExecutions = MavericksViewModelConfig.BlockExecutions.Completely + blockExecutions = MavericksBlockExecutions.Completely ) } } diff --git a/launcher/src/main/java/com/airbnb/mvrx/launcher/ViewModelEnabler.kt b/launcher/src/main/java/com/airbnb/mvrx/launcher/ViewModelEnabler.kt index 3d271a9fe..23344094f 100644 --- a/launcher/src/main/java/com/airbnb/mvrx/launcher/ViewModelEnabler.kt +++ b/launcher/src/main/java/com/airbnb/mvrx/launcher/ViewModelEnabler.kt @@ -4,9 +4,9 @@ import android.os.Handler import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.OnLifecycleEvent +import com.airbnb.mvrx.MavericksBlockExecutions import com.airbnb.mvrx.MavericksView import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelConfig import com.airbnb.mvrx.mocking.MockBehavior import com.airbnb.mvrx.mocking.MockableMavericksViewModelConfig import com.airbnb.mvrx.mocking.MockedView @@ -46,7 +46,7 @@ class ViewModelEnabler( .forEach { viewModel -> MockableMavericksViewModelConfig.access(viewModel).pushBehaviorOverride( MockBehavior( - blockExecutions = MavericksViewModelConfig.BlockExecutions.No, + blockExecutions = MavericksBlockExecutions.No, stateStoreBehavior = MockBehavior.StateStoreBehavior.Normal ) ) diff --git a/mvrx-core/build.gradle b/mvrx-core/build.gradle new file mode 100644 index 000000000..a8f23a0d0 --- /dev/null +++ b/mvrx-core/build.gradle @@ -0,0 +1,29 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' +} + +tasks.withType(KotlinCompile).all { + kotlinOptions { + freeCompilerArgs += [ + '-Xopt-in=kotlin.RequiresOptIn', + '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', + '-Xopt-in=com.airbnb.mvrx.InternalMavericksApi', + '-Xopt-in=com.airbnb.mvrx.ExperimentalMavericksApi', + ] + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + api Libraries.kotlinCoroutines + + testImplementation TestLibraries.junit + testImplementation TestLibraries.kotlinCoroutinesTest +} \ No newline at end of file diff --git a/mvrx-core/gradle.properties b/mvrx-core/gradle.properties new file mode 100644 index 000000000..1f13b4918 --- /dev/null +++ b/mvrx-core/gradle.properties @@ -0,0 +1,4 @@ +POM_NAME=Mavericks +POM_ARTIFACT_ID=mavericks-core +POM_PACKAGING=jar +GROUP=com.airbnb.android \ No newline at end of file diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/Async.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/Async.kt similarity index 98% rename from mvrx/src/main/kotlin/com/airbnb/mvrx/Async.kt rename to mvrx-core/src/main/java/com/airbnb/mvrx/Async.kt index cb1423d93..2a588d24e 100644 --- a/mvrx/src/main/kotlin/com/airbnb/mvrx/Async.kt +++ b/mvrx-core/src/main/java/com/airbnb/mvrx/Async.kt @@ -52,7 +52,7 @@ data class Success(private val value: T) : Async(complete = true, shou * you could map a network request to just the data you need in the value, but your base layers could * keep metadata about the request, like timing, for logging. * - * @see MavericksViewModel.execute + * @see MavericksRepository.execute * @see Async.setMetadata * @see Async.getMetadata */ diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/CoroutinesStateStore.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/CoroutinesStateStore.kt similarity index 98% rename from mvrx/src/main/kotlin/com/airbnb/mvrx/CoroutinesStateStore.kt rename to mvrx-core/src/main/java/com/airbnb/mvrx/CoroutinesStateStore.kt index 0a8a4b394..5e40a0477 100644 --- a/mvrx/src/main/kotlin/com/airbnb/mvrx/CoroutinesStateStore.kt +++ b/mvrx-core/src/main/java/com/airbnb/mvrx/CoroutinesStateStore.kt @@ -124,6 +124,6 @@ class CoroutinesStateStore( * The internally allocated buffer is replay + extraBufferCapacity but always allocates 2^n space. * We use replay=1 so buffer = 64-1. */ - internal const val SubscriberBufferSize = 63 + @InternalMavericksApi const val SubscriberBufferSize = 63 } } diff --git a/mvrx-core/src/main/java/com/airbnb/mvrx/ExperimentalMavericksApi.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/ExperimentalMavericksApi.kt new file mode 100644 index 000000000..381c03436 --- /dev/null +++ b/mvrx-core/src/main/java/com/airbnb/mvrx/ExperimentalMavericksApi.kt @@ -0,0 +1,9 @@ +package com.airbnb.mvrx + +/** + * Marks declarations that are still experimental in Mavericks API. + * Marked declarations are subject to change their semantics or behaviours, that not backward compatible. + */ +@Retention(value = AnnotationRetention.BINARY) +@RequiresOptIn(level = RequiresOptIn.Level.WARNING) +annotation class ExperimentalMavericksApi diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/InternalMavericksApi.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/InternalMavericksApi.kt similarity index 100% rename from mvrx/src/main/kotlin/com/airbnb/mvrx/InternalMavericksApi.kt rename to mvrx-core/src/main/java/com/airbnb/mvrx/InternalMavericksApi.kt diff --git a/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksBlockExecutions.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksBlockExecutions.kt new file mode 100644 index 000000000..e1ce76f4f --- /dev/null +++ b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksBlockExecutions.kt @@ -0,0 +1,19 @@ +package com.airbnb.mvrx + +/** + * Defines whether a [MavericksRepository.execute] invocation should not be run. + */ +enum class MavericksBlockExecutions { + /** Run the execute block normally. */ + No, + + /** Block the execute call from having an impact. */ + Completely, + + /** + * Block the execute call from having an impact from values returned by the object + * being executed, but perform one state callback to set the Async property to loading + * as if the call is actually happening. + */ + WithLoading +} diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksMutabilityHelper.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksMutabilityHelper.kt similarity index 76% rename from mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksMutabilityHelper.kt rename to mvrx-core/src/main/java/com/airbnb/mvrx/MavericksMutabilityHelper.kt index a8c999c99..adc67fb4b 100644 --- a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksMutabilityHelper.kt +++ b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksMutabilityHelper.kt @@ -1,10 +1,5 @@ package com.airbnb.mvrx -import android.os.Build -import android.util.SparseArray -import androidx.collection.ArrayMap -import androidx.collection.LongSparseArray -import androidx.collection.SparseArrayCompat import java.lang.reflect.Field import java.lang.reflect.Modifier import java.lang.reflect.ParameterizedType @@ -30,6 +25,16 @@ fun assertMavericksDataClassImmutability( ) { require(kClass.java.isData) { "Mavericks state must be a data class! - ${kClass.simpleName}" } + val disallowedFieldCollectionTypes = listOfNotNull( + ArrayList::class.java, + HashMap::class.java, + runCatching { Class.forName("android.util.SparseArray") }.getOrNull(), + runCatching { Class.forName("androidx.collection.LongSparseArray") }.getOrNull(), + runCatching { Class.forName("androidx.collection.SparseArrayCompat") }.getOrNull(), + runCatching { Class.forName("androidx.collection.ArrayMap") }.getOrNull(), + runCatching { Class.forName("android.util.ArrayMap") }.getOrNull(), + ) + fun Field.isSubtype(vararg classes: KClass<*>): Boolean { return classes.any { klass -> return when (val returnType = this.type) { @@ -43,16 +48,12 @@ fun assertMavericksDataClassImmutability( // During tests, jacoco can add a transient field called jacocoData. .filterNot { Modifier.isTransient(it.modifiers) } .forEach { prop -> + val disallowedFieldCollectionType = disallowedFieldCollectionTypes.firstOrNull { clazz -> prop.isSubtype(clazz.kotlin) } when { !Modifier.isFinal(prop.modifiers) -> "State property ${prop.name} must be a val, not a var." - prop.isSubtype(ArrayList::class) -> "You cannot use ArrayList for ${prop.name}.\n$IMMUTABLE_LIST_MESSAGE" - prop.isSubtype(SparseArray::class) -> "You cannot use SparseArray for ${prop.name}.\n$IMMUTABLE_LIST_MESSAGE" - prop.isSubtype(LongSparseArray::class) -> "You cannot use LongSparseArray for ${prop.name}.\n$IMMUTABLE_LIST_MESSAGE" - prop.isSubtype(SparseArrayCompat::class) -> "You cannot use SparseArrayCompat for ${prop.name}.\n$IMMUTABLE_LIST_MESSAGE" - prop.isSubtype(ArrayMap::class) -> "You cannot use ArrayMap for ${prop.name}.\n$IMMUTABLE_MAP_MESSAGE" - Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && - prop.isSubtype(android.util.ArrayMap::class) -> "You cannot use ArrayMap for ${prop.name}.\n$IMMUTABLE_MAP_MESSAGE" - prop.isSubtype(HashMap::class) -> "You cannot use HashMap for ${prop.name}.\n$IMMUTABLE_MAP_MESSAGE" + disallowedFieldCollectionType != null -> { + "You cannot use ${disallowedFieldCollectionType.simpleName} for ${prop.name}.\n$IMMUTABLE_LIST_MESSAGE" + } !allowFunctions && prop.isSubtype(Function::class, KCallable::class) -> { "You cannot use functions inside Mavericks state. Only pure data should be represented: ${prop.name}" } diff --git a/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksRepository.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksRepository.kt new file mode 100644 index 000000000..f3cc7943f --- /dev/null +++ b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksRepository.kt @@ -0,0 +1,415 @@ +package com.airbnb.mvrx + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.yield +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.reflect.KProperty1 + +@ExperimentalMavericksApi +abstract class MavericksRepository( + private val config: MavericksRepositoryConfig +) { + constructor( + /** + * State to initialize repository with. + */ + initialState: S, + /** + * The coroutine scope that will be provided to the repository. + */ + coroutineScope: CoroutineScope, + /** + * If true, extra validations will be applied to ensure the repository is used correctly. + */ + performCorrectnessValidations: Boolean, + ) : this( + MavericksRepositoryConfig( + performCorrectnessValidations = performCorrectnessValidations, + stateStore = CoroutinesStateStore( + initialState = initialState, + scope = coroutineScope, + ), + coroutineScope = coroutineScope, + ) + ) + + protected val coroutineScope: CoroutineScope = config.coroutineScope + + @InternalMavericksApi + protected val stateStore: MavericksStateStore = config.stateStore + + private val tag by lazy { javaClass.simpleName } + + private val mutableStateChecker = if (config.performCorrectnessValidations) MutableStateChecker(config.stateStore.state) else null + + /** + * Synchronous access to state is not exposed externally because there is no guarantee that + * all setState reducers have run yet. + */ + @InternalMavericksApi + val state: S + get() = stateStore.state + + /** + * Return the current state as a Flow. For certain situations, this may be more convenient + * than subscribe and selectSubscribe because it can easily be composed with other + * coroutines operations and chained with operators. + * + * This WILL emit the current state followed by all subsequent state updates. + * + * This is not a StateFlow to prevent from having synchronous access to state. withState { state -> } should + * be used as it is guaranteed to be run after all pending setState reducers have run. + */ + val stateFlow: Flow + get() = stateStore.flow + + init { + if (config.performCorrectnessValidations) { + coroutineScope.launch(Dispatchers.Default) { + validateState() + } + } + } + + /** + * Validates a number of properties on the state class. This cannot be called from the main thread because it does + * a fair amount of reflection. + */ + private fun validateState() { + assertMavericksDataClassImmutability(state::class) + } + + /** + * Call this to mutate the current state. + * A few important notes about the state reducer. + * 1) It will not be called synchronously or on the same thread. This is for performance and accuracy reasons. + * 2) Similar to the execute lambda above, the current state is the state receiver so the `count` in `count + 1` is actually the count + * property of the state at the time that the lambda is called. + * 3) In development, MvRx will do checks to make sure that your setState is pure by calling in multiple times. As a result, DO NOT use + * mutable variables or properties from outside the lambda or else it may crash. + */ + protected fun setState(reducer: S.() -> S) { + if (config.performCorrectnessValidations) { + // Must use `set` to ensure the validated state is the same as the actual state used in reducer + // Do not use `get` since `getState` queue has lower priority and the validated state would be the state after reduced + stateStore.set { + val firstState = this.reducer() + val secondState = this.reducer() + + if (firstState != secondState) { + @Suppress("UNCHECKED_CAST") + val changedProp = firstState::class.java.declaredFields.asSequence() + .onEach { it.isAccessible = true } + .firstOrNull { property -> + @Suppress("Detekt.TooGenericExceptionCaught") + try { + property.get(firstState) != property.get(secondState) + } catch (e: Throwable) { + false + } + } + if (changedProp != null) { + throw IllegalArgumentException( + "Impure reducer set on ${this@MavericksRepository::class.java.simpleName}! " + + "${changedProp.name} changed from ${changedProp.get(firstState)} " + + "to ${changedProp.get(secondState)}. " + + "Ensure that your state properties properly implement hashCode." + ) + } else { + throw IllegalArgumentException( + "Impure reducer set on ${this@MavericksRepository::class.java.simpleName}! Differing states were provided by the same reducer." + + "Ensure that your state properties properly implement hashCode. First state: $firstState -> Second state: $secondState" + ) + } + } + mutableStateChecker?.onStateChanged(firstState) + + firstState + } + } else { + stateStore.set(reducer) + } + } + + /** + * Calling this function suspends until all pending setState reducers are run and then returns the latest state. + * As a result, it is safe to call setState { } and assume that the result from a subsequent awaitState() call will have that state. + */ + suspend fun awaitState(): S { + val deferredState = CompletableDeferred() + withState(deferredState::complete) + return deferredState.await() + } + + /** + * Access the current repository state. Takes a block of code that will be run after all current pending state + * updates are processed. + */ + protected fun withState(action: (state: S) -> Unit) { + stateStore.get(action) + } + + /** + * Run a coroutine and wrap its progression with [Async] property reduced to the global state. + * + * @param dispatcher A custom coroutine dispatcher that the coroutine will run on. If null, uses the dispatcher in [coroutineScope], + * which defaults to [Dispatchers.Main.immediate] and can be overridden globally with [Mavericks.initialize]. + * @param retainValue A state property that, when set, will be called to retrieve an optional existing data value that will be retained across + * subsequent Loading and Fail states. This is useful if you want to display the previously successful data when + * refreshing. + * @param reducer A reducer that is applied to the current state and should return the new state. Because the state is the receiver + * and is likely a data class, an implementation may look like: `{ copy(response = it) }`. + */ + protected open fun Deferred.execute( + dispatcher: CoroutineDispatcher? = null, + retainValue: KProperty1>? = null, + reducer: S.(Async) -> S + ) = suspend { await() }.execute(dispatcher, retainValue, reducer) + + /** + * Run a coroutine and wrap its progression with [Async] property reduced to the global state. + * + * @param dispatcher A custom coroutine dispatcher that the coroutine will run on. If null, uses the dispatcher in [coroutineScope], + * which defaults to [Dispatchers.Main.immediate] and can be overridden globally with [Mavericks.initialize]. + * @param retainValue A state property that, when set, will be called to retrieve an optional existing data value that will be retained across + * subsequent Loading and Fail states. This is useful if you want to display the previously successful data when + * refreshing. + * @param reducer A reducer that is applied to the current state and should return the new state. Because the state is the receiver + * and is likely a data class, an implementation may look like: `{ copy(response = it) }`. + */ + protected open fun (suspend () -> T).execute( + dispatcher: CoroutineDispatcher? = null, + retainValue: KProperty1>? = null, + reducer: S.(Async) -> S + ): Job { + val blockExecutions = config.onExecute(this@MavericksRepository) + if (blockExecutions != MavericksBlockExecutions.No) { + if (blockExecutions == MavericksBlockExecutions.WithLoading) { + setState { reducer(Loading()) } + } + // Simulate infinite loading + return coroutineScope.launch { delay(Long.MAX_VALUE) } + } + + setState { reducer(Loading(value = retainValue?.get(this)?.invoke())) } + + return coroutineScope.launch(dispatcher ?: EmptyCoroutineContext) { + try { + val result = invoke() + setState { reducer(Success(result)) } + } catch (e: CancellationException) { + @Suppress("RethrowCaughtException") + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { + setState { reducer(Fail(e, value = retainValue?.get(this)?.invoke())) } + } + } + } + + /** + * Collect a Flow and wrap its progression with [Async] property reduced to the global state. + * + * @param dispatcher A custom coroutine dispatcher that the coroutine will run on. If null, uses the dispatcher in [coroutineScope], + * which defaults to [Dispatchers.Main.immediate] and can be overridden globally with [Mavericks.initialize]. + * @param retainValue A state property that, when set, will be called to retrieve an optional existing data value that will be retained across + * subsequent Loading and Fail states. This is useful if you want to display the previously successful data when + * refreshing. + * @param reducer A reducer that is applied to the current state and should return the new state. Because the state is the receiver + * and is likely a data class, an implementation may look like: `{ copy(response = it) }`. + */ + protected open fun Flow.execute( + dispatcher: CoroutineDispatcher? = null, + retainValue: KProperty1>? = null, + reducer: S.(Async) -> S + ): Job { + val blockExecutions = config.onExecute(this@MavericksRepository) + if (blockExecutions != MavericksBlockExecutions.No) { + if (blockExecutions == MavericksBlockExecutions.WithLoading) { + setState { reducer(Loading(value = retainValue?.get(this)?.invoke())) } + } + // Simulate infinite loading + return coroutineScope.launch { delay(Long.MAX_VALUE) } + } + + setState { reducer(Loading(value = retainValue?.get(this)?.invoke())) } + + return catch { error -> setState { reducer(Fail(error, value = retainValue?.get(this)?.invoke())) } } + .onEach { value -> setState { reducer(Success(value)) } } + .launchIn(coroutineScope + (dispatcher ?: EmptyCoroutineContext)) + } + + /** + * Collect a Flow and update state each time it emits a value. This is functionally the same as wrapping onEach with a setState call. + * + * @param dispatcher A custom coroutine dispatcher that the coroutine will run on. If null, uses the dispatcher in [coroutineScope], + * which defaults to [Dispatchers.Main.immediate] and can be overridden globally with [Mavericks.initialize]. + * @param reducer A reducer that is applied to the current state and should return the new state. Because the state is the receiver + * and is likely a data class, an implementation may look like: `{ copy(response = it) }`. + */ + protected open fun Flow.setOnEach( + dispatcher: CoroutineDispatcher? = null, + reducer: S.(T) -> S + ): Job { + val blockExecutions = config.onExecute(this@MavericksRepository) + if (blockExecutions != MavericksBlockExecutions.No) { + // Simulate infinite work + return coroutineScope.launch { delay(Long.MAX_VALUE) } + } + + return onEach { + setState { reducer(it) } + }.launchIn(coroutineScope + (dispatcher ?: EmptyCoroutineContext)) + } + + /** + * Subscribe to all state changes. + * + * @param action supports cooperative cancellation. The previous action will be cancelled if it is not completed before + * the next one is emitted. + */ + protected fun onEach( + action: suspend (S) -> Unit + ) = _internal(action) + + /** + * Subscribe to state changes for a single property. + * + * @param action supports cooperative cancellation. The previous action will be cancelled if it is not completed before + * the next one is emitted. + */ + protected fun onEach( + prop1: KProperty1, + action: suspend (A) -> Unit + ) = _internal1(prop1, action = action) + + /** + * Subscribe to state changes for two properties. + * + * @param action supports cooperative cancellation. The previous action will be cancelled if it is not completed before + * the next one is emitted. + */ + protected fun onEach( + prop1: KProperty1, + prop2: KProperty1, + action: suspend (A, B) -> Unit + ) = _internal2(prop1, prop2, action = action) + + /** + * Subscribe to state changes for three properties. + * + * @param action supports cooperative cancellation. The previous action will be cancelled if it is not completed before + * the next one is emitted. + */ + protected fun onEach( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + action: suspend (A, B, C) -> Unit + ) = _internal3(prop1, prop2, prop3, action = action) + + /** + * Subscribe to state changes for four properties. + * + * @param action supports cooperative cancellation. The previous action will be cancelled if it is not completed before + * the next one is emitted. + */ + protected fun onEach( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + action: suspend (A, B, C, D) -> Unit + ) = _internal4(prop1, prop2, prop3, prop4, action = action) + + /** + * Subscribe to state changes for five properties. + * + * @param action supports cooperative cancellation. The previous action will be cancelled if it is not completed before + * the next one is emitted. + */ + protected fun onEach( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + action: suspend (A, B, C, D, E) -> Unit + ) = _internal5(prop1, prop2, prop3, prop4, prop5, action = action) + + /** + * Subscribe to state changes for six properties. + * + * @param action supports cooperative cancellation. The previous action will be cancelled if it is not completed before + * the next one is emitted. + */ + protected fun onEach( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + prop6: KProperty1, + action: suspend (A, B, C, D, E, F) -> Unit + ) = _internal6(prop1, prop2, prop3, prop4, prop5, prop6, action = action) + + /** + * Subscribe to state changes for seven properties. + * + * @param action supports cooperative cancellation. The previous action will be cancelled if it is not completed before + * the next one is emitted. + */ + protected fun onEach( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + prop6: KProperty1, + prop7: KProperty1, + action: suspend (A, B, C, D, E, F, G) -> Unit + ) = _internal7(prop1, prop2, prop3, prop4, prop5, prop6, prop7, action = action) + + /** + * Subscribe to changes in an async property. There are optional parameters for onSuccess + * and onFail which automatically unwrap the value or error. + * + * @param onFail supports cooperative cancellation. The previous action will be cancelled if it as not completed before + * the next one is emitted. + * @param onSuccess supports cooperative cancellation. The previous action will be cancelled if it as not completed before + * the next one is emitted. + */ + protected fun onAsync( + asyncProp: KProperty1>, + onFail: (suspend (Throwable) -> Unit)? = null, + onSuccess: (suspend (T) -> Unit)? = null + ) = _internalSF(asyncProp, onFail, onSuccess) + + @Suppress("EXPERIMENTAL_API_USAGE") + @InternalMavericksApi + fun Flow.resolveSubscription(action: suspend (T) -> Unit): Job { + return (coroutineScope + config.subscriptionCoroutineContextOverride).launch(start = CoroutineStart.UNDISPATCHED) { + // Use yield to ensure flow collect coroutine is dispatched rather than invoked immediately. + // This is necessary when Dispatchers.Main.immediate is used in scope. + // Coroutine is launched with start = CoroutineStart.UNDISPATCHED to perform dispatch only once. + yield() + collectLatest(action) + } + } + + override fun toString(): String = "${this::class.java.simpleName} $state" +} diff --git a/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksRepositoryConfig.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksRepositoryConfig.kt new file mode 100644 index 000000000..84bd85331 --- /dev/null +++ b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksRepositoryConfig.kt @@ -0,0 +1,54 @@ +package com.airbnb.mvrx + +import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Provides configuration for a [MavericksRepositoryConfig]. + */ +@ExperimentalMavericksApi +class MavericksRepositoryConfig( + /** + * If true, extra validations will be applied to ensure the repository is used correctly. + * Should be enabled for debug build only. + */ + val performCorrectnessValidations: Boolean, + + /** + * The state store instance that will control the state of the repository. + */ + val stateStore: MavericksStateStore, + + /** + * The coroutine scope that will be provided to the repository. + */ + val coroutineScope: CoroutineScope, + + /** + * Provide a context that will be added to the coroutine scope when a subscription is registered (eg [MavericksRepository.onEach]). + * + * By default subscriptions use [coroutineScope] to launch the subscription in. + */ + val subscriptionCoroutineContextOverride: CoroutineContext = EmptyCoroutineContext, + + /** + * Called each time a [MavericksRepository.execute] function is invoked. This allows + * the execute function to be skipped, based on the returned [MavericksBlockExecutions] value. + * + * This is intended to be used to allow the [MavericksRepository] to be mocked out for testing. + * Blocking calls to execute prevents long running asynchronous operations from changing the + * state later on when the calls complete. + * + * Mocking out the state store cannot accomplish this on its own, because in some cases we may + * want the state store to initially be mocked, with state changes blocked, but later on we may + * want it to allow state changes. + * + * This prevents the case of an executed async call from modifying state once the state stored + * is "enabled", even if the execute was performed when the state store was "disabled" and we + * didn't intend to allow operations to change the state. + */ + val onExecute: (repository: MavericksRepository) -> MavericksBlockExecutions = { + MavericksBlockExecutions.No + } +) diff --git a/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksRepositoryExtensions.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksRepositoryExtensions.kt new file mode 100644 index 000000000..5e02d0d3b --- /dev/null +++ b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksRepositoryExtensions.kt @@ -0,0 +1,123 @@ +@file:Suppress("FunctionName") + +package com.airbnb.mvrx + +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlin.reflect.KProperty1 + +@InternalMavericksApi +fun , S : MavericksState> Repository._internal( + action: suspend (S) -> Unit +) = stateFlow.resolveSubscription(action) + +@InternalMavericksApi +fun , S : MavericksState, A> Repository._internal1( + prop1: KProperty1, + action: suspend (A) -> Unit +) = stateFlow + .map { MavericksTuple1(prop1.get(it)) } + .distinctUntilChanged() + .resolveSubscription() { (a) -> + action(a) + } + +@InternalMavericksApi +fun , S : MavericksState, A, B> Repository._internal2( + prop1: KProperty1, + prop2: KProperty1, + action: suspend (A, B) -> Unit +) = stateFlow + .map { MavericksTuple2(prop1.get(it), prop2.get(it)) } + .distinctUntilChanged() + .resolveSubscription() { (a, b) -> + action(a, b) + } + +@InternalMavericksApi +fun , S : MavericksState, A, B, C> Repository._internal3( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + action: suspend (A, B, C) -> Unit +) = stateFlow + .map { MavericksTuple3(prop1.get(it), prop2.get(it), prop3.get(it)) } + .distinctUntilChanged() + .resolveSubscription() { (a, b, c) -> + action(a, b, c) + } + +@InternalMavericksApi +fun , S : MavericksState, A, B, C, D> Repository._internal4( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + action: suspend (A, B, C, D) -> Unit +) = stateFlow + .map { MavericksTuple4(prop1.get(it), prop2.get(it), prop3.get(it), prop4.get(it)) } + .distinctUntilChanged() + .resolveSubscription() { (a, b, c, d) -> + action(a, b, c, d) + } + +@InternalMavericksApi +fun , S : MavericksState, A, B, C, D, E> Repository._internal5( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + action: suspend (A, B, C, D, E) -> Unit +) = stateFlow + .map { MavericksTuple5(prop1.get(it), prop2.get(it), prop3.get(it), prop4.get(it), prop5.get(it)) } + .distinctUntilChanged() + .resolveSubscription() { (a, b, c, d, e) -> + action(a, b, c, d, e) + } + +@InternalMavericksApi +fun , S : MavericksState, A, B, C, D, E, F> Repository._internal6( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + prop6: KProperty1, + action: suspend (A, B, C, D, E, F) -> Unit +) = stateFlow + .map { MavericksTuple6(prop1.get(it), prop2.get(it), prop3.get(it), prop4.get(it), prop5.get(it), prop6.get(it)) } + .distinctUntilChanged() + .resolveSubscription() { (a, b, c, d, e, f) -> + action(a, b, c, d, e, f) + } + +@InternalMavericksApi +fun , S : MavericksState, A, B, C, D, E, F, G> Repository._internal7( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + prop6: KProperty1, + prop7: KProperty1, + action: suspend (A, B, C, D, E, F, G) -> Unit +) = stateFlow + .map { MavericksTuple7(prop1.get(it), prop2.get(it), prop3.get(it), prop4.get(it), prop5.get(it), prop6.get(it), prop7.get(it)) } + .distinctUntilChanged() + .resolveSubscription() { (a, b, c, d, e, f, g) -> + action(a, b, c, d, e, f, g) + } + +@InternalMavericksApi +fun , S : MavericksState, T> Repository._internalSF( + asyncProp: KProperty1>, + onFail: (suspend (Throwable) -> Unit)? = null, + onSuccess: (suspend (T) -> Unit)? = null +) = _internal1(asyncProp) { asyncValue -> + if (onSuccess != null && asyncValue is Success) { + onSuccess(asyncValue()) + } else if (onFail != null && asyncValue is Fail) { + onFail(asyncValue.error) + } +} diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksState.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksState.kt similarity index 100% rename from mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksState.kt rename to mvrx-core/src/main/java/com/airbnb/mvrx/MavericksState.kt diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksStateStore.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksStateStore.kt similarity index 100% rename from mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksStateStore.kt rename to mvrx-core/src/main/java/com/airbnb/mvrx/MavericksStateStore.kt diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksTestOverrides.java b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksTestOverrides.java similarity index 100% rename from mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksTestOverrides.java rename to mvrx-core/src/main/java/com/airbnb/mvrx/MavericksTestOverrides.java diff --git a/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksTuples.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksTuples.kt new file mode 100644 index 000000000..5b53560a7 --- /dev/null +++ b/mvrx-core/src/main/java/com/airbnb/mvrx/MavericksTuples.kt @@ -0,0 +1,24 @@ +package com.airbnb.mvrx + +@InternalMavericksApi data class MavericksTuple1(val a: A) +@InternalMavericksApi data class MavericksTuple2(val a: A, val b: B) +@InternalMavericksApi data class MavericksTuple3(val a: A, val b: B, val c: C) +@InternalMavericksApi data class MavericksTuple4(val a: A, val b: B, val c: C, val d: D) +@InternalMavericksApi data class MavericksTuple5(val a: A, val b: B, val c: C, val d: D, val e: E) +@InternalMavericksApi data class MavericksTuple6( + val a: A, + val b: B, + val c: C, + val d: D, + val e: E, + val f: F +) +@InternalMavericksApi data class MavericksTuple7( + val a: A, + val b: B, + val c: C, + val d: D, + val e: E, + val f: F, + val g: G +) diff --git a/mvrx-core/src/main/java/com/airbnb/mvrx/RepositoryStateContainer.kt b/mvrx-core/src/main/java/com/airbnb/mvrx/RepositoryStateContainer.kt new file mode 100644 index 000000000..7934b1b14 --- /dev/null +++ b/mvrx-core/src/main/java/com/airbnb/mvrx/RepositoryStateContainer.kt @@ -0,0 +1,57 @@ +package com.airbnb.mvrx + +/** + * Accesses repository state from a single repository synchronously and returns the result of the block. + */ +fun , B : MavericksState, C> withState(repository1: A, block: (B) -> C) = block(repository1.state) + +/** + * Accesses repository state from two repositories synchronously and returns the result of the block. + */ +fun , B : MavericksState, C : MavericksRepository, D : MavericksState, E> withState( + repository1: A, + repository2: C, + block: (B, D) -> E +) = block(repository1.state, repository2.state) + +/** + * Accesses repository state from three repositories synchronously and returns the result of the block. + */ +fun , B : MavericksState, C : MavericksRepository, D : MavericksState, E : MavericksRepository, F : MavericksState, G> withState( + repository1: A, + repository2: C, + repository3: E, + block: (B, D, F) -> G +) = block(repository1.state, repository2.state, repository3.state) + +/** + * Accesses repository state from four repositories synchronously and returns the result of the block. + */ +fun < + A : MavericksRepository, B : MavericksState, + C : MavericksRepository, D : MavericksState, + E : MavericksRepository, F : MavericksState, + G : MavericksRepository, H : MavericksState, + I + > withState(repository1: A, repository2: C, repository3: E, repository4: G, block: (B, D, F, H) -> I) = + block(repository1.state, repository2.state, repository3.state, repository4.state) + +/** + * Accesses repository state from five repositories synchronously and returns the result of the block. + */ +fun < + A : MavericksRepository, B : MavericksState, + C : MavericksRepository, D : MavericksState, + E : MavericksRepository, F : MavericksState, + G : MavericksRepository, H : MavericksState, + I : MavericksRepository, J : MavericksState, + K + > withState( + repository1: A, + repository2: C, + repository3: E, + repository4: G, + repository5: I, + block: (B, D, F, H, J) -> K +) = + block(repository1.state, repository2.state, repository3.state, repository4.state, repository5.state) diff --git a/mvrx/src/test/kotlin/com/airbnb/mvrx/AsyncStateStoreTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/AsyncStateStoreTest.kt similarity index 97% rename from mvrx/src/test/kotlin/com/airbnb/mvrx/AsyncStateStoreTest.kt rename to mvrx-core/src/test/kotlin/com/airbnb/mvrx/AsyncStateStoreTest.kt index 5123e5e31..8a629cef4 100644 --- a/mvrx/src/test/kotlin/com/airbnb/mvrx/AsyncStateStoreTest.kt +++ b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/AsyncStateStoreTest.kt @@ -11,7 +11,6 @@ import org.junit.Assert.assertNull import org.junit.Test import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime -import kotlin.time.seconds class AsyncStateStoreTest { diff --git a/mvrx/src/test/kotlin/com/airbnb/mvrx/AsyncTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/AsyncTest.kt similarity index 100% rename from mvrx/src/test/kotlin/com/airbnb/mvrx/AsyncTest.kt rename to mvrx-core/src/test/kotlin/com/airbnb/mvrx/AsyncTest.kt diff --git a/mvrx-core/src/test/kotlin/com/airbnb/mvrx/BaseTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/BaseTest.kt new file mode 100644 index 000000000..7c7648620 --- /dev/null +++ b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/BaseTest.kt @@ -0,0 +1,20 @@ +package com.airbnb.mvrx + +import org.junit.AfterClass +import org.junit.BeforeClass + +abstract class BaseTest { + companion object { + @JvmStatic + @BeforeClass + fun classSetUp() { + MavericksTestOverrides.FORCE_SYNCHRONOUS_STATE_STORES = true + } + + @JvmStatic + @AfterClass + fun classTearDown() { + MavericksTestOverrides.FORCE_SYNCHRONOUS_STATE_STORES = false + } + } +} diff --git a/mvrx-core/src/test/kotlin/com/airbnb/mvrx/BaseTestMavericksRepository.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/BaseTestMavericksRepository.kt new file mode 100644 index 000000000..d4ea645e1 --- /dev/null +++ b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/BaseTestMavericksRepository.kt @@ -0,0 +1,15 @@ +package com.airbnb.mvrx + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel + +abstract class BaseTestMavericksRepository(initialState: S) : MavericksRepository( + initialState = initialState, + coroutineScope = CoroutineScope(Dispatchers.Unconfined), + performCorrectnessValidations = true, +) { + fun tearDown() { + coroutineScope.cancel() + } +} diff --git a/mvrx/src/test/kotlin/com/airbnb/mvrx/CoroutineStateStoreReplayTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/CoroutineStateStoreReplayTest.kt similarity index 97% rename from mvrx/src/test/kotlin/com/airbnb/mvrx/CoroutineStateStoreReplayTest.kt rename to mvrx-core/src/test/kotlin/com/airbnb/mvrx/CoroutineStateStoreReplayTest.kt index 89d697ced..8c13d8507 100644 --- a/mvrx/src/test/kotlin/com/airbnb/mvrx/CoroutineStateStoreReplayTest.kt +++ b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/CoroutineStateStoreReplayTest.kt @@ -26,13 +26,11 @@ class CoroutineStateStoreReplayTest { repeat(100) { singleReplayTestIteration(N = 5000, subscribers = 10) } - Unit } @Test fun replayLargeTest() = runBlocking { singleReplayTestIteration(N = 100_000, subscribers = 10) - Unit } /** @@ -42,6 +40,7 @@ class CoroutineStateStoreReplayTest { * or 4,3,4,5 (incorrect order) * or 3,3,4,5 (duplicate value) */ + @Suppress("DeferredResultUnused") private suspend fun singleReplayTestIteration(N: Int, subscribers: Int) = withContext(Dispatchers.Default) { val scope = CoroutineScope(Dispatchers.Default + Job()) val store = CoroutinesStateStore(State(foo = 0), scope) @@ -72,6 +71,7 @@ class CoroutineStateStoreReplayTest { * Will fail if stateChannel subscription will be collected without finally block in CoroutinesStateStore.flow builder */ @Test(timeout = 10_000) + @Suppress("DeferredResultUnused", "LocalVariableName") fun testProperCancellation() = runBlocking { val scope = CoroutineScope(Dispatchers.Default + Job()) val store = CoroutinesStateStore(State(foo = 0), scope) @@ -97,6 +97,5 @@ class CoroutineStateStoreReplayTest { } } scope.cancel() - Unit } } diff --git a/mvrx/src/test/kotlin/com/airbnb/mvrx/MavericksMutabilityHelperKtTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/MavericksMutabilityHelperKtTest.kt similarity index 100% rename from mvrx/src/test/kotlin/com/airbnb/mvrx/MavericksMutabilityHelperKtTest.kt rename to mvrx-core/src/test/kotlin/com/airbnb/mvrx/MavericksMutabilityHelperKtTest.kt diff --git a/mvrx-core/src/test/kotlin/com/airbnb/mvrx/MavericksRepositoryTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/MavericksRepositoryTest.kt new file mode 100644 index 000000000..6aabf0078 --- /dev/null +++ b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/MavericksRepositoryTest.kt @@ -0,0 +1,207 @@ +package com.airbnb.mvrx + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import kotlin.reflect.KProperty1 + +data class MavericksRepositoryTestState( + val asyncInt: Async = Uninitialized, + val int: Int = 0 +) : MavericksState + +class MavericksRepositoryTestRepository : BaseTestMavericksRepository(MavericksRepositoryTestState()) { + suspend fun runInRepository(block: suspend MavericksRepositoryTestRepository.() -> Unit) { + block() + } + + fun setInt(int: Int) = setState { copy(int = int) } + + fun (suspend () -> T).executePublic( + dispatcher: CoroutineDispatcher? = null, + retainValue: KProperty1>? = null, + reducer: MavericksRepositoryTestState.(Async) -> MavericksRepositoryTestState + ) { + execute(dispatcher, retainValue, reducer) + } + + fun Flow.executePublic( + dispatcher: CoroutineDispatcher? = null, + retainValue: KProperty1>? = null, + reducer: MavericksRepositoryTestState.(Async) -> MavericksRepositoryTestState + ) { + execute(dispatcher, retainValue, reducer) + } + + fun Deferred.executePublic( + dispatcher: CoroutineDispatcher? = null, + retainValue: KProperty1>? = null, + reducer: MavericksRepositoryTestState.(Async) -> MavericksRepositoryTestState + ) = suspend { await() }.execute(dispatcher, retainValue, reducer) + + fun Flow.setOnEachPublic( + dispatcher: CoroutineDispatcher? = null, + reducer: MavericksRepositoryTestState.(T) -> MavericksRepositoryTestState + ) { + setOnEach(dispatcher, reducer) + } +} + +@ExperimentalCoroutinesApi +class MavericksRepositoryTest : BaseTest() { + + private lateinit var repository: MavericksRepositoryTestRepository + + @Before + fun setup() { + repository = MavericksRepositoryTestRepository() + } + + @Test + fun testAsyncSuccess() = runInRepositoryBlocking( + MavericksRepositoryTestState(asyncInt = Uninitialized), + MavericksRepositoryTestState(asyncInt = Loading()), + MavericksRepositoryTestState(asyncInt = Success(5)) + ) { + suspend { + 5 + }.executePublic { copy(asyncInt = it) } + } + + @Test + fun testAsyncSuccessWithRetainValue() = runInRepositoryBlocking( + MavericksRepositoryTestState(asyncInt = Uninitialized), + MavericksRepositoryTestState(asyncInt = Loading()), + MavericksRepositoryTestState(asyncInt = Success(5)), + MavericksRepositoryTestState(asyncInt = Loading(value = 5)), + MavericksRepositoryTestState(asyncInt = Success(7)) + ) { + suspend { + 5 + }.executePublic(retainValue = MavericksRepositoryTestState::asyncInt) { copy(asyncInt = it) } + suspend { + 7 + }.executePublic(retainValue = MavericksRepositoryTestState::asyncInt) { copy(asyncInt = it) } + } + + @Test + fun testAsyncFail() = runInRepositoryBlocking( + MavericksRepositoryTestState(asyncInt = Uninitialized), + MavericksRepositoryTestState(asyncInt = Loading()), + MavericksRepositoryTestState(asyncInt = Fail(exception)) + ) { + suspend { + throw exception + }.executePublic(retainValue = MavericksRepositoryTestState::asyncInt) { copy(asyncInt = it) } + } + + @Test + fun testAsyncFailWithRetainValue() = runInRepositoryBlocking( + MavericksRepositoryTestState(asyncInt = Uninitialized), + MavericksRepositoryTestState(asyncInt = Loading()), + MavericksRepositoryTestState(asyncInt = Success(5)), + MavericksRepositoryTestState(asyncInt = Loading(value = 5)), + MavericksRepositoryTestState(asyncInt = Fail(exception, value = 5)) + ) { + suspend { + 5 + }.executePublic(retainValue = MavericksRepositoryTestState::asyncInt) { copy(asyncInt = it) } + suspend { + throw exception + }.executePublic(retainValue = MavericksRepositoryTestState::asyncInt) { copy(asyncInt = it) } + } + + @Test + fun testDeferredSuccess() = runInRepositoryBlocking( + MavericksRepositoryTestState(asyncInt = Uninitialized), + MavericksRepositoryTestState(asyncInt = Loading()), + MavericksRepositoryTestState(asyncInt = Success(5)) + ) { + val deferredValue = CompletableDeferred() + deferredValue.executePublic { copy(asyncInt = it) } + delay(1000) + deferredValue.complete(5) + } + + @Test + fun testDeferredFail() = runInRepositoryBlocking( + MavericksRepositoryTestState(asyncInt = Uninitialized), + MavericksRepositoryTestState(asyncInt = Loading()), + MavericksRepositoryTestState(asyncInt = Fail(exception)) + ) { + val deferredValue = CompletableDeferred() + deferredValue.executePublic { copy(asyncInt = it) } + delay(1000) + deferredValue.completeExceptionally(exception) + } + + @Test + fun testFlowExecute() = runInRepositoryBlocking( + MavericksRepositoryTestState(asyncInt = Uninitialized), + MavericksRepositoryTestState(asyncInt = Loading()), + MavericksRepositoryTestState(asyncInt = Success(1)), + MavericksRepositoryTestState(asyncInt = Success(2)) + ) { + flowOf(1, 2).executePublic { copy(asyncInt = it) } + } + + @Test + fun testFlowExecuteWithRetainValue() = runInRepositoryBlocking( + MavericksRepositoryTestState(asyncInt = Uninitialized), + MavericksRepositoryTestState(asyncInt = Loading()), + MavericksRepositoryTestState(asyncInt = Success(5)), + MavericksRepositoryTestState(asyncInt = Fail(exception, value = 5)) + ) { + flow { + emit(5) + throw exception + }.executePublic(retainValue = MavericksRepositoryTestState::asyncInt) { copy(asyncInt = it) } + } + + @Test + fun testFlowSetOnEach() = runInRepositoryBlocking( + MavericksRepositoryTestState(int = 0), + MavericksRepositoryTestState(int = 1), + MavericksRepositoryTestState(int = 2) + ) { + flowOf(1, 2).setOnEachPublic { copy(int = it) } + } + + @Test + fun testAwaitState() = runInRepositoryBlocking( + MavericksRepositoryTestState(int = 0), + MavericksRepositoryTestState(int = 1), + ) { + setInt(1) + val state = awaitState() + assertEquals(1, state.int) + } + + private fun runInRepositoryBlocking( + vararg expectedState: MavericksRepositoryTestState, + block: suspend MavericksRepositoryTestRepository.() -> Unit + ) = runBlockingTest { + val states = mutableListOf() + val consumerJob = repository.stateFlow.onEach { states += it }.launchIn(this) + repository.runInRepository(block) + repository.tearDown() + consumerJob.cancel() + // We stringify the state list to make all exceptions equal each other. Without it, the stack traces cause tests to fail. + assertEquals(expectedState.toList().toString(), states.toString()) + } + + companion object { + private val exception = IllegalStateException("Fail!") + } +} diff --git a/mvrx/src/test/kotlin/com/airbnb/mvrx/MutableStateValidationTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/MutableStateValidationTest.kt similarity index 68% rename from mvrx/src/test/kotlin/com/airbnb/mvrx/MutableStateValidationTest.kt rename to mvrx-core/src/test/kotlin/com/airbnb/mvrx/MutableStateValidationTest.kt index bd2a7a3aa..2c3396170 100644 --- a/mvrx/src/test/kotlin/com/airbnb/mvrx/MutableStateValidationTest.kt +++ b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/MutableStateValidationTest.kt @@ -9,8 +9,8 @@ class MutableStateValidationTest : BaseTest() { @Test(expected = IllegalArgumentException::class) fun mutableStateShouldFail() { - class ViewModel(initialState: StateWithMutableMap) : - TestMavericksViewModel(initialState) { + class Repository(initialState: StateWithMutableMap) : + BaseTestMavericksRepository(initialState) { fun addKeyToMap() { val myMap = withState(this) { it.map } @@ -19,13 +19,13 @@ class MutableStateValidationTest : BaseTest() { setState { copy(map = myMap) } } } - ViewModel(StateWithMutableMap()).addKeyToMap() + Repository(StateWithMutableMap()).addKeyToMap() } @Test fun immutableStateShouldNotFail() { - class ViewModel(initialState: StateWithImmutableMap) : - TestMavericksViewModel(initialState) { + class Repository(initialState: StateWithImmutableMap) : + BaseTestMavericksRepository(initialState) { fun addKeyToMap() { val myMap = withState(this) { it.map }.toMutableMap() @@ -34,6 +34,6 @@ class MutableStateValidationTest : BaseTest() { setState { copy(map = myMap) } } } - ViewModel(StateWithImmutableMap()).addKeyToMap() + Repository(StateWithImmutableMap()).addKeyToMap() } } diff --git a/mvrx/src/test/kotlin/com/airbnb/mvrx/PureReducerValidationTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/PureReducerValidationTest.kt similarity index 51% rename from mvrx/src/test/kotlin/com/airbnb/mvrx/PureReducerValidationTest.kt rename to mvrx-core/src/test/kotlin/com/airbnb/mvrx/PureReducerValidationTest.kt index 2a68b35f5..b169f5d9b 100644 --- a/mvrx/src/test/kotlin/com/airbnb/mvrx/PureReducerValidationTest.kt +++ b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/PureReducerValidationTest.kt @@ -15,23 +15,23 @@ class PureReducerValidationTest : BaseTest() { @Test fun impureReducerShouldFail() { - class ImpureViewModel(initialState: PureReducerValidationState) : TestMavericksViewModel(initialState) { + class ImpureRepository(initialState: PureReducerValidationState) : BaseTestMavericksRepository(initialState) { private var count = 0 fun impureReducer() { setState { - val state = copy(count = ++this@ImpureViewModel.count) + val state = copy(count = ++this@ImpureRepository.count) state } } } thrown.expect(IllegalArgumentException::class.java) - thrown.expectMessage("Impure reducer set on ImpureViewModel! count changed from 1 to 2. Ensure that your state properties properly implement hashCode.") - ImpureViewModel(PureReducerValidationState()).impureReducer() + thrown.expectMessage("Impure reducer set on ImpureRepository! count changed from 1 to 2. Ensure that your state properties properly implement hashCode.") + ImpureRepository(PureReducerValidationState()).impureReducer() } @Test fun pureReducerShouldNotFail() { - class PureViewModel(initialState: PureReducerValidationState) : TestMavericksViewModel(initialState) { + class PureRepository(initialState: PureReducerValidationState) : BaseTestMavericksRepository(initialState) { fun pureReducer() { setState { val state = copy(count = count + 1) @@ -39,33 +39,33 @@ class PureReducerValidationTest : BaseTest() { } } } - PureViewModel(PureReducerValidationState()).pureReducer() + PureRepository(PureReducerValidationState()).pureReducer() } @Test fun shouldBeAbleToUsePrivateProps() { - class PureViewModel(initialState: StateWithPrivateVal) : TestMavericksViewModel(initialState) { + class PureRepository(initialState: StateWithPrivateVal) : BaseTestMavericksRepository(initialState) { fun pureReducer() { setState { this } } } - PureViewModel(StateWithPrivateVal()).pureReducer() + PureRepository(StateWithPrivateVal()).pureReducer() } @Test fun impureReducerWithPrivatePropShouldFail() { - class ImpureViewModel(initialState: StateWithPrivateVal) : TestMavericksViewModel(initialState) { + class ImpureRepository(initialState: StateWithPrivateVal) : BaseTestMavericksRepository(initialState) { private var count = 0 fun impureReducer() { setState { - val state = copy(count = ++this@ImpureViewModel.count) + val state = copy(count = ++this@ImpureRepository.count) state } } } thrown.expect(IllegalArgumentException::class.java) - thrown.expectMessage("Impure reducer set on ImpureViewModel! count changed from 1 to 2. Ensure that your state properties properly implement hashCode.") - ImpureViewModel(StateWithPrivateVal()).impureReducer() + thrown.expectMessage("Impure reducer set on ImpureRepository! count changed from 1 to 2. Ensure that your state properties properly implement hashCode.") + ImpureRepository(StateWithPrivateVal()).impureReducer() } } diff --git a/mvrx/src/test/kotlin/com/airbnb/mvrx/SetStateWithStateAfterScopeCancellationTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/SetStateWithStateAfterScopeCancellationTest.kt similarity index 100% rename from mvrx/src/test/kotlin/com/airbnb/mvrx/SetStateWithStateAfterScopeCancellationTest.kt rename to mvrx-core/src/test/kotlin/com/airbnb/mvrx/SetStateWithStateAfterScopeCancellationTest.kt diff --git a/mvrx-core/src/test/kotlin/com/airbnb/mvrx/StateImmutabilityTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/StateImmutabilityTest.kt new file mode 100644 index 000000000..828fe15a0 --- /dev/null +++ b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/StateImmutabilityTest.kt @@ -0,0 +1,88 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.airbnb.mvrx + +import org.junit.Test + +class StateImmutabilityTest : BaseTest() { + + @Test + fun valProp() { + data class State(val foo: Int = 5) + assertMavericksDataClassImmutability(State::class) + } + + @Test + fun immutableMap() { + data class State(val foo: Map = mapOf("a" to 0)) + assertMavericksDataClassImmutability(State::class) + } + + @Test + fun immutableList() { + data class State(val foo: List = listOf(1, 2, 3)) + assertMavericksDataClassImmutability(State::class) + } + + @Test(expected = IllegalArgumentException::class) + fun nonDataState() { + class State + assertMavericksDataClassImmutability(State::class) + } + + @Test(expected = IllegalArgumentException::class) + fun nonDataStateWithComponent1() { + class State { + operator fun component1() = 5 + } + assertMavericksDataClassImmutability(State::class) + } + + @Suppress("EqualsOrHashCode") + @Test(expected = IllegalArgumentException::class) + fun nonDataStateWithHashCode() { + class State { + override fun hashCode() = 123 + } + assertMavericksDataClassImmutability(State::class) + } + + @Suppress("EqualsOrHashCode") + @Test(expected = IllegalArgumentException::class) + fun nonDataStateWithEquals() { + class State { + override fun equals(other: Any?) = false + } + assertMavericksDataClassImmutability(State::class) + } + + @Test(expected = IllegalArgumentException::class) + fun varState() { + data class State(var foo: Int = 5) + assertMavericksDataClassImmutability(State::class) + } + + @Test(expected = IllegalArgumentException::class) + fun mutableList() { + data class State(val list: ArrayList = ArrayList()) + assertMavericksDataClassImmutability(State::class) + } + + @Test(expected = IllegalArgumentException::class) + fun mutableMap() { + data class State(val map: HashMap = HashMap()) + assertMavericksDataClassImmutability(State::class) + } + + @Test(expected = IllegalArgumentException::class) + fun lambda() { + data class State(val func: () -> Unit = {}) + assertMavericksDataClassImmutability(State::class) + } + + @Test + fun lambdaAllowed() { + data class State(val func: () -> Unit = {}) + assertMavericksDataClassImmutability(State::class, allowFunctions = true) + } +} diff --git a/mvrx/src/test/kotlin/com/airbnb/mvrx/StateStoreTest.kt b/mvrx-core/src/test/kotlin/com/airbnb/mvrx/StateStoreTest.kt similarity index 100% rename from mvrx/src/test/kotlin/com/airbnb/mvrx/StateStoreTest.kt rename to mvrx-core/src/test/kotlin/com/airbnb/mvrx/StateStoreTest.kt diff --git a/mvrx-mocking/build.gradle b/mvrx-mocking/build.gradle index 391342fdc..440f56a57 100644 --- a/mvrx-mocking/build.gradle +++ b/mvrx-mocking/build.gradle @@ -10,6 +10,7 @@ tasks.withType(KotlinCompile).all { kotlinOptions { freeCompilerArgs += [ '-Xopt-in=com.airbnb.mvrx.InternalMavericksApi', + '-Xopt-in=com.airbnb.mvrx.ExperimentalMavericksApi', ] } } diff --git a/mvrx-mocking/src/main/kotlin/com/airbnb/mvrx/mocking/MockableMavericksViewModelConfig.kt b/mvrx-mocking/src/main/kotlin/com/airbnb/mvrx/mocking/MockableMavericksViewModelConfig.kt index 0b827b671..6bc41a40e 100644 --- a/mvrx-mocking/src/main/kotlin/com/airbnb/mvrx/mocking/MockableMavericksViewModelConfig.kt +++ b/mvrx-mocking/src/main/kotlin/com/airbnb/mvrx/mocking/MockableMavericksViewModelConfig.kt @@ -3,12 +3,13 @@ package com.airbnb.mvrx.mocking import android.content.Context import androidx.lifecycle.LifecycleOwner import com.airbnb.mvrx.CoroutinesStateStore +import com.airbnb.mvrx.MavericksBlockExecutions import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelConfig import com.airbnb.mvrx.MavericksViewModelConfigFactory import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksStateStore import com.airbnb.mvrx.MavericksView +import com.airbnb.mvrx.MavericksViewModelConfig import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.ScriptableStateStore import com.airbnb.mvrx.mocking.printer.ViewModelStatePrinter @@ -23,7 +24,12 @@ class MockableMavericksViewModelConfig( val initialMockBehavior: MockBehavior, coroutineScope: CoroutineScope, debugMode: Boolean -) : MavericksViewModelConfig(debugMode = debugMode, stateStore = mockableStateStore, coroutineScope = coroutineScope) { +) : MavericksViewModelConfig( + debugMode = debugMode, + stateStore = mockableStateStore, + coroutineScope = coroutineScope, + subscriptionCoroutineContextOverride = EmptyCoroutineContext +) { val currentMockBehavior: MockBehavior get() = mockBehaviorOverrides.peek() ?: initialMockBehavior @@ -31,7 +37,7 @@ class MockableMavericksViewModelConfig( private val mockBehaviorOverrides = LinkedList() private val onExecuteListeners = - mutableSetOf<(MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksViewModelConfig.BlockExecutions) -> Unit>() + mutableSetOf<(MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksBlockExecutions) -> Unit>() fun pushBehaviorOverride(behaviorChange: (currentBehavior: MockBehavior) -> MockBehavior) { pushBehaviorOverride(behaviorChange(currentMockBehavior)) @@ -54,24 +60,24 @@ class MockableMavericksViewModelConfig( } fun addOnExecuteListener( - listener: (MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksViewModelConfig.BlockExecutions) -> Unit + listener: (MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksBlockExecutions) -> Unit ) { onExecuteListeners.add(listener) } fun removeOnExecuteListener( - listener: (MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksViewModelConfig.BlockExecutions) -> Unit + listener: (MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksBlockExecutions) -> Unit ) { onExecuteListeners.remove(listener) } fun clearOnExecuteListeners(): Unit = onExecuteListeners.clear() - override fun onExecute(viewModel: MavericksViewModel): BlockExecutions { + override fun onExecute(viewModel: MavericksViewModel): MavericksBlockExecutions { val blockExecutions = currentMockBehavior.blockExecutions - onExecuteListeners.forEach { - it(this, viewModel, blockExecutions) + onExecuteListeners.forEach { listener -> + listener(this, viewModel, blockExecutions) } return blockExecutions @@ -104,7 +110,7 @@ class MockableMavericksViewModelConfig( */ data class MockBehavior( val initialStateMocking: InitialStateMocking = InitialStateMocking.None, - val blockExecutions: MavericksViewModelConfig.BlockExecutions = MavericksViewModelConfig.BlockExecutions.No, + val blockExecutions: MavericksBlockExecutions = MavericksBlockExecutions.No, val stateStoreBehavior: StateStoreBehavior = StateStoreBehavior.Normal, /** * If true, when a view registers a ViewModel via a delegate the view will be subscribed @@ -194,7 +200,7 @@ open class MockMavericksViewModelConfigFactory( get() = mockConfigs.toMap() private val onExecuteListeners = - mutableSetOf<(MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksViewModelConfig.BlockExecutions) -> Unit>() + mutableSetOf<(MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksBlockExecutions) -> Unit>() /** * Determines what sort of mocked state store is created when [provideConfig] is called. @@ -204,7 +210,7 @@ open class MockMavericksViewModelConfigFactory( */ var mockBehavior: MockBehavior = MockBehavior( initialStateMocking = MockBehavior.InitialStateMocking.None, - blockExecutions = MavericksViewModelConfig.BlockExecutions.No, + blockExecutions = MavericksBlockExecutions.No, stateStoreBehavior = MockBehavior.StateStoreBehavior.Normal ) @@ -305,14 +311,14 @@ open class MockMavericksViewModelConfigFactory( * Add a lambda that will be invoked whenever [MavericksViewModel.execute] is used. */ fun addOnExecuteListener( - listener: (MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksViewModelConfig.BlockExecutions) -> Unit + listener: (MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksBlockExecutions) -> Unit ) { onExecuteListeners.add(listener) mockConfigs.values.forEach { it.addOnExecuteListener(listener) } } fun removeOnExecuteListener( - listener: (MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksViewModelConfig.BlockExecutions) -> Unit + listener: (MavericksViewModelConfig<*>, MavericksViewModel<*>, MavericksBlockExecutions) -> Unit ) { onExecuteListeners.remove(listener) mockConfigs.values.forEach { it.removeOnExecuteListener(listener) } diff --git a/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/MavericksViewModelConfigTest.kt b/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/MavericksViewModelConfigTest.kt index cc1069876..36e331822 100644 --- a/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/MavericksViewModelConfigTest.kt +++ b/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/MavericksViewModelConfigTest.kt @@ -1,10 +1,11 @@ package com.airbnb.mvrx.mocking import com.airbnb.mvrx.Mavericks +import com.airbnb.mvrx.MavericksBlockExecutions import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelConfig import com.airbnb.mvrx.MavericksViewModelConfigFactory import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.MavericksViewModelConfig import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -19,9 +20,9 @@ class MavericksViewModelConfigTest : BaseTest() { val originalBehavior = provider.mockBehavior val newBehavior = - MockBehavior(blockExecutions = MavericksViewModelConfig.BlockExecutions.Completely) + MockBehavior(blockExecutions = MavericksBlockExecutions.Completely) val newBehavior2 = - MockBehavior(blockExecutions = MavericksViewModelConfig.BlockExecutions.WithLoading) + MockBehavior(blockExecutions = MavericksBlockExecutions.WithLoading) val result = provider.withMockBehavior(newBehavior) { assertEquals(newBehavior, provider.mockBehavior) diff --git a/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/MockableMavericksStateStoreTest.kt b/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/MockableMavericksStateStoreTest.kt index 2f0d0f323..3894f0699 100644 --- a/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/MockableMavericksStateStoreTest.kt +++ b/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/MockableMavericksStateStoreTest.kt @@ -1,6 +1,6 @@ package com.airbnb.mvrx.mocking -import com.airbnb.mvrx.MavericksViewModelConfig +import com.airbnb.mvrx.MavericksBlockExecutions import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.mocking.MockBehavior.InitialStateMocking import com.airbnb.mvrx.mocking.MockBehavior.StateStoreBehavior @@ -187,7 +187,7 @@ class MockableMavericksStateStoreTest : BaseTest() { TestState(), MockBehavior( initialStateMocking = InitialStateMocking.None, - blockExecutions = MavericksViewModelConfig.BlockExecutions.No, + blockExecutions = MavericksBlockExecutions.No, stateStoreBehavior = storeBehavior ), coroutineScope = testCoroutineScope() diff --git a/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/ViewModelBlockExecutionTest.kt b/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/ViewModelBlockExecutionTest.kt index bc6c4af56..d4ef154d5 100644 --- a/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/ViewModelBlockExecutionTest.kt +++ b/mvrx-mocking/src/test/kotlin/com/airbnb/mvrx/mocking/ViewModelBlockExecutionTest.kt @@ -2,8 +2,8 @@ package com.airbnb.mvrx.mocking import com.airbnb.mvrx.Async import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MavericksBlockExecutions import com.airbnb.mvrx.MavericksViewModel -import com.airbnb.mvrx.MavericksViewModelConfig import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized import org.junit.Assert.assertEquals @@ -24,7 +24,7 @@ class ViewModelBlockExecutionTest : BaseTest() { @Test fun executeBlockedCompletely() { MockableMavericks.mockConfigFactory.mockBehavior = MockBehavior( - blockExecutions = MavericksViewModelConfig.BlockExecutions.Completely, + blockExecutions = MavericksBlockExecutions.Completely, stateStoreBehavior = MockBehavior.StateStoreBehavior.Synchronous ) @@ -36,7 +36,7 @@ class ViewModelBlockExecutionTest : BaseTest() { @Test fun executeBlockedWithLoading() { MockableMavericks.mockConfigFactory.mockBehavior = MockBehavior( - blockExecutions = MavericksViewModelConfig.BlockExecutions.WithLoading, + blockExecutions = MavericksBlockExecutions.WithLoading, stateStoreBehavior = MockBehavior.StateStoreBehavior.Synchronous ) diff --git a/mvrx-rxjava2/build.gradle b/mvrx-rxjava2/build.gradle index acc614669..1babe19ed 100644 --- a/mvrx-rxjava2/build.gradle +++ b/mvrx-rxjava2/build.gradle @@ -8,6 +8,7 @@ tasks.withType(KotlinCompile).all { kotlinOptions { freeCompilerArgs += [ '-Xopt-in=com.airbnb.mvrx.InternalMavericksApi', + '-Xopt-in=com.airbnb.mvrx.ExperimentalMavericksApi', ] } } diff --git a/mvrx-rxjava2/src/main/kotlin/com/airbnb/mvrx/BaseMvRxViewModel.kt b/mvrx-rxjava2/src/main/kotlin/com/airbnb/mvrx/BaseMvRxViewModel.kt index abe8039e0..971a711d5 100644 --- a/mvrx-rxjava2/src/main/kotlin/com/airbnb/mvrx/BaseMvRxViewModel.kt +++ b/mvrx-rxjava2/src/main/kotlin/com/airbnb/mvrx/BaseMvRxViewModel.kt @@ -115,8 +115,8 @@ abstract class BaseMvRxViewModel( stateReducer: S.(Async) -> S ): Disposable { val blockExecutions = config.onExecute(this@BaseMvRxViewModel) - if (blockExecutions != MavericksViewModelConfig.BlockExecutions.No) { - if (blockExecutions == MavericksViewModelConfig.BlockExecutions.WithLoading) { + if (blockExecutions != MavericksBlockExecutions.No) { + if (blockExecutions == MavericksBlockExecutions.WithLoading) { setState { stateReducer(Loading()) } } return Disposables.disposed() diff --git a/mvrx/build.gradle b/mvrx/build.gradle index 33b57a8e5..ceaed352e 100644 --- a/mvrx/build.gradle +++ b/mvrx/build.gradle @@ -10,6 +10,7 @@ tasks.withType(KotlinCompile).all { freeCompilerArgs += [ '-Xopt-in=kotlin.RequiresOptIn', '-Xopt-in=com.airbnb.mvrx.InternalMavericksApi', + '-Xopt-in=com.airbnb.mvrx.ExperimentalMavericksApi', ] } } @@ -35,6 +36,7 @@ android { } dependencies { + api project(':mvrx-core') api Libraries.kotlinCoroutines api Libraries.activity diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/Mavericks.kt b/mvrx/src/main/kotlin/com/airbnb/mvrx/Mavericks.kt index f65eb9b92..eb9e4f297 100644 --- a/mvrx/src/main/kotlin/com/airbnb/mvrx/Mavericks.kt +++ b/mvrx/src/main/kotlin/com/airbnb/mvrx/Mavericks.kt @@ -23,7 +23,7 @@ object Mavericks { var viewModelDelegateFactory: ViewModelDelegateFactory = DefaultViewModelDelegateFactory() /** - * A factory for creating a [MavericksViewModelConfig] for each ViewModel. + * A factory for creating a [MavericksRepositoryConfig] for each ViewModel. * * You MUST provide an instance here before creating any viewmodels. You can do this when * your application is created via the [initialize] helper. diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksTuples.kt b/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksTuples.kt deleted file mode 100644 index cf815d90b..000000000 --- a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksTuples.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.airbnb.mvrx - -internal data class MavericksTuple1(val a: A) -internal data class MavericksTuple2(val a: A, val b: B) -internal data class MavericksTuple3(val a: A, val b: B, val c: C) -internal data class MavericksTuple4(val a: A, val b: B, val c: C, val d: D) -internal data class MavericksTuple5(val a: A, val b: B, val c: C, val d: D, val e: E) -internal data class MavericksTuple6(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F) -internal data class MavericksTuple7( - val a: A, - val b: B, - val c: C, - val d: D, - val e: E, - val f: F, - val g: G -) diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModel.kt b/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModel.kt index 318ab9db3..dabbd6a84 100644 --- a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModel.kt +++ b/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModel.kt @@ -2,26 +2,15 @@ package com.airbnb.mvrx import androidx.annotation.CallSuper import androidx.lifecycle.LifecycleOwner -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.yield import java.util.Collections import java.util.concurrent.ConcurrentHashMap -import kotlin.coroutines.EmptyCoroutineContext import kotlin.reflect.KProperty1 /** @@ -34,9 +23,9 @@ import kotlin.reflect.KProperty1 * Other classes can observe the state via [stateFlow]. */ abstract class MavericksViewModel( - initialState: S + initialState: S, + configFactory: MavericksViewModelConfigFactory = Mavericks.viewModelConfigFactory, ) { - // Use the same factory for the life of the viewmodel, as it might change after this viewmodel is created (especially during tests) @PublishedApi internal val configFactory = Mavericks.viewModelConfigFactory @@ -50,19 +39,16 @@ abstract class MavericksViewModel( val viewModelScope = config.coroutineScope - private val stateStore = config.stateStore + private val repository = Repository() private val lastDeliveredStates = ConcurrentHashMap() private val activeSubscriptions = Collections.newSetFromMap(ConcurrentHashMap()) - private val tag by lazy { javaClass.simpleName } - private val mutableStateChecker = if (config.debugMode) MutableStateChecker(initialState) else null - /** * Synchronous access to state is not exposed externally because there is no guarantee that * all setState reducers have run yet. */ internal val state: S - get() = stateStore.state + get() = repository.state /** * Return the current state as a Flow. For certain situations, this may be more convenient @@ -75,7 +61,7 @@ abstract class MavericksViewModel( * be used as it is guaranteed to be run after all pending setState reducers have run. */ val stateFlow: Flow - get() = stateStore.flow + get() = repository.stateFlow init { if (config.debugMode) { @@ -95,7 +81,6 @@ abstract class MavericksViewModel( * a fair amount of reflection. */ private fun validateState(initialState: S) { - assertMavericksDataClassImmutability(state::class) // Assert that state can be saved and restored. val bundle = persistMavericksState(state = state, validation = true) restorePersistedMavericksState(bundle, initialState, validation = true) @@ -111,46 +96,7 @@ abstract class MavericksViewModel( * mutable variables or properties from outside the lambda or else it may crash. */ protected fun setState(reducer: S.() -> S) { - if (config.debugMode) { - // Must use `set` to ensure the validated state is the same as the actual state used in reducer - // Do not use `get` since `getState` queue has lower priority and the validated state would be the state after reduced - stateStore.set { - val firstState = this.reducer() - val secondState = this.reducer() - - if (firstState != secondState) { - @Suppress("UNCHECKED_CAST") - val changedProp = firstState::class.java.declaredFields.asSequence() - .onEach { it.isAccessible = true } - .firstOrNull { property -> - @Suppress("Detekt.TooGenericExceptionCaught") - try { - property.get(firstState) != property.get(secondState) - } catch (e: Throwable) { - false - } - } - if (changedProp != null) { - throw IllegalArgumentException( - "Impure reducer set on ${this@MavericksViewModel::class.java.simpleName}! " + - "${changedProp.name} changed from ${changedProp.get(firstState)} " + - "to ${changedProp.get(secondState)}. " + - "Ensure that your state properties properly implement hashCode." - ) - } else { - throw IllegalArgumentException( - "Impure reducer set on ${this@MavericksViewModel::class.java.simpleName}! Differing states were provided by the same reducer." + - "Ensure that your state properties properly implement hashCode. First state: $firstState -> Second state: $secondState" - ) - } - } - mutableStateChecker?.onStateChanged(firstState) - - firstState - } - } else { - stateStore.set(reducer) - } + repository.setStateInternal(reducer) } /** @@ -158,9 +104,7 @@ abstract class MavericksViewModel( * As a result, it is safe to call setState { } and assume that the result from a subsequent awaitState() call will have that state. */ suspend fun awaitState(): S { - val deferredState = CompletableDeferred() - withState(deferredState::complete) - return deferredState.await() + return repository.awaitState() } /** @@ -168,7 +112,7 @@ abstract class MavericksViewModel( * updates are processed. */ protected fun withState(action: (state: S) -> Unit) { - stateStore.get(action) + repository.withStateInternal(action) } /** @@ -204,27 +148,8 @@ abstract class MavericksViewModel( retainValue: KProperty1>? = null, reducer: S.(Async) -> S ): Job { - val blockExecutions = config.onExecute(this@MavericksViewModel) - if (blockExecutions != MavericksViewModelConfig.BlockExecutions.No) { - if (blockExecutions == MavericksViewModelConfig.BlockExecutions.WithLoading) { - setState { reducer(Loading()) } - } - // Simulate infinite loading - return viewModelScope.launch { delay(Long.MAX_VALUE) } - } - - setState { reducer(Loading(value = retainValue?.get(this)?.invoke())) } - - return viewModelScope.launch(dispatcher ?: EmptyCoroutineContext) { - try { - val result = invoke() - setState { reducer(Success(result)) } - } catch (e: CancellationException) { - @Suppress("RethrowCaughtException") - throw e - } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { - setState { reducer(Fail(e, value = retainValue?.get(this)?.invoke())) } - } + return with(repository) { + executeInternal(dispatcher, retainValue, reducer) } } @@ -244,20 +169,9 @@ abstract class MavericksViewModel( retainValue: KProperty1>? = null, reducer: S.(Async) -> S ): Job { - val blockExecutions = config.onExecute(this@MavericksViewModel) - if (blockExecutions != MavericksViewModelConfig.BlockExecutions.No) { - if (blockExecutions == MavericksViewModelConfig.BlockExecutions.WithLoading) { - setState { reducer(Loading(value = retainValue?.get(this)?.invoke())) } - } - // Simulate infinite loading - return viewModelScope.launch { delay(Long.MAX_VALUE) } + return with(repository) { + executeInternal(dispatcher, retainValue, reducer) } - - setState { reducer(Loading(value = retainValue?.get(this)?.invoke())) } - - return catch { error -> setState { reducer(Fail(error, value = retainValue?.get(this)?.invoke())) } } - .onEach { value -> setState { reducer(Success(value)) } } - .launchIn(viewModelScope + (dispatcher ?: EmptyCoroutineContext)) } /** @@ -272,15 +186,9 @@ abstract class MavericksViewModel( dispatcher: CoroutineDispatcher? = null, reducer: S.(T) -> S ): Job { - val blockExecutions = config.onExecute(this@MavericksViewModel) - if (blockExecutions != MavericksViewModelConfig.BlockExecutions.No) { - // Simulate infinite work - return viewModelScope.launch { delay(Long.MAX_VALUE) } + return with(repository) { + setOnEachInternal(dispatcher, reducer) } - - return onEach { - setState { reducer(it) } - }.launchIn(viewModelScope + (dispatcher ?: EmptyCoroutineContext)) } /** @@ -291,7 +199,7 @@ abstract class MavericksViewModel( */ protected fun onEach( action: suspend (S) -> Unit - ) = _internal(null, RedeliverOnStart, action) + ) = repository._internal(action) /** * Subscribe to state changes for a single property. @@ -302,7 +210,7 @@ abstract class MavericksViewModel( protected fun onEach( prop1: KProperty1, action: suspend (A) -> Unit - ) = _internal1(null, prop1, action = action) + ) = repository._internal1(prop1, action = action) /** * Subscribe to state changes for two properties. @@ -314,7 +222,7 @@ abstract class MavericksViewModel( prop1: KProperty1, prop2: KProperty1, action: suspend (A, B) -> Unit - ) = _internal2(null, prop1, prop2, action = action) + ) = repository._internal2(prop1, prop2, action = action) /** * Subscribe to state changes for three properties. @@ -327,7 +235,7 @@ abstract class MavericksViewModel( prop2: KProperty1, prop3: KProperty1, action: suspend (A, B, C) -> Unit - ) = _internal3(null, prop1, prop2, prop3, action = action) + ) = repository._internal3(prop1, prop2, prop3, action = action) /** * Subscribe to state changes for four properties. @@ -341,7 +249,7 @@ abstract class MavericksViewModel( prop3: KProperty1, prop4: KProperty1, action: suspend (A, B, C, D) -> Unit - ) = _internal4(null, prop1, prop2, prop3, prop4, action = action) + ) = repository._internal4(prop1, prop2, prop3, prop4, action = action) /** * Subscribe to state changes for five properties. @@ -356,7 +264,7 @@ abstract class MavericksViewModel( prop4: KProperty1, prop5: KProperty1, action: suspend (A, B, C, D, E) -> Unit - ) = _internal5(null, prop1, prop2, prop3, prop4, prop5, action = action) + ) = repository._internal5(prop1, prop2, prop3, prop4, prop5, action = action) /** * Subscribe to state changes for six properties. @@ -372,7 +280,7 @@ abstract class MavericksViewModel( prop5: KProperty1, prop6: KProperty1, action: suspend (A, B, C, D, E, F) -> Unit - ) = _internal6(null, prop1, prop2, prop3, prop4, prop5, prop6, action = action) + ) = repository._internal6(prop1, prop2, prop3, prop4, prop5, prop6, action = action) /** * Subscribe to state changes for seven properties. @@ -389,7 +297,7 @@ abstract class MavericksViewModel( prop6: KProperty1, prop7: KProperty1, action: suspend (A, B, C, D, E, F, G) -> Unit - ) = _internal7(null, prop1, prop2, prop3, prop4, prop5, prop6, prop7, action = action) + ) = repository._internal7(prop1, prop2, prop3, prop4, prop5, prop6, prop7, action = action) /** * Subscribe to changes in an async property. There are optional parameters for onSuccess @@ -404,7 +312,7 @@ abstract class MavericksViewModel( asyncProp: KProperty1>, onFail: (suspend (Throwable) -> Unit)? = null, onSuccess: (suspend (T) -> Unit)? = null - ) = _internalSF(null, asyncProp, RedeliverOnStart, onFail, onSuccess) + ) = repository._internalSF(asyncProp, onFail, onSuccess) @Suppress("EXPERIMENTAL_API_USAGE") internal fun Flow.resolveSubscription( @@ -415,21 +323,52 @@ abstract class MavericksViewModel( return if (lifecycleOwner != null) { collectLatest(lifecycleOwner, lastDeliveredStates, activeSubscriptions, deliveryMode, action) } else { - (viewModelScope + configFactory.subscriptionCoroutineContextOverride).launch(start = CoroutineStart.UNDISPATCHED) { - // Use yield to ensure flow collect coroutine is dispatched rather than invoked immediately. - // This is necessary when Dispatchers.Main.immediate is used in scope. - // Coroutine is launched with start = CoroutineStart.UNDISPATCHED to perform dispatch only once. - yield() - collectLatest(action) + with(repository) { + resolveSubscription(action) } } } - private fun assertSubscribeToDifferentViewModel(viewModel: MavericksViewModel) { - require(this != viewModel) { - "This method is for subscribing to other view models. Please pass a different instance as the argument." + override fun toString(): String = "${this::class.java.simpleName} $state" + + private inner class Repository : MavericksRepository( + MavericksRepositoryConfig( + performCorrectnessValidations = config.debugMode, + stateStore = config.stateStore, + coroutineScope = config.coroutineScope, + subscriptionCoroutineContextOverride = config.subscriptionCoroutineContextOverride, + onExecute = { config.onExecute(this@MavericksViewModel) }, + ) + ) { + fun setStateInternal(reducer: S.() -> S) { + setState(reducer) } - } - override fun toString(): String = "${this::class.java.simpleName} $state" + fun withStateInternal(action: (state: S) -> Unit) { + withState(action) + } + + fun (suspend () -> T).executeInternal( + dispatcher: CoroutineDispatcher? = null, + retainValue: KProperty1>? = null, + reducer: S.(Async) -> S + ): Job { + return execute(dispatcher, retainValue, reducer) + } + + fun Flow.executeInternal( + dispatcher: CoroutineDispatcher? = null, + retainValue: KProperty1>? = null, + reducer: S.(Async) -> S + ): Job { + return execute(dispatcher, retainValue, reducer) + } + + fun Flow.setOnEachInternal( + dispatcher: CoroutineDispatcher? = null, + reducer: S.(T) -> S + ): Job { + return setOnEach(dispatcher, reducer) + } + } } diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModelConfig.kt b/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModelConfig.kt index 2c1b80c82..a91e835c9 100644 --- a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModelConfig.kt +++ b/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModelConfig.kt @@ -1,6 +1,8 @@ package com.airbnb.mvrx +import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.CoroutineContext /** * Provides configuration for a [MavericksViewModel]. @@ -18,13 +20,20 @@ abstract class MavericksViewModelConfig( /** * The coroutine scope that will be provided to the view model. */ - val coroutineScope: CoroutineScope + val coroutineScope: CoroutineScope, + /** + * Provide a context that will be added to the coroutine scope when a subscription is registered (eg [MavericksView.onEach]). + * + * By default subscriptions use [MavericksView.subscriptionLifecycleOwner] and [LifecycleOwner.lifecycleScope] to + * retrieve a coroutine scope to launch the subscription in. + */ + val subscriptionCoroutineContextOverride: CoroutineContext ) { /** - * Called each time a [MavericksViewModel.execute] function is invoked. This allows - * the execute function to be skipped, based on the returned [BlockExecutions] value. + * Called each time a [MavericksRepository.execute] function is invoked. This allows + * the execute function to be skipped, based on the returned [MavericksBlockExecutions] value. * - * This is intended to be used to allow the ViewModel to be mocked out for testing. + * This is intended to be used to allow the [MavericksRepository] to be mocked out for testing. * Blocking calls to execute prevents long running asynchronous operations from changing the * state later on when the calls complete. * @@ -36,25 +45,5 @@ abstract class MavericksViewModelConfig( * is "enabled", even if the execute was performed when the state store was "disabled" and we * didn't intend to allow operations to change the state. */ - abstract fun onExecute( - viewModel: MavericksViewModel - ): BlockExecutions - - /** - * Defines whether a [MavericksViewModel.execute] invocation should not be run. - */ - enum class BlockExecutions { - /** Run the execute block normally. */ - No, - - /** Block the execute call from having an impact. */ - Completely, - - /** - * Block the execute call from having an impact from values returned by the object - * being executed, but perform one state callback to set the Async property to loading - * as if the call is actually happening. - */ - WithLoading - } + abstract fun onExecute(viewModel: MavericksViewModel): MavericksBlockExecutions } diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModelConfigFactory.kt b/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModelConfigFactory.kt index 19cbbde6c..4da91eb8f 100644 --- a/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModelConfigFactory.kt +++ b/mvrx/src/main/kotlin/com/airbnb/mvrx/MavericksViewModelConfigFactory.kt @@ -78,9 +78,14 @@ open class MavericksViewModelConfigFactory( initialState: S ): MavericksViewModelConfig { val scope = coroutineScope() - return object : MavericksViewModelConfig(debugMode, CoroutinesStateStore(initialState, scope, storeContextOverride), scope) { - override fun onExecute(viewModel: MavericksViewModel): BlockExecutions { - return BlockExecutions.No + return object : MavericksViewModelConfig( + debugMode = debugMode, + stateStore = CoroutinesStateStore(initialState, scope, storeContextOverride), + coroutineScope = scope, + subscriptionCoroutineContextOverride = subscriptionCoroutineContextOverride + ) { + override fun onExecute(viewModel: MavericksViewModel): MavericksBlockExecutions { + return MavericksBlockExecutions.No } } } diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/StateContainer.kt b/mvrx/src/main/kotlin/com/airbnb/mvrx/StateContainer.kt deleted file mode 100644 index b05aa3dfe..000000000 --- a/mvrx/src/main/kotlin/com/airbnb/mvrx/StateContainer.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.airbnb.mvrx - -/** - * Accesses ViewModel state from a single ViewModel synchronously and returns the result of the block. - */ -fun , B : MavericksState, C> withState(viewModel1: A, block: (B) -> C) = block(viewModel1.state) - -/** - * Accesses ViewModel state from two ViewModels synchronously and returns the result of the block. - */ -fun , B : MavericksState, C : MavericksViewModel, D : MavericksState, E> withState( - viewModel1: A, - viewModel2: C, - block: (B, D) -> E -) = block(viewModel1.state, viewModel2.state) - -/** - * Accesses ViewModel state from three ViewModels synchronously and returns the result of the block. - */ -fun , B : MavericksState, C : MavericksViewModel, D : MavericksState, E : MavericksViewModel, F : MavericksState, G> withState( - viewModel1: A, - viewModel2: C, - viewModel3: E, - block: (B, D, F) -> G -) = block(viewModel1.state, viewModel2.state, viewModel3.state) - -/** - * Accesses ViewModel state from four ViewModels synchronously and returns the result of the block. - */ -fun < - A : MavericksViewModel, B : MavericksState, - C : MavericksViewModel, D : MavericksState, - E : MavericksViewModel, F : MavericksState, - G : MavericksViewModel, H : MavericksState, - I - > withState(viewModel1: A, viewModel2: C, viewModel3: E, viewModel4: G, block: (B, D, F, H) -> I) = - block(viewModel1.state, viewModel2.state, viewModel3.state, viewModel4.state) - -/** - * Accesses ViewModel state from five ViewModels synchronously and returns the result of the block. - */ -fun < - A : MavericksViewModel, B : MavericksState, - C : MavericksViewModel, D : MavericksState, - E : MavericksViewModel, F : MavericksState, - G : MavericksViewModel, H : MavericksState, - I : MavericksViewModel, J : MavericksState, - K - > withState(viewModel1: A, viewModel2: C, viewModel3: E, viewModel4: G, viewModel5: I, block: (B, D, F, H, J) -> K) = - block(viewModel1.state, viewModel2.state, viewModel3.state, viewModel4.state, viewModel5.state) diff --git a/mvrx/src/main/kotlin/com/airbnb/mvrx/ViewModelStateContainer.kt b/mvrx/src/main/kotlin/com/airbnb/mvrx/ViewModelStateContainer.kt new file mode 100644 index 000000000..993ae0489 --- /dev/null +++ b/mvrx/src/main/kotlin/com/airbnb/mvrx/ViewModelStateContainer.kt @@ -0,0 +1,57 @@ +package com.airbnb.mvrx + +/** + * Accesses repository state from a single repository synchronously and returns the result of the block. + */ +fun , B : MavericksState, C> withState(repository1: A, block: (B) -> C) = block(repository1.state) + +/** + * Accesses repository state from two repositories synchronously and returns the result of the block. + */ +fun , B : MavericksState, C : MavericksViewModel, D : MavericksState, E> withState( + repository1: A, + repository2: C, + block: (B, D) -> E +) = block(repository1.state, repository2.state) + +/** + * Accesses repository state from three repositories synchronously and returns the result of the block. + */ +fun , B : MavericksState, C : MavericksViewModel, D : MavericksState, E : MavericksViewModel, F : MavericksState, G> withState( + repository1: A, + repository2: C, + repository3: E, + block: (B, D, F) -> G +) = block(repository1.state, repository2.state, repository3.state) + +/** + * Accesses repository state from four repositories synchronously and returns the result of the block. + */ +fun < + A : MavericksViewModel, B : MavericksState, + C : MavericksViewModel, D : MavericksState, + E : MavericksViewModel, F : MavericksState, + G : MavericksViewModel, H : MavericksState, + I + > withState(repository1: A, repository2: C, repository3: E, repository4: G, block: (B, D, F, H) -> I) = + block(repository1.state, repository2.state, repository3.state, repository4.state) + +/** + * Accesses repository state from five repositories synchronously and returns the result of the block. + */ +fun < + A : MavericksViewModel, B : MavericksState, + C : MavericksViewModel, D : MavericksState, + E : MavericksViewModel, F : MavericksState, + G : MavericksViewModel, H : MavericksState, + I : MavericksViewModel, J : MavericksState, + K + > withState( + repository1: A, + repository2: C, + repository3: E, + repository4: G, + repository5: I, + block: (B, D, F, H, J) -> K +) = + block(repository1.state, repository2.state, repository3.state, repository4.state, repository5.state) diff --git a/mvrx/src/test/kotlin/com/airbnb/mvrx/MvRxMutabilityHelperKtTest.kt b/mvrx/src/test/kotlin/com/airbnb/mvrx/MvRxMutabilityHelperKtTest.kt deleted file mode 100644 index 67636d9c8..000000000 --- a/mvrx/src/test/kotlin/com/airbnb/mvrx/MvRxMutabilityHelperKtTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.airbnb.mvrx - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class MvRxMutabilityHelperKtTest { - - @Test - fun isData() { - assertTrue(TestDataClass::class.java.isData) - assertFalse(String::class.java.isData) - } - - data class TestDataClass( - internal val foo: Int - ) -} diff --git a/mvrx/src/test/kotlin/com/airbnb/mvrx/StateImmutabilityTest.kt b/mvrx/src/test/kotlin/com/airbnb/mvrx/StateImmutabilityTest.kt index b75ea706e..1954a11ff 100644 --- a/mvrx/src/test/kotlin/com/airbnb/mvrx/StateImmutabilityTest.kt +++ b/mvrx/src/test/kotlin/com/airbnb/mvrx/StateImmutabilityTest.kt @@ -9,72 +9,6 @@ import org.junit.Test class StateImmutabilityTest : BaseTest() { - @Test() - fun valProp() { - data class State(val foo: Int = 5) - assertMavericksDataClassImmutability(State::class) - } - - @Test() - fun immutableMap() { - data class State(val foo: Map = mapOf("a" to 0)) - assertMavericksDataClassImmutability(State::class) - } - - @Test() - fun immutableList() { - data class State(val foo: List = listOf(1, 2, 3)) - assertMavericksDataClassImmutability(State::class) - } - - @Test(expected = IllegalArgumentException::class) - fun nonDataState() { - class State - assertMavericksDataClassImmutability(State::class) - } - - @Test(expected = IllegalArgumentException::class) - fun nonDataStateWithComponent1() { - class State { - operator fun component1() = 5 - } - assertMavericksDataClassImmutability(State::class) - } - - @Test(expected = IllegalArgumentException::class) - fun nonDataStateWithHashCode() { - class State { - override fun hashCode() = 123 - } - assertMavericksDataClassImmutability(State::class) - } - - @Test(expected = IllegalArgumentException::class) - fun nonDataStateWithEquals() { - class State { - override fun equals(other: Any?) = false - } - assertMavericksDataClassImmutability(State::class) - } - - @Test(expected = IllegalArgumentException::class) - fun varState() { - data class State(var foo: Int = 5) - assertMavericksDataClassImmutability(State::class) - } - - @Test(expected = IllegalArgumentException::class) - fun mutableList() { - data class State(val list: ArrayList = ArrayList()) - assertMavericksDataClassImmutability(State::class) - } - - @Test(expected = IllegalArgumentException::class) - fun mutableMap() { - data class State(val map: HashMap = HashMap()) - assertMavericksDataClassImmutability(State::class) - } - @Test(expected = IllegalArgumentException::class) fun arrayMap() { data class State(val map: ArrayMap = ArrayMap()) @@ -92,16 +26,4 @@ class StateImmutabilityTest : BaseTest() { data class State(val map: SparseArrayCompat = SparseArrayCompat()) assertMavericksDataClassImmutability(State::class) } - - @Test(expected = IllegalArgumentException::class) - fun lambda() { - data class State(val func: () -> Unit = {}) - assertMavericksDataClassImmutability(State::class) - } - - @Test - fun lambdaAllowed() { - data class State(val func: () -> Unit = {}) - assertMavericksDataClassImmutability(State::class, allowFunctions = true) - } } diff --git a/settings.gradle b/settings.gradle index 3e531b1c5..9dc70aed3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,6 @@ // Libraries include ':mvrx' +include ':mvrx-core' include ':mvrx-compose' include ':mvrx-mocking' include ':mvrx-navigation'