diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index e776c40da..0f1d77c1a 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,11 +1,6 @@ - - diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 000000000..b1077fbd0 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/docs/rxjava3.md b/docs/rxjava3.md new file mode 100644 index 000000000..f23a37d3b --- /dev/null +++ b/docs/rxjava3.md @@ -0,0 +1,11 @@ +# Mavericks + RxJava3 + +[//]: # (TODO refine the documentation) + +MvRx 1.x was designed to work well with RxJava 2. + +However, Mavericks now uses coroutines internally. + +RxJava 3 compatibility is available via the `mavericks-rxjava3` artifact. + +To use it, use `BaseMvRxViewModel` instead of `MavericksViewModel`. Doing so will give you access to `execute` extensions on `Single` and `Observable` as well as all other MvRx 1.x APIs. diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index 73fb8ca26..2cb4d3339 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -1,5 +1,5 @@ // Mostly from https://nolambda.stream/posts/jacoco-setup-for-multi-module-project/ -def coveredProjectNames = ["mvrx", "mvrx-compose", "mvrx-mocking", "mvrx-navigation", "mvrx-hilt", "mvrx-rxjava2"] +def coveredProjectNames = ["mvrx", "mvrx-compose", "mvrx-mocking", "mvrx-navigation", "mvrx-hilt", "mvrx-rxjava2", "mvrx-rxjava3"] def coveredProjects = subprojects.findAll( project -> { coveredProjectNames.contains(project.name) }) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e58e39a1..075084973 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ activity = "androidx.activity:activity:_" activityCompose = "androidx.activity:activity-compose:_" appcompat = "androidx.appcompat:appcompat:_" cardview = "androidx.cardview:cardview:_" +material = "com.google.android.material:material:_" constraintlayout = "androidx.constraintlayout:constraintlayout:_" coordinatorLayout = "androidx.coordinatorlayout:coordinatorlayout:_" coreKtx = "androidx.core:core-ktx:_" @@ -59,12 +60,17 @@ picasso = "com.squareup.picasso:picasso:_" recyclerview = "androidx.recyclerview:recyclerview:_" retrofit = "com.squareup.retrofit2:retrofit:_" retrofitMoshi = "com.squareup.retrofit2:converter-moshi:_" -retrofitRxJava = "com.squareup.retrofit2:adapter-rxjava2:_" +retrofitRxJava2 = "com.squareup.retrofit2:adapter-rxjava2:_" +retrofitRxJava3 = "com.squareup.retrofit2:adapter-rxjava3:_" +coil = "io.coil-kt:coil:_" + roomRuntime = "androidx.room:room-runtime:_" roomRxJava = "androidx.room:room-rxjava2:_" runtimeKtx = "androidx.lifecycle:lifecycle-runtime-ktx:_" -rxAndroid = "io.reactivex.rxjava2:rxandroid:_" -rxJava = "io.reactivex.rxjava2:rxjava:_" +rxJava2 = "io.reactivex.rxjava2:rxjava:_" +rxAndroid2 = "io.reactivex.rxjava2:rxandroid:_" +rxJava3 = "io.reactivex.rxjava3:rxjava:_" +rxAndroid3 = "io.reactivex.rxjava3:rxandroid:_" viewModelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:_" viewModelCompose = "androidx.lifecycle:lifecycle-viewmodel-compose:_" viewModelSavedState = "androidx.lifecycle:lifecycle-viewmodel-savedstate:_" diff --git a/mvrx-rxjava2/build.gradle b/mvrx-rxjava2/build.gradle index c3994feb7..0fb16ef70 100644 --- a/mvrx-rxjava2/build.gradle +++ b/mvrx-rxjava2/build.gradle @@ -32,7 +32,7 @@ android { dependencies { api project(':mvrx') - api libs.rxJava + api libs.rxJava2 implementation libs.fragment implementation libs.lifecycleCommon diff --git a/mvrx-rxjava3/.gitignore b/mvrx-rxjava3/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/mvrx-rxjava3/.gitignore @@ -0,0 +1 @@ +/build diff --git a/mvrx-rxjava3/build.gradle b/mvrx-rxjava3/build.gradle new file mode 100644 index 000000000..afe1c625d --- /dev/null +++ b/mvrx-rxjava3/build.gradle @@ -0,0 +1,47 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" +apply plugin: "com.vanniktech.maven.publish" + +tasks.withType(KotlinCompile).all { + kotlinOptions { + freeCompilerArgs += [ + '-opt-in=com.airbnb.mvrx.InternalMavericksApi', + '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', + '-opt-in=com.airbnb.mvrx.ExperimentalMavericksApi', + ] + } +} + +android { + resourcePrefix "mvrx_" + + testOptions { + unitTests { + includeAndroidResources = true + all { + jacoco { + includeNoLocationClasses = true + jacoco.excludes = ['jdk.internal.*'] + } + } + } + } +} + +dependencies { + api project(':mvrx') + api libs.rxJava3 + + implementation libs.fragment + implementation libs.lifecycleCommon + implementation libs.runtimeKtx + + testImplementation project(':mvrx-testing') + testImplementation libs.junit + testImplementation libs.appcompat + testImplementation libs.kotlinCoroutinesTest + testImplementation libs.mockito + testImplementation libs.roboeletric +} diff --git a/mvrx-rxjava3/gradle.properties b/mvrx-rxjava3/gradle.properties new file mode 100644 index 000000000..bff9fe266 --- /dev/null +++ b/mvrx-rxjava3/gradle.properties @@ -0,0 +1,4 @@ +POM_NAME=Mavericks +POM_ARTIFACT_ID=mavericks-rxjava2 +POM_PACKAGING=aar +GROUP=com.airbnb.android \ No newline at end of file diff --git a/mvrx-rxjava3/src/main/AndroidManifest.xml b/mvrx-rxjava3/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7c6f51980 --- /dev/null +++ b/mvrx-rxjava3/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/BaseMvRxFragment.kt b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/BaseMvRxFragment.kt new file mode 100644 index 000000000..6f03fc5d6 --- /dev/null +++ b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/BaseMvRxFragment.kt @@ -0,0 +1,12 @@ +package com.airbnb.mvrx + +import androidx.annotation.LayoutRes +import androidx.fragment.app.Fragment + +/** + * Make your base Fragment class extend this to get MvRx functionality. + * + * This is necessary for the view model delegates and persistence to work correctly. + */ +@Deprecated("You no longer need a base MvRxFragment. All you need to do is make your Fragment implement MavericksView.") +abstract class BaseMvRxFragment(@LayoutRes contentLayoutId: Int = 0) : Fragment(contentLayoutId), MvRxView diff --git a/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/BaseMvRxViewModel.kt b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/BaseMvRxViewModel.kt new file mode 100644 index 000000000..0b554cbc6 --- /dev/null +++ b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/BaseMvRxViewModel.kt @@ -0,0 +1,464 @@ +package com.airbnb.mvrx + +import android.annotation.SuppressLint +import android.util.Log +import androidx.annotation.RestrictTo +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModel +import com.airbnb.mvrx.rxjava3.MvRxTuple1 +import com.airbnb.mvrx.rxjava3.MvRxTuple2 +import com.airbnb.mvrx.rxjava3.MvRxTuple3 +import com.airbnb.mvrx.rxjava3.MvRxTuple4 +import com.airbnb.mvrx.rxjava3.MvRxTuple5 +import com.airbnb.mvrx.rxjava3.MvRxTuple6 +import com.airbnb.mvrx.rxjava3.MvRxTuple7 +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlin.reflect.KProperty1 + +/** + * Base ViewModel implementation that all other ViewModels should extend. + */ +abstract class BaseMvRxViewModel( + initialState: S +) : MavericksViewModel(initialState) { + private val tag by lazy { javaClass.simpleName } + private val disposables = CompositeDisposable() + + /** + * Define a [LifecycleOwner] to control subscriptions between [BaseMvRxViewModel]s. This only + * provides two states, [Lifecycle.State.RESUMED] and [Lifecycle.State.DESTROYED] as it follows + * the [ViewModel] object lifecycle. That is, when instantiated the lifecycle will be + * [Lifecycle.State.RESUMED] and when [ViewModel.onCleared] is called the lifecycle will be + * [Lifecycle.State.DESTROYED]. + * + * This is not publicly accessible as it should only be used to control subscriptions + * between two view models. + */ + private val lifecycleOwner: LifecycleOwner = LifecycleOwner { lifecycleRegistry } + + /** + * Since lifecycle 2.3.0, it enforces calls from the main thread. Mavericks owns this registry so it can enforce that to be the case. + * Without this, it is impossible to use MvRxViewModels in tests without robolectric which isn't ideal. + */ + @SuppressLint("VisibleForTests") + private val lifecycleRegistry = LifecycleRegistry.createUnsafe(lifecycleOwner).apply { currentState = Lifecycle.State.RESUMED } + + override fun onCleared() { + super.onCleared() + disposables.dispose() + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + } + + /** + * Helper to map a [Single] to an [Async] property on the state object. + */ + fun Single.execute( + stateReducer: S.(Async) -> S + ) = toObservable().execute({ it }, null, stateReducer) + + /** + * Helper to map a [Single] to an [Async] property on the state object. + * @param mapper A map converting the Single type to the desired Async type. + * @param stateReducer A reducer that is applied to the current state and should return the + * new state. Because the state is the receiver and it likely a data + * class, an implementation may look like: `{ copy(response = it) }`. + */ + fun Single.execute( + mapper: (T) -> V, + stateReducer: S.(Async) -> S + ) = toObservable().execute(mapper, null, stateReducer) + + /** + * Helper to map an [Observable] to an [Async] property on the state object. + */ + fun Observable.execute( + stateReducer: S.(Async) -> S + ) = execute({ it }, null, stateReducer) + + /** + * Helper to map a [Completable] to an [Async] property on the state object. + */ + fun Completable.execute( + stateReducer: S.(Async) -> S + ) = toSingle { Unit }.execute(stateReducer) + + /** + * Execute an [Observable] and wrap its progression with [Async] property reduced to the global state. + * + * @param mapper A map converting the Observable type to the desired Async type. + * @param successMetaData A map that provides metadata to set on the Success result. + * It allows data about the original Observable to be kept and accessed later. For example, + * your mapper could map a network request to just the data your UI needs, but your base layers could + * keep metadata about the request, like timing, for logging. + * @param stateReducer A reducer that is applied to the current state and should return the + * new state. Because the state is the receiver and it likely a data + * class, an implementation may look like: `{ copy(response = it) }`. + * + * @see Success.metadata + */ + fun Observable.execute( + mapper: (T) -> V, + successMetaData: ((T) -> Any)? = null, + stateReducer: S.(Async) -> S + ): Disposable { + val blockExecutions = config.onExecute(this@BaseMvRxViewModel) + if (blockExecutions != MavericksBlockExecutions.No) { + if (blockExecutions == MavericksBlockExecutions.WithLoading) { + setState { stateReducer(Loading()) } + } + return Disposable.disposed() + } + + // Intentionally didn't use RxJava's startWith operator. When withState is called right after execute then the loading reducer won't be enqueued yet if startWith is used. + setState { stateReducer(Loading()) } + + return map> { value -> + val success = Success(mapper(value)) + success.metadata = successMetaData?.invoke(value) + success + } + .onErrorReturn { e -> + Fail(e) + } + .subscribe { asyncData -> setState { stateReducer(asyncData) } } + .disposeOnClear() + } + + /** + * Output all state changes to logcat. + */ + fun logStateChanges() { + if (!config.debugMode) return + subscribe { Log.d(tag, "New State: $it") } + } + + /** + * For ViewModels that want to subscribe to itself. + */ + protected fun subscribe(subscriber: (S) -> Unit): Disposable = _internal(null, action = { subscriber(it) }).toDisposable() + + /** + * Subscribe to state when this LifecycleOwner is started. + */ + @RestrictTo(RestrictTo.Scope.LIBRARY) + fun subscribe( + owner: LifecycleOwner, + deliveryMode: DeliveryMode = RedeliverOnStart, + subscriber: (S) -> Unit + ): Disposable { + return _internal(owner, deliveryMode) { subscriber(it) }.toDisposable() + } + + /** + * For ViewModels that want to subscribe to another ViewModel. + */ + protected fun subscribe( + viewModel: BaseMvRxViewModel, + subscriber: (S) -> Unit + ) { + assertSubscribeToDifferentViewModel(viewModel) + viewModel.stateFlow + .onEach { subscriber(it) } + .launchIn(viewModelScope) + .cancelOnClear(viewModel.viewModelScope) + } + + /** + * Subscribe to state changes for only a single property. + */ + protected fun selectSubscribe( + prop1: KProperty1, + subscriber: (A) -> Unit + ) = _internal1(null, prop1, action = { subscriber(it) }).toDisposable() + + /** + * Subscribe to state changes for only a single property in a different ViewModel. + */ + protected fun selectSubscribe( + viewModel: BaseMvRxViewModel, + prop1: KProperty1, + subscriber: (A) -> Unit + ) { + assertSubscribeToDifferentViewModel(viewModel) + viewModel.stateFlow + .map { MvRxTuple1(prop1.get(it)) } + .distinctUntilChanged() + .onEach { (a) -> subscriber(a) } + .launchIn(viewModelScope) + .cancelOnClear(viewModel.viewModelScope) + } + + private fun selectSubscribeInternal( + owner: LifecycleOwner?, + prop1: KProperty1, + deliveryMode: DeliveryMode, + subscriber: (A) -> Unit + ) = _internal1(owner, prop1, deliveryMode) { subscriber(it) }.toDisposable() + + /** + * Subscribe to changes in an async property. There are optional parameters for onSuccess + * and onFail which automatically unwrap the value or error. + */ + protected fun asyncSubscribe( + asyncProp: KProperty1>, + onFail: ((Throwable) -> Unit)? = null, + onSuccess: ((T) -> Unit)? = null + ) = _internalSF(null, asyncProp, onFail = { onFail?.invoke(it) }, onSuccess = { onSuccess?.invoke(it) }).toDisposable() + + /** + * Subscribe to changes in an async property in a different ViewModel. There are optional parameters + * for onSuccess and onFail which automatically unwrap the value or error. + */ + protected fun asyncSubscribe( + viewModel: BaseMvRxViewModel, + asyncProp: KProperty1>, + onFail: ((Throwable) -> Unit)? = null, + onSuccess: ((T) -> Unit)? = null + ) { + assertSubscribeToDifferentViewModel(viewModel) + viewModel.stateFlow + .map { MvRxTuple1(asyncProp.get(it)) } + .distinctUntilChanged() + .onEach { (asyncValue) -> + if (onSuccess != null && asyncValue is Success) { + onSuccess(asyncValue()) + } else if (onFail != null && asyncValue is Fail) { + onFail(asyncValue.error) + } + } + .launchIn(viewModelScope) + .cancelOnClear(viewModel.viewModelScope) + } + + /** + * Subscribe to state changes for two properties. + */ + protected fun selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + subscriber: (A, B) -> Unit + ) = _internal2(null, prop1, prop2, action = { a, b -> subscriber(a, b) }).toDisposable() + + /** + * Subscribe to state changes for two properties in a different ViewModel. + */ + protected fun selectSubscribe( + viewModel: BaseMvRxViewModel, + prop1: KProperty1, + prop2: KProperty1, + subscriber: (A, B) -> Unit + ) { + assertSubscribeToDifferentViewModel(viewModel) + viewModel.stateFlow + .map { MvRxTuple2(prop1.get(it), prop2.get(it)) } + .distinctUntilChanged() + .onEach { (a, b) -> subscriber(a, b) } + .launchIn(viewModelScope) + .cancelOnClear(viewModel.viewModelScope) + } + + /** + * Subscribe to state changes for three properties. + */ + protected fun selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + subscriber: (A, B, C) -> Unit + ) = _internal3(null, prop1, prop2, prop3, action = { a, b, c -> subscriber(a, b, c) }).toDisposable() + + /** + * Subscribe to state changes for three properties in a different ViewModel. + */ + protected fun selectSubscribe( + viewModel: BaseMvRxViewModel, + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + subscriber: (A, B, C) -> Unit + ) { + assertSubscribeToDifferentViewModel(viewModel) + viewModel.stateFlow + .map { MvRxTuple3(prop1.get(it), prop2.get(it), prop3.get(it)) } + .distinctUntilChanged() + .onEach { (a, b, c) -> subscriber(a, b, c) } + .launchIn(viewModelScope) + .cancelOnClear(viewModel.viewModelScope) + } + + /** + * Subscribe to state changes for four properties. + */ + protected fun selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + subscriber: (A, B, C, D) -> Unit + ) = _internal4(null, prop1, prop2, prop3, prop4, RedeliverOnStart) { a, b, c, d -> subscriber(a, b, c, d) }.toDisposable() + + /** + * Subscribe to state changes for four properties in a different ViewModel. + */ + protected fun selectSubscribe( + viewModel: BaseMvRxViewModel, + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + subscriber: (A, B, C, D) -> Unit + ) { + assertSubscribeToDifferentViewModel(viewModel) + viewModel.stateFlow + .map { MvRxTuple4(prop1.get(it), prop2.get(it), prop3.get(it), prop4.get(it)) } + .distinctUntilChanged() + .onEach { (a, b, c, d) -> subscriber(a, b, c, d) } + .launchIn(viewModelScope) + .cancelOnClear(viewModel.viewModelScope) + } + + /** + * Subscribe to state changes for five properties. + */ + protected fun selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + subscriber: (A, B, C, D, E) -> Unit + ) = _internal5(null, prop1, prop2, prop3, prop4, prop5, RedeliverOnStart) { a, b, c, d, e -> + subscriber(a, b, c, d, e) + }.toDisposable() + + /** + * Subscribe to state changes for five properties in a different ViewModel. + */ + protected fun selectSubscribe( + viewModel: BaseMvRxViewModel, + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + subscriber: (A, B, C, D, E) -> Unit + ) { + assertSubscribeToDifferentViewModel(viewModel) + viewModel.stateFlow + .map { MvRxTuple5(prop1.get(it), prop2.get(it), prop3.get(it), prop4.get(it), prop5.get(it)) } + .distinctUntilChanged() + .onEach { (a, b, c, d, e) -> subscriber(a, b, c, d, e) } + .launchIn(viewModelScope) + .cancelOnClear(viewModel.viewModelScope) + } + + /** + * Subscribe to state changes for six properties. + */ + protected fun selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + prop6: KProperty1, + subscriber: (A, B, C, D, E, F) -> Unit + ) = _internal6(null, prop1, prop2, prop3, prop4, prop5, prop6, RedeliverOnStart) { a, b, c, d, e, f -> + subscriber(a, b, c, d, e, f) + }.toDisposable() + + /** + * Subscribe to state changes for six properties in a different ViewModel. + */ + protected fun selectSubscribe( + viewModel: BaseMvRxViewModel, + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + prop6: KProperty1, + subscriber: (A, B, C, D, E, F) -> Unit + ) { + assertSubscribeToDifferentViewModel(viewModel) + viewModel.stateFlow + .map { MvRxTuple6(prop1.get(it), prop2.get(it), prop3.get(it), prop4.get(it), prop5.get(it), prop6.get(it)) } + .distinctUntilChanged() + .onEach { (a, b, c, d, e, f) -> subscriber(a, b, c, d, e, f) } + .launchIn(viewModelScope) + .cancelOnClear(viewModel.viewModelScope) + } + + /** + * Subscribe to state changes for seven properties. + */ + protected fun selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + prop6: KProperty1, + prop7: KProperty1, + subscriber: (A, B, C, D, E, F, G) -> Unit + ) = _internal7(null, prop1, prop2, prop3, prop4, prop5, prop6, prop7, RedeliverOnStart) { a, b, c, d, e, f, g -> + subscriber(a, b, c, d, e, f, g) + }.toDisposable() + + /** + * Subscribe to state changes for seven properties in a different ViewModel. + */ + protected fun selectSubscribe( + viewModel: BaseMvRxViewModel, + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + prop6: KProperty1, + prop7: KProperty1, + subscriber: (A, B, C, D, E, F, G) -> Unit + ) { + assertSubscribeToDifferentViewModel(viewModel) + viewModel.stateFlow + .map { MvRxTuple7(prop1.get(it), prop2.get(it), prop3.get(it), prop4.get(it), prop5.get(it), prop6.get(it), prop7.get(it)) } + .distinctUntilChanged() + .onEach { (a, b, c, d, e, f, g) -> subscriber(a, b, c, d, e, f, g) } + .launchIn(viewModelScope) + .cancelOnClear(viewModel.viewModelScope) + } + + private fun Job.cancelOnClear(scope: CoroutineScope): Job { + scope.coroutineContext[Job]?.invokeOnCompletion { + cancel() + } + return this + } + + protected fun Disposable.disposeOnClear(): Disposable { + disposables.add(this) + return this + } + + private fun Job.toDisposable() = Disposable.fromAction { + cancel() + } + + private fun assertSubscribeToDifferentViewModel(viewModel: BaseMvRxViewModel) { + require(this != viewModel) { + "This method is for subscribing to other view models. Please pass a different instance as the argument." + } + } +} diff --git a/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRx.kt b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRx.kt new file mode 100644 index 000000000..f905aa0b6 --- /dev/null +++ b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRx.kt @@ -0,0 +1,17 @@ +package com.airbnb.mvrx + +/** + * Exists for backwards compatibility. + * + * @see Mavericks + */ +object MvRx { + /** + * @see Mavericks.KEY_ARG + */ + @Deprecated( + message = "MvRx has been replaced with Mavericks", + replaceWith = ReplaceWith("Mavericks.KEY_ARG") + ) + const val KEY_ARG = Mavericks.KEY_ARG +} diff --git a/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRxState.kt b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRxState.kt new file mode 100644 index 000000000..c0955a7b0 --- /dev/null +++ b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRxState.kt @@ -0,0 +1,9 @@ +package com.airbnb.mvrx + +/** + * MvRx state exists solely for MvRx 1.x backwards compatibility. + * [MavericksState] is a drop in replacement going forward. + * + * @see MavericksState + */ +interface MvRxState : MavericksState diff --git a/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRxView.kt b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRxView.kt new file mode 100644 index 000000000..72544390c --- /dev/null +++ b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRxView.kt @@ -0,0 +1,230 @@ +package com.airbnb.mvrx + +import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.Job +import java.util.concurrent.atomic.AtomicReference +import kotlin.reflect.KProperty1 + +/** + * Implement this in your MvRx capable Fragment. + * + * When you get a ViewModel with fragmentViewModel, activityViewModel, or existingViewModel, it + * will automatically subscribe to all state changes in the ViewModel and call [invalidate]. + */ +interface MvRxView : MavericksView { + + /** + * Subscribes to all state updates for the given viewModel. + * + * @param deliveryMode If [UniqueOnly] when this MvRxView goes from a stopped to started lifecycle a state value + * will only be emitted if the state changed. This is useful for transient views that should only + * be shown once (toasts, poptarts), or logging. Most other views should use false, as when a view is destroyed + * and recreated the previous state is necessary to recreate the view. + * + * Use [uniqueOnly] to automatically create a [UniqueOnly] mode with a unique id for this view. + * + * Default: [RedeliverOnStart]. + */ + fun BaseMvRxViewModel.subscribe( + deliveryMode: DeliveryMode = RedeliverOnStart, + subscriber: (S) -> Unit + ) = + _internal(subscriptionLifecycleOwner, deliveryMode, { subscriber(it) }).toDisposable() + + /** + * Subscribes to state changes for only a specific property and calls the subscribe with + * only that single property. + * + * @param deliveryMode If [UniqueOnly], when this MvRxView goes from a stopped to start lifecycle a state value + * will only be emitted if the state changed. This is useful for transient views that should only + * be shown once (toasts, poptarts), or logging. Most other views should use false, as when a view is destroyed + * and recreated the previous state is necessary to recreate the view. + * + * Use [uniqueOnly] to automatically create a [UniqueOnly] mode with a unique id for this view. + * + * Default: [RedeliverOnStart]. + */ + fun BaseMvRxViewModel.selectSubscribe( + prop1: KProperty1, + deliveryMode: DeliveryMode = RedeliverOnStart, + subscriber: (A) -> Unit + ) = _internal1(subscriptionLifecycleOwner, prop1, deliveryMode, { subscriber(it) }).toDisposable() + + /** + * Subscribe to changes in an async property. There are optional parameters for onSuccess + * and onFail which automatically unwrap the value or error. + * + * @param deliveryMode If [UniqueOnly], when this MvRxView goes from a stopped to start lifecycle a state value + * will only be emitted if the state changed. This is useful for transient views that should only + * be shown once (toasts, poptarts), or logging. Most other views should use false, as when a view is destroyed + * and recreated the previous state is necessary to recreate the view. + * + * Use [uniqueOnly] to automatically create a [UniqueOnly] mode with a unique id for this view. + * + * Default: [RedeliverOnStart]. + */ + fun BaseMvRxViewModel.asyncSubscribe( + asyncProp: KProperty1>, + deliveryMode: DeliveryMode = RedeliverOnStart, + onFail: ((Throwable) -> Unit)? = null, + onSuccess: ((T) -> Unit)? = null + ) = _internalSF(subscriptionLifecycleOwner, asyncProp, deliveryMode, { onFail?.invoke(it) }, { onSuccess?.invoke(it) }).toDisposable() + + /** + * Subscribes to state changes for two properties. + * + * @param deliveryMode If [UniqueOnly], when this MvRxView goes from a stopped to start lifecycle a state value + * will only be emitted if the state changed. This is useful for transient views that should only + * be shown once (toasts, poptarts), or logging. Most other views should use false, as when a view is destroyed + * and recreated the previous state is necessary to recreate the view. + * + * Use [uniqueOnly] to automatically create a [UniqueOnly] mode with a unique id for this view. + * + * Default: [RedeliverOnStart]. + */ + fun BaseMvRxViewModel.selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + deliveryMode: DeliveryMode = RedeliverOnStart, + subscriber: (A, B) -> Unit + ) = _internal2(subscriptionLifecycleOwner, prop1, prop2, deliveryMode, { a, b -> subscriber(a, b) }).toDisposable() + + /** + * Subscribes to state changes for three properties. + * + * @param deliveryMode If [UniqueOnly], when this MvRxView goes from a stopped to start lifecycle a state value + * will only be emitted if the state changed. This is useful for transient views that should only + * be shown once (toasts, poptarts), or logging. Most other views should use false, as when a view is destroyed + * and recreated the previous state is necessary to recreate the view. + * + * Use [uniqueOnly] to automatically create a [UniqueOnly] mode with a unique id for this view. + * + * Default: [RedeliverOnStart]. + */ + fun BaseMvRxViewModel.selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + deliveryMode: DeliveryMode = RedeliverOnStart, + subscriber: (A, B, C) -> Unit + ) = _internal3(subscriptionLifecycleOwner, prop1, prop2, prop3, deliveryMode, { a, b, c -> subscriber(a, b, c) }).toDisposable() + + /** + * Subscribes to state changes for four properties. + * + * @param deliveryMode If [UniqueOnly], when this MvRxView goes from a stopped to start lifecycle a state value + * will only be emitted if the state changed. This is useful for transient views that should only + * be shown once (toasts, poptarts), or logging. Most other views should use false, as when a view is destroyed + * and recreated the previous state is necessary to recreate the view. + * + * Use [uniqueOnly] to automatically create a [UniqueOnly] mode with a unique id for this view. + * + * Default: [RedeliverOnStart]. + */ + fun BaseMvRxViewModel.selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + deliveryMode: DeliveryMode = RedeliverOnStart, + subscriber: (A, B, C, D) -> Unit + ) = _internal4(subscriptionLifecycleOwner, prop1, prop2, prop3, prop4, deliveryMode, { a, b, c, d -> subscriber(a, b, c, d) }).toDisposable() + + /** + * Subscribes to state changes for five properties. + * + * @param deliveryMode If [UniqueOnly], when this MvRxView goes from a stopped to start lifecycle a state value + * will only be emitted if the state changed. This is useful for transient views that should only + * be shown once (toasts, poptarts), or logging. Most other views should use false, as when a view is destroyed + * and recreated the previous state is necessary to recreate the view. + * + * Use [uniqueOnly] to automatically create a [UniqueOnly] mode with a unique id for this view. + * + * Default: [RedeliverOnStart]. + */ + fun BaseMvRxViewModel.selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + deliveryMode: DeliveryMode = RedeliverOnStart, + subscriber: (A, B, C, D, E) -> Unit + ) = _internal5( + subscriptionLifecycleOwner, prop1, prop2, prop3, prop4, prop5, deliveryMode, + { a, b, c, d, e -> + subscriber(a, b, c, d, e) + } + ).toDisposable() + + /** + * Subscribes to state changes for six properties. + * + * @param deliveryMode If [UniqueOnly], when this MvRxView goes from a stopped to start lifecycle a state value + * will only be emitted if the state changed. This is useful for transient views that should only + * be shown once (toasts, poptarts), or logging. Most other views should use false, as when a view is destroyed + * and recreated the previous state is necessary to recreate the view. + * + * Use [uniqueOnly] to automatically create a [UniqueOnly] mode with a unique id for this view. + * + * Default: [RedeliverOnStart]. + */ + fun BaseMvRxViewModel.selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + prop6: KProperty1, + deliveryMode: DeliveryMode = RedeliverOnStart, + subscriber: (A, B, C, D, E, F) -> Unit + ) = _internal6( + subscriptionLifecycleOwner, prop1, prop2, prop3, prop4, prop5, prop6, deliveryMode, + { a, b, c, d, e, f -> + subscriber(a, b, c, d, e, f) + } + ).toDisposable() + + /** + * Subscribes to state changes for seven properties. + * + * @param deliveryMode If [UniqueOnly], when this MvRxView goes from a stopped to start lifecycle a state value + * will only be emitted if the state changed. This is useful for transient views that should only + * be shown once (toasts, poptarts), or logging. Most other views should use false, as when a view is destroyed + * and recreated the previous state is necessary to recreate the view. + * + * Use [uniqueOnly] to automatically create a [UniqueOnly] mode with a unique id for this view. + * + * Default: [RedeliverOnStart]. + */ + fun BaseMvRxViewModel.selectSubscribe( + prop1: KProperty1, + prop2: KProperty1, + prop3: KProperty1, + prop4: KProperty1, + prop5: KProperty1, + prop6: KProperty1, + prop7: KProperty1, + deliveryMode: DeliveryMode = RedeliverOnStart, + subscriber: (A, B, C, D, E, F, G) -> Unit + ) = _internal7( + subscriptionLifecycleOwner, prop1, prop2, prop3, prop4, prop5, prop6, prop7, deliveryMode, + { a, b, c, d, e, f, g -> + subscriber(a, b, c, d, e, f, g) + } + ).toDisposable() +} + +private class JobDisposable(job: Job) : AtomicReference(job), Disposable { + init { + job.invokeOnCompletion { set(null) } + } + + override fun dispose() { + getAndSet(null)?.cancel() + } + + override fun isDisposed(): Boolean = get()?.isActive?.not() ?: true +} + +internal fun Job.toDisposable(): Disposable = JobDisposable(this) diff --git a/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRxViewModelFactory.kt b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRxViewModelFactory.kt new file mode 100644 index 000000000..afcf6cf7c --- /dev/null +++ b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/MvRxViewModelFactory.kt @@ -0,0 +1,7 @@ +package com.airbnb.mvrx + +@Deprecated( + message = "MvRx has been replaced with Mavericks", + replaceWith = ReplaceWith("MavericksViewModelFactory") +) +interface MvRxViewModelFactory, S : MavericksState> : MavericksViewModelFactory diff --git a/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/rxjava3/MvRxTuples.kt b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/rxjava3/MvRxTuples.kt new file mode 100644 index 000000000..4349d00cb --- /dev/null +++ b/mvrx-rxjava3/src/main/kotlin/com/airbnb/mvrx/rxjava3/MvRxTuples.kt @@ -0,0 +1,17 @@ +package com.airbnb.mvrx.rxjava3 + +internal data class MvRxTuple1(val a: A) +internal data class MvRxTuple2(val a: A, val b: B) +internal data class MvRxTuple3(val a: A, val b: B, val c: C) +internal data class MvRxTuple4(val a: A, val b: B, val c: C, val d: D) +internal data class MvRxTuple5(val a: A, val b: B, val c: C, val d: D, val e: E) +internal data class MvRxTuple6(val a: A, val b: B, val c: C, val d: D, val e: E, val f: F) +internal data class MvRxTuple7( + 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-rxjava3/src/test/kotlin/com/airbnb/mvrx/BaseTest.kt b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/BaseTest.kt new file mode 100644 index 000000000..d582415f4 --- /dev/null +++ b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/BaseTest.kt @@ -0,0 +1,21 @@ +package com.airbnb.mvrx + +import com.airbnb.mvrx.mocking.MockBehavior +import com.airbnb.mvrx.test.MavericksTestRule +import org.junit.Ignore +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@Ignore("Base Class") +abstract class BaseTest { + + @get:Rule + val mvrxRule = MavericksTestRule( + setForceDisableLifecycleAwareObserver = false, + viewModelMockBehavior = MockBehavior( + stateStoreBehavior = MockBehavior.StateStoreBehavior.Synchronous + ), + ) +} diff --git a/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/DisposableJobTest.kt b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/DisposableJobTest.kt new file mode 100644 index 000000000..ab6fc8557 --- /dev/null +++ b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/DisposableJobTest.kt @@ -0,0 +1,42 @@ +package com.airbnb.mvrx + +import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.Job +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class DisposableJobTest { + private lateinit var job: CompletableJob + private lateinit var disposable: Disposable + + @Before + fun setup() { + job = Job() + disposable = job.toDisposable() + } + + @Test + fun `dispose() cancels job`() { + disposable.dispose() + assert(job.isCancelled) + assert(disposable.isDisposed) + } + + @Test + fun `isDisposed reflects job cancellation`() { + assert(!disposable.isDisposed) + job.cancel() + assert(disposable.isDisposed) + } + + @Test + fun `isDisposed reflects job completion`() { + assert(!disposable.isDisposed) + job.complete() + assert(disposable.isDisposed) + } +} diff --git a/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/InterViewModelSubscriberTest.kt b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/InterViewModelSubscriberTest.kt new file mode 100644 index 000000000..920345837 --- /dev/null +++ b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/InterViewModelSubscriberTest.kt @@ -0,0 +1,400 @@ +package com.airbnb.mvrx + +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +data class OuterViewModelTestState( + val foo: Int = 0, + val prop2: Int = 0, + val prop3: Int = 0, + val prop4: Int = 0, + val prop5: Int = 0, + val prop6: Int = 0, + val prop7: Int = 0, + val async: Async = Uninitialized +) : MavericksState + +class OuterViewModelTestViewModel(initialState: OuterViewModelTestState) : BaseMvRxViewModel(initialState) { + + fun setFoo(foo: Int) = setState { copy(foo = foo) } + + fun setProp2(value: Int) = setState { copy(prop2 = value) } + + fun setProp3(value: Int) = setState { copy(prop3 = value) } + + fun setProp4(value: Int) = setState { copy(prop4 = value) } + + fun setProp5(value: Int) = setState { copy(prop5 = value) } + + fun setProp6(value: Int) = setState { copy(prop6 = value) } + + fun setProp7(value: Int) = setState { copy(prop7 = value) } + + fun setAsync(async: Async) { + setState { copy(async = async) } + } + + fun triggerCleared() { + onCleared() + } + + fun subscribeSameViewModel() { + subscribe(this) { } + } + + fun asyncSubscribeSameViewModel() { + asyncSubscribe(this, OuterViewModelTestState::async) { } + } + + fun selectSubscribeSameViewModel() { + selectSubscribe(this, OuterViewModelTestState::foo) { } + } + + fun subscribeToTwoPropsSameViewModel() { + selectSubscribe(this, OuterViewModelTestState::foo, OuterViewModelTestState::prop2) { _, _ -> } + } + + fun subscribeToThreePropsSameViewModel() { + selectSubscribe( + this, + OuterViewModelTestState::foo, + OuterViewModelTestState::prop2, + OuterViewModelTestState::prop3 + ) { _, _, _ -> } + } + + fun subscribeToFourPropsSameViewModel() { + selectSubscribe( + this, + OuterViewModelTestState::foo, + OuterViewModelTestState::prop2, + OuterViewModelTestState::prop3, + OuterViewModelTestState::prop4 + ) { _, _, _, _ -> } + } + + fun subscribeToFivePropsSameViewModel() { + selectSubscribe( + this, + OuterViewModelTestState::foo, + OuterViewModelTestState::prop2, + OuterViewModelTestState::prop3, + OuterViewModelTestState::prop4, + OuterViewModelTestState::prop5 + ) { _, _, _, _, _ -> } + } + + fun subscribeToSixPropsSameViewModel() { + selectSubscribe( + this, + OuterViewModelTestState::foo, + OuterViewModelTestState::prop2, + OuterViewModelTestState::prop3, + OuterViewModelTestState::prop4, + OuterViewModelTestState::prop5, + OuterViewModelTestState::prop6 + ) { _, _, _, _, _, _ -> } + } + + fun subscribeToSevenPropsSameViewModel() { + selectSubscribe( + this, + OuterViewModelTestState::foo, + OuterViewModelTestState::prop2, + OuterViewModelTestState::prop3, + OuterViewModelTestState::prop4, + OuterViewModelTestState::prop5, + OuterViewModelTestState::prop6, + OuterViewModelTestState::prop7 + ) { _, _, _, _, _, _, _ -> } + } +} + +data class InnerViewModelTestState( + val foo: Int = 0 +) : MavericksState + +class InnerViewModelTestViewModel( + initialState: InnerViewModelTestState, + outerViewModelTestViewModel: OuterViewModelTestViewModel +) : BaseMvRxViewModel(initialState) { + + var subscribeCalled = 0 + var selectSubscribeCalled = 0 + var onSuccessCalled = 0 + var onFailCalled = 0 + + init { + subscribe(outerViewModelTestViewModel) { subscribeCalled++ } + selectSubscribe(outerViewModelTestViewModel, OuterViewModelTestState::foo) { selectSubscribeCalled++ } + asyncSubscribe(outerViewModelTestViewModel, OuterViewModelTestState::async, { onFailCalled++ }) { onSuccessCalled++ } + } + + fun triggerCleared() { + onCleared() + } + + fun subscribeToTwoProps(viewModel: OuterViewModelTestViewModel, subscriber: () -> Unit) { + selectSubscribe(viewModel, OuterViewModelTestState::foo, OuterViewModelTestState::prop2) { _, _ -> subscriber.invoke() } + } + + fun subscribeToThreeProps(viewModel: OuterViewModelTestViewModel, subscriber: () -> Unit) { + selectSubscribe( + viewModel, + OuterViewModelTestState::foo, + OuterViewModelTestState::prop2, + OuterViewModelTestState::prop3 + ) { _, _, _ -> subscriber.invoke() } + } + + fun subscribeToFourProps(viewModel: OuterViewModelTestViewModel, subscriber: () -> Unit) { + selectSubscribe( + viewModel, + OuterViewModelTestState::foo, + OuterViewModelTestState::prop2, + OuterViewModelTestState::prop3, + OuterViewModelTestState::prop4 + ) { _, _, _, _ -> subscriber.invoke() } + } + + fun subscribeToFiveProps(viewModel: OuterViewModelTestViewModel, subscriber: () -> Unit) { + selectSubscribe( + viewModel, + OuterViewModelTestState::foo, + OuterViewModelTestState::prop2, + OuterViewModelTestState::prop3, + OuterViewModelTestState::prop4, + OuterViewModelTestState::prop5 + ) { _, _, _, _, _ -> subscriber.invoke() } + } + + fun subscribeToSixProps(viewModel: OuterViewModelTestViewModel, subscriber: () -> Unit) { + selectSubscribe( + viewModel, + OuterViewModelTestState::foo, + OuterViewModelTestState::prop2, + OuterViewModelTestState::prop3, + OuterViewModelTestState::prop4, + OuterViewModelTestState::prop5, + OuterViewModelTestState::prop6 + ) { _, _, _, _, _, _ -> subscriber.invoke() } + } + + fun subscribeToSevenProps(viewModel: OuterViewModelTestViewModel, subscriber: () -> Unit) { + selectSubscribe( + viewModel, + OuterViewModelTestState::foo, + OuterViewModelTestState::prop2, + OuterViewModelTestState::prop3, + OuterViewModelTestState::prop4, + OuterViewModelTestState::prop5, + OuterViewModelTestState::prop6, + OuterViewModelTestState::prop7 + ) { _, _, _, _, _, _, _ -> subscriber.invoke() } + } +} + +class InnerViewModelSubscriberTest : BaseTest() { + + private lateinit var innerViewModel: InnerViewModelTestViewModel + private lateinit var outerViewModel: OuterViewModelTestViewModel + + @Before + fun setup() { + outerViewModel = OuterViewModelTestViewModel(OuterViewModelTestState()) + innerViewModel = InnerViewModelTestViewModel(InnerViewModelTestState(), outerViewModel) + } + + @Test + fun testSubscribe() { + assertEquals(1, innerViewModel.subscribeCalled) + } + + @Test + fun testSelectSubscribe() { + assertEquals(1, innerViewModel.selectSubscribeCalled) + } + + @Test + fun testNotChangingFoo() { + outerViewModel.setFoo(0) + assertEquals(1, innerViewModel.subscribeCalled) + assertEquals(0, innerViewModel.onSuccessCalled) + assertEquals(0, innerViewModel.onFailCalled) + } + + @Test + fun testChangingFoo() { + outerViewModel.setFoo(1) + assertEquals(2, innerViewModel.subscribeCalled) + assertEquals(0, innerViewModel.onSuccessCalled) + assertEquals(0, innerViewModel.onFailCalled) + } + + @Test + fun testSuccess() { + outerViewModel.setAsync(Success("Hello World")) + assertEquals(2, innerViewModel.subscribeCalled) + assertEquals(1, innerViewModel.onSuccessCalled) + } + + @Test + fun testFail() { + outerViewModel.setAsync(Fail(IllegalStateException("foo"))) + assertEquals(2, innerViewModel.subscribeCalled) + assertEquals(0, innerViewModel.onSuccessCalled) + assertEquals(1, innerViewModel.onFailCalled) + } + + @Test + fun testChangesAfterInnerCleared() { + innerViewModel.triggerCleared() + outerViewModel.setAsync(Success("Hello World")) + outerViewModel.setFoo(1) + assertEquals(1, innerViewModel.subscribeCalled) + assertEquals(0, innerViewModel.onSuccessCalled) + assertEquals(0, innerViewModel.onFailCalled) + } + + @Test + fun testSelectSubscribe2() { + var callCount = 0 + innerViewModel.subscribeToTwoProps(outerViewModel) { callCount++ } + assertEquals(1, callCount) + outerViewModel.setFoo(1) + assertEquals(2, callCount) + outerViewModel.setProp2(2) + assertEquals(3, callCount) + } + + @Test + fun testSelectSubscribe3() { + var callCount = 0 + innerViewModel.subscribeToThreeProps(outerViewModel) { callCount++ } + assertEquals(1, callCount) + outerViewModel.setFoo(1) + assertEquals(2, callCount) + outerViewModel.setProp2(2) + assertEquals(3, callCount) + outerViewModel.setProp3(3) + assertEquals(4, callCount) + } + + @Test + fun testSelectSubscribe4() { + var callCount = 0 + innerViewModel.subscribeToFourProps(outerViewModel) { callCount++ } + assertEquals(1, callCount) + outerViewModel.setFoo(1) + assertEquals(2, callCount) + outerViewModel.setProp2(2) + assertEquals(3, callCount) + outerViewModel.setProp3(3) + assertEquals(4, callCount) + outerViewModel.setProp4(4) + assertEquals(5, callCount) + } + + @Test + fun testSelectSubscribe5() { + var callCount = 0 + innerViewModel.subscribeToFiveProps(outerViewModel) { callCount++ } + assertEquals(1, callCount) + outerViewModel.setFoo(1) + assertEquals(2, callCount) + outerViewModel.setProp2(2) + assertEquals(3, callCount) + outerViewModel.setProp3(3) + assertEquals(4, callCount) + outerViewModel.setProp4(4) + assertEquals(5, callCount) + outerViewModel.setProp5(5) + assertEquals(6, callCount) + } + + @Test + fun testSelectSubscribe6() { + var callCount = 0 + innerViewModel.subscribeToSixProps(outerViewModel) { callCount++ } + assertEquals(1, callCount) + outerViewModel.setFoo(1) + assertEquals(2, callCount) + outerViewModel.setProp2(2) + assertEquals(3, callCount) + outerViewModel.setProp3(3) + assertEquals(4, callCount) + outerViewModel.setProp4(4) + assertEquals(5, callCount) + outerViewModel.setProp5(5) + assertEquals(6, callCount) + outerViewModel.setProp6(6) + assertEquals(7, callCount) + } + + @Test + fun testSelectSubscribe7() { + var callCount = 0 + innerViewModel.subscribeToSevenProps(outerViewModel) { callCount++ } + assertEquals(1, callCount) + outerViewModel.setFoo(1) + assertEquals(2, callCount) + outerViewModel.setProp2(2) + assertEquals(3, callCount) + outerViewModel.setProp3(3) + assertEquals(4, callCount) + outerViewModel.setProp4(4) + assertEquals(5, callCount) + outerViewModel.setProp5(5) + assertEquals(6, callCount) + outerViewModel.setProp6(6) + assertEquals(7, callCount) + outerViewModel.setProp7(7) + assertEquals(8, callCount) + } + + @Test(expected = IllegalArgumentException::class) + fun testSameViewModelSubscribe() { + outerViewModel.subscribeSameViewModel() + } + + @Test(expected = IllegalArgumentException::class) + fun testSameViewModelAsyncSubscribe() { + outerViewModel.asyncSubscribeSameViewModel() + } + + @Test(expected = IllegalArgumentException::class) + fun testSameViewModelSelectSubscribe() { + outerViewModel.selectSubscribeSameViewModel() + } + + @Test(expected = IllegalArgumentException::class) + fun testSameViewModelSelectSubscribe2() { + outerViewModel.subscribeToTwoPropsSameViewModel() + } + + @Test(expected = IllegalArgumentException::class) + fun testSameViewModelSelectSubscribe3() { + outerViewModel.subscribeToThreePropsSameViewModel() + } + + @Test(expected = IllegalArgumentException::class) + fun testSameViewModelSelectSubscribe4() { + outerViewModel.subscribeToFourPropsSameViewModel() + } + + @Test(expected = IllegalArgumentException::class) + fun testSameViewModelSelectSubscribe5() { + outerViewModel.subscribeToFivePropsSameViewModel() + } + + @Test(expected = IllegalArgumentException::class) + fun testSameViewModelSelectSubscribe6() { + outerViewModel.subscribeToSixPropsSameViewModel() + } + + @Test(expected = IllegalArgumentException::class) + fun testSameViewModelSelectSubscribe7() { + outerViewModel.subscribeToSevenPropsSameViewModel() + } +} diff --git a/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/ListExtensionsTest.kt b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/ListExtensionsTest.kt new file mode 100644 index 000000000..2679f1212 --- /dev/null +++ b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/ListExtensionsTest.kt @@ -0,0 +1,49 @@ +package com.airbnb.mvrx + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ListExtensionsTest : BaseTest() { + + @Test + fun testAppendNull() { + val list = listOf(1, 2, 3) + assertEquals(listOf(1, 2, 3), list.appendAt(null, 3)) + } + + @Test + fun testAppendAtEnd() { + val list = listOf(1, 2, 3) + assertEquals(listOf(1, 2, 3, 4), list.appendAt(listOf(4), 3)) + } + + @Test + fun testAppendPastEnd() { + val list = listOf(1, 2, 3) + assertEquals(listOf(1, 2, 3, 4), list.appendAt(listOf(4), 3)) + } + + @Test + fun testAppendInMiddle() { + val list = listOf(1, 2, 3) + assertEquals(listOf(1, 2, 4), list.appendAt(listOf(4), 2)) + } + + @Test + fun testAppendAtBeginning() { + val list = listOf(1, 2, 3) + assertEquals(listOf(4), list.appendAt(listOf(4), 0)) + } + + @Test + fun testAppendAtShorterList() { + val list = listOf(1) + assertEquals(listOf(1, 4), list.appendAt(listOf(4), 3)) + } + + @Test + fun testAppendSmallListInMiddleOfLongList() { + val list = listOf(1, 2, 3, 4, 5) + assertEquals(listOf(1, 4), list.appendAt(listOf(4), 1)) + } +} diff --git a/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/TestLifecycleOwner.kt b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/TestLifecycleOwner.kt new file mode 100644 index 000000000..91dfc446f --- /dev/null +++ b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/TestLifecycleOwner.kt @@ -0,0 +1,11 @@ +package com.airbnb.mvrx + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry + +class TestLifecycleOwner : LifecycleOwner { + + private val _lifecycle = LifecycleRegistry(this) + + override fun getLifecycle(): LifecycleRegistry = _lifecycle +} diff --git a/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/ViewModelSubscriberTest.kt b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/ViewModelSubscriberTest.kt new file mode 100644 index 000000000..4f2b1372a --- /dev/null +++ b/mvrx-rxjava3/src/test/kotlin/com/airbnb/mvrx/ViewModelSubscriberTest.kt @@ -0,0 +1,807 @@ +package com.airbnb.mvrx + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +data class ViewModelTestState( + val foo: Int = 0, + val bar: Int = 0, + val bam: Int = 0, + val list: List = emptyList(), + // for Single and Observable tests + val async: Async = Uninitialized, + // for Completable tests + val asyncUnit: Async = Uninitialized, + val prop6: Int = 0, + val prop7: Int = 0 +) : MavericksState + +class ViewModelTestViewModel(initialState: ViewModelTestState) : BaseMvRxViewModel(initialState) { + + var subscribeCallCount = 0 + var selectSubscribe1Called = 0 + var selectSubscribe2Called = 0 + var selectSubscribe3Called = 0 + var onSuccessCalled = 0 + var onFailCalled = 0 + + init { + onEach { subscribeCallCount++ } + onEach(ViewModelTestState::foo) { selectSubscribe1Called++ } + onEach(ViewModelTestState::foo, ViewModelTestState::bar) { _, _ -> selectSubscribe2Called++ } + onEach(ViewModelTestState::foo, ViewModelTestState::bar, ViewModelTestState::bam) { _, _, _ -> selectSubscribe3Called++ } + onAsync(ViewModelTestState::async, { onFailCalled++ }) { onSuccessCalled++ } + } + + fun setFoo(foo: Int) = setState { copy(foo = foo) } + + fun setBar(bar: Int) = setState { copy(bar = bar) } + + fun setBam(bam: Int) = setState { copy(bam = bam) } + + fun set(reducer: ViewModelTestState.() -> ViewModelTestState) { + setState(reducer) + } + + fun setAsync(async: Async) { + setState { copy(async = async) } + } + + fun disposeOnClear(disposable: Disposable) { + disposable.disposeOnClear() + } + + fun triggerCleared() { + onCleared() + } + + fun testCompletableSuccess() { + var callCount = 0 + onEach(ViewModelTestState::asyncUnit) { + callCount++ + assertEquals( + when (callCount) { + 1 -> Uninitialized + 2 -> Loading() + 3 -> Success(Unit) + else -> throw IllegalArgumentException("Unexpected call count $callCount") + }, + it + ) + } + Completable.create { emitter -> + emitter.onComplete() + }.execute { copy(asyncUnit = it) } + assertEquals(3, callCount) + } + + fun testCompletableFail() { + var callCount = 0 + val error = IllegalStateException("Fail") + onEach(ViewModelTestState::asyncUnit) { + callCount++ + assertEquals( + when (callCount) { + 1 -> Uninitialized + 2 -> Loading() + 3 -> Fail(error) + else -> throw IllegalArgumentException("Unexpected call count $callCount") + }, + it + ) + } + Completable.create { + throw error + }.execute { copy(asyncUnit = it) } + assertEquals(3, callCount) + } + + fun testSingleSuccess() { + var callCount = 0 + onEach(ViewModelTestState::async) { + callCount++ + assertEquals( + when (callCount) { + 1 -> Uninitialized + 2 -> Loading() + 3 -> Success("Hello World") + else -> throw IllegalArgumentException("Unexpected call count $callCount") + }, + it + ) + } + Single.create { emitter -> + emitter.onSuccess("Hello World") + }.execute { copy(async = it) } + assertEquals(3, callCount) + } + + fun testSingleFail() { + var callCount = 0 + val error = IllegalStateException("Fail") + onEach(ViewModelTestState::async) { + callCount++ + assertEquals( + when (callCount) { + 1 -> Uninitialized + 2 -> Loading() + 3 -> Fail(error) + else -> throw IllegalArgumentException("Unexpected call count $callCount") + }, + it + ) + } + Single.create { + throw error + }.execute { copy(async = it) } + assertEquals(3, callCount) + } + + fun testObservableSuccess() { + var callCount = 0 + onEach(ViewModelTestState::async) { + callCount++ + assertEquals( + when (callCount) { + 1 -> Uninitialized + 2 -> Loading() + 3 -> Success("Hello World") + else -> throw IllegalArgumentException("Unexpected call count $callCount") + }, + it + ) + } + Observable.just("Hello World").execute { copy(async = it) } + assertEquals(3, callCount) + } + + fun testSequentialSetStatesWithinWithStateBlock() { + var callCount = 0 + withState { + onEach(ViewModelTestState::foo) { + callCount++ + assertEquals( + when (callCount) { + 1 -> 0 + 2 -> 1 + 3 -> 2 + else -> throw IllegalArgumentException("Unexpected call count $callCount") + }, + it + ) + } + setState { copy(foo = 1) } + setState { copy(foo = 2) } + } + assertEquals(3, callCount) + } + + fun testObservableFail() { + var callCount = 0 + val error = IllegalStateException("Fail") + onEach(ViewModelTestState::async) { + callCount++ + assertEquals( + when (callCount) { + 1 -> Uninitialized + 2 -> Loading() + 3 -> Fail(error) + else -> throw IllegalArgumentException("Unexpected call count $callCount") + }, + it + ) + } + Observable.create { + throw error + }.execute { copy(async = it) } + assertEquals(3, callCount) + } + + fun testObservableWithMapper() { + var callCount = 0 + onEach(ViewModelTestState::async) { + callCount++ + assertEquals( + when (callCount) { + 1 -> Uninitialized + 2 -> Loading() + 3 -> Success("Hello World!") + else -> throw IllegalArgumentException("Unexpected call count $callCount") + }, + it + ) + } + Observable.just("Hello World").execute(mapper = { "$it!" }) { copy(async = it) } + assertEquals(3, callCount) + } +} + +class ViewModelSubscriberTest : BaseTest() { + + private lateinit var viewModel: ViewModelTestViewModel + private lateinit var owner: TestLifecycleOwner + + @Before + fun setup() { + viewModel = ViewModelTestViewModel(ViewModelTestState()) + owner = TestLifecycleOwner() + owner.lifecycle.currentState = Lifecycle.State.RESUMED + } + + @Test + fun testSubscribe() { + assertEquals(1, viewModel.subscribeCallCount) + } + + @Test + fun testSubscribeExternal() { + var callCount = 0 + viewModel._internal(owner) { callCount++ } + assertEquals(1, callCount) + } + + @Test + fun testSelectSubscribe() { + assertEquals(1, viewModel.selectSubscribe1Called) + } + + @Test + fun testSelectSubscribe1External() { + var callCount = 0 + viewModel._internal1(owner, ViewModelTestState::foo) { callCount++ } + assertEquals(1, callCount) + viewModel.setFoo(1) + assertEquals(2, callCount) + } + + @Test + fun testSelectSubscribe2External() { + var callCount = 0 + viewModel._internal2(owner, ViewModelTestState::foo, ViewModelTestState::bar) { _, _ -> callCount++ } + assertEquals(1, callCount) + viewModel.setFoo(1) + assertEquals(2, callCount) + viewModel.setBar(2) + assertEquals(3, callCount) + } + + @Test + fun testSelectSubscribe3External() { + var callCount = 0 + viewModel._internal3( + owner, + ViewModelTestState::foo, + ViewModelTestState::bar, + ViewModelTestState::bam + ) { _, _, _ -> callCount++ } + assertEquals(1, callCount) + viewModel.setFoo(1) + assertEquals(2, callCount) + viewModel.setBar(2) + assertEquals(3, callCount) + viewModel.setBam(2) + assertEquals(4, callCount) + } + + @Test + fun testSelectSubscribe4External() { + var callCount = 0 + viewModel._internal4( + owner, + ViewModelTestState::foo, + ViewModelTestState::bar, + ViewModelTestState::bam, + ViewModelTestState::list + ) { _, _, _, _ -> callCount++ } + assertEquals(1, callCount) + viewModel.setFoo(1) + assertEquals(2, callCount) + viewModel.setBar(2) + assertEquals(3, callCount) + viewModel.setBam(2) + assertEquals(4, callCount) + viewModel.set { copy(list = listOf(1, 2, 3)) } + assertEquals(5, callCount) + } + + @Test + fun testSelectSubscribe5External() { + var callCount = 0 + viewModel._internal5( + owner, + ViewModelTestState::foo, + ViewModelTestState::bar, + ViewModelTestState::bam, + ViewModelTestState::list, + ViewModelTestState::async + ) { _, _, _, _, _ -> callCount++ } + assertEquals(1, callCount) + viewModel.setFoo(1) + assertEquals(2, callCount) + viewModel.setBar(2) + assertEquals(3, callCount) + viewModel.setBam(2) + assertEquals(4, callCount) + viewModel.set { copy(list = listOf(1, 2, 3)) } + assertEquals(5, callCount) + viewModel.set { copy(async = Loading()) } + assertEquals(6, callCount) + } + + @Test + fun testSelectSubscribe6External() { + var callCount = 0 + viewModel._internal6( + owner, + ViewModelTestState::foo, + ViewModelTestState::bar, + ViewModelTestState::bam, + ViewModelTestState::list, + ViewModelTestState::async, + ViewModelTestState::prop6 + ) { _, _, _, _, _, _ -> callCount++ } + assertEquals(1, callCount) + viewModel.setFoo(1) + assertEquals(2, callCount) + viewModel.setBar(2) + assertEquals(3, callCount) + viewModel.setBam(2) + assertEquals(4, callCount) + viewModel.set { copy(list = listOf(1, 2, 3)) } + assertEquals(5, callCount) + viewModel.set { copy(async = Loading()) } + assertEquals(6, callCount) + viewModel.set { copy(prop6 = 1) } + assertEquals(7, callCount) + } + + @Test + fun testSelectSubscribe7External() { + var callCount = 0 + viewModel._internal7( + owner, + ViewModelTestState::foo, + ViewModelTestState::bar, + ViewModelTestState::bam, + ViewModelTestState::list, + ViewModelTestState::async, + ViewModelTestState::prop6, + ViewModelTestState::prop7 + ) { _, _, _, _, _, _, _ -> callCount++ } + assertEquals(1, callCount) + viewModel.setFoo(1) + assertEquals(2, callCount) + viewModel.setBar(2) + assertEquals(3, callCount) + viewModel.setBam(2) + assertEquals(4, callCount) + viewModel.set { copy(list = listOf(1, 2, 3)) } + assertEquals(5, callCount) + viewModel.set { copy(async = Loading()) } + assertEquals(6, callCount) + viewModel.set { copy(prop6 = 1) } + assertEquals(7, callCount) + viewModel.set { copy(prop7 = 1) } + assertEquals(8, callCount) + } + + @Test + fun testNotChangingFoo() { + viewModel.setFoo(0) + assertEquals(1, viewModel.subscribeCallCount) + assertEquals(1, viewModel.selectSubscribe1Called) + assertEquals(1, viewModel.selectSubscribe2Called) + assertEquals(1, viewModel.selectSubscribe3Called) + assertEquals(0, viewModel.onSuccessCalled) + assertEquals(0, viewModel.onFailCalled) + } + + @Test + fun testChangingFoo() { + viewModel.setFoo(1) + assertEquals(2, viewModel.subscribeCallCount) + assertEquals(2, viewModel.selectSubscribe1Called) + assertEquals(2, viewModel.selectSubscribe2Called) + assertEquals(2, viewModel.selectSubscribe3Called) + assertEquals(0, viewModel.onSuccessCalled) + assertEquals(0, viewModel.onFailCalled) + } + + @Test + fun testChangingBar() { + viewModel.setBar(1) + assertEquals(2, viewModel.subscribeCallCount) + assertEquals(1, viewModel.selectSubscribe1Called) + assertEquals(2, viewModel.selectSubscribe2Called) + assertEquals(2, viewModel.selectSubscribe3Called) + assertEquals(0, viewModel.onSuccessCalled) + assertEquals(0, viewModel.onFailCalled) + } + + @Test + fun testChangingBam() { + viewModel.setBam(1) + assertEquals(2, viewModel.subscribeCallCount) + assertEquals(1, viewModel.selectSubscribe1Called) + assertEquals(1, viewModel.selectSubscribe2Called) + assertEquals(2, viewModel.selectSubscribe3Called) + assertEquals(0, viewModel.onSuccessCalled) + assertEquals(0, viewModel.onFailCalled) + } + + @Test + fun testSuccess() { + viewModel.setAsync(Success("Hello World")) + assertEquals(2, viewModel.subscribeCallCount) + assertEquals(1, viewModel.selectSubscribe1Called) + assertEquals(1, viewModel.selectSubscribe2Called) + assertEquals(1, viewModel.selectSubscribe3Called) + assertEquals(1, viewModel.onSuccessCalled) + assertEquals(0, viewModel.onFailCalled) + } + + @Test + fun testFail() { + viewModel.setAsync(Fail(IllegalStateException("foo"))) + assertEquals(2, viewModel.subscribeCallCount) + assertEquals(1, viewModel.selectSubscribe1Called) + assertEquals(1, viewModel.selectSubscribe2Called) + assertEquals(1, viewModel.selectSubscribe3Called) + assertEquals(0, viewModel.onSuccessCalled) + assertEquals(1, viewModel.onFailCalled) + } + + @Test + fun testDisposeOnClear() { + val disposable = Maybe.never().subscribe() + viewModel.disposeOnClear(disposable) + assertFalse(disposable.isDisposed) + viewModel.triggerCleared() + assertTrue(disposable.isDisposed) + } + + @Test + fun testCompletableSuccess() { + viewModel.testCompletableSuccess() + } + + @Test + fun testCompletableFail() { + viewModel.testCompletableFail() + } + + @Test + fun testSingleSuccess() { + viewModel.testSingleSuccess() + } + + @Test + fun testSingleFail() { + viewModel.testSingleFail() + } + + @Test + fun testObservableSuccess() { + viewModel.testObservableSuccess() + } + + @Test + fun testSequentialSetStatesWithinWithStateBlock() { + viewModel.testSequentialSetStatesWithinWithStateBlock() + } + + @Test + fun testObservableWithMapper() { + viewModel.testObservableWithMapper() + } + + @Test + fun testObservableFail() { + viewModel.testObservableFail() + } + + @Test + fun testSubscribeNotCalledInInitialized() { + owner.lifecycle.currentState = Lifecycle.State.INITIALIZED + + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + + assertEquals(0, callCount) + } + + @Test + fun testSubscribeNotCalledInCreated() { + owner.lifecycle.currentState = Lifecycle.State.CREATED + + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + + assertEquals(0, callCount) + } + + @Test + fun testSubscribeCalledInStarted() { + owner.lifecycle.currentState = Lifecycle.State.STARTED + + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + + assertEquals(1, callCount) + } + + @Test + fun testSubscribeCalledInResumed() { + owner.lifecycle.currentState = Lifecycle.State.RESUMED + + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + + assertEquals(1, callCount) + } + + @Test + fun testSubscribeNotCalledInDestroyed() { + owner.lifecycle.currentState = Lifecycle.State.DESTROYED + + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + + assertEquals(0, callCount) + } + + @Test + fun testSubscribeNotCalledWhenTransitionedToStopped() { + owner.lifecycle.currentState = Lifecycle.State.RESUMED + + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + + viewModel.setFoo(1) + + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + + viewModel.setFoo(2) + + assertEquals(2, callCount) + } + + @Test + fun testSubscribeNotCalledWhenTransitionedToDestroyed() { + owner.lifecycle.currentState = Lifecycle.State.RESUMED + + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + + viewModel.setFoo(1) + + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + + viewModel.setFoo(2) + + assertEquals(2, callCount) + } + + @Test + fun testSubscribeCalledWhenTransitionToStarted() { + owner.lifecycle.currentState = Lifecycle.State.CREATED + + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + + assertEquals(0, callCount) + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + assertEquals(1, callCount) + } + + @Test + fun testSubscribeCalledWhenTransitionToResumed() { + owner.lifecycle.currentState = Lifecycle.State.STARTED + + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + + viewModel.setFoo(1) + + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + + viewModel.setFoo(2) + + assertEquals(3, callCount) + } + + @Test + fun testSubscribeCalledOnRestart() { + owner.lifecycle.currentState = Lifecycle.State.RESUMED + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + assertEquals(1, callCount) + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + assertEquals(1, callCount) + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + assertEquals(1, callCount) + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) + assertEquals(2, callCount) + } + + @Test + fun testUniqueOnlySubscribeCalledOnStartIfUpdateOccurredInStop() { + owner.lifecycle.currentState = Lifecycle.State.STARTED + + var callCount = 0 + viewModel._internal(owner, deliveryMode = UniqueOnly("id")) { + callCount++ + } + + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + + viewModel.setFoo(1) + assertEquals(1, callCount) + + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) + assertEquals(2, callCount) + } + + @Test + fun testUniqueOnlySubscribeNotCalledOnStartIfNoUpdateOccurredInStop() { + owner.lifecycle.currentState = Lifecycle.State.STARTED + + var callCount = 0 + viewModel._internal(owner, deliveryMode = UniqueOnly("id")) { + callCount++ + } + + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + assertEquals(1, callCount) + + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) + assertEquals(1, callCount) + } + + @Test + fun testAsync() { + var callCount = 0 + val success = "Hello World" + val fail = IllegalStateException("Uh oh") + viewModel._internalSF( + owner, ViewModelTestState::async, + onFail = { + callCount++ + assertEquals(fail, it) + } + ) { + callCount++ + assertEquals(success, it) + } + viewModel.setAsync(Success(success)) + viewModel.setAsync(Fail(fail)) + assertEquals(2, callCount) + } + + @Test + fun testAddToList() { + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + assertEquals(1, callCount) + + viewModel.set { copy(list = list + 5) } + + assertEquals(2, callCount) + } + + @Test + fun testStopsSubscriptionWhenCancelled() { + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + viewModel.set { copy(list = list + 1) } + assertEquals(2, callCount) + + owner.lifecycleScope.cancel() + viewModel.set { copy(list = list + 1) } + assertEquals(2, callCount) + } + + @Test + fun testReplace() { + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + assertEquals(1, callCount) + + viewModel.set { copy(list = listOf(5)) } + + assertEquals(2, callCount) + } + + @Test + fun testChangeValue() { + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + assertEquals(1, callCount) + + viewModel.set { copy(list = listOf(5)) } + + assertEquals(2, callCount) + + viewModel.set { copy(list = list.toMutableList().apply { set(0, 3) }) } + + assertEquals(3, callCount) + } + + @Test + fun testNoEventEmittedIfSameStateIsSet() { + var callCount = 0 + viewModel._internal(owner) { + callCount++ + } + assertEquals(1, callCount) + + viewModel.set { copy() } + assertEquals(1, callCount) + } + + @Test + fun testCancelledIfOwnerDestroyed() { + val job = viewModel._internal(owner) {} + owner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + assertTrue(job.isCompleted) + } + + @Test + fun testStateFlowReceivesAllStates() = runTest(UnconfinedTestDispatcher()) { + val receivedValues = mutableListOf() + val subscribeJob = viewModel.stateFlow.onEach { + receivedValues += it.foo + delay(1000) + }.launchIn(this) + (1..6).forEach { + viewModel.set { copy(foo = it) } + } + delay(6000) + assertEquals(listOf(0, 1, 2, 3, 4, 5), receivedValues) + subscribeJob.cancel() + } +} diff --git a/mvrx-rxjava3/src/test/resources/robolectric.properties b/mvrx-rxjava3/src/test/resources/robolectric.properties new file mode 100644 index 000000000..c6516c0a0 --- /dev/null +++ b/mvrx-rxjava3/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +# SDK 29 requires Java 9 or newer +sdk=28 diff --git a/mvrx-testing/build.gradle b/mvrx-testing/build.gradle index 75c91bff2..3d544373f 100644 --- a/mvrx-testing/build.gradle +++ b/mvrx-testing/build.gradle @@ -37,8 +37,8 @@ dependencies { implementation libs.junit implementation libs.junit5 implementation libs.kotlinReflect - implementation libs.rxAndroid + implementation libs.rxAndroid3 implementation libs.multidex implementation libs.kotlinCoroutinesTest - implementation project(":mvrx-rxjava2") + implementation project(":mvrx-rxjava3") } diff --git a/sample-dagger/build.gradle b/sample-dagger/build.gradle index 02c29aa85..76843d86f 100644 --- a/sample-dagger/build.gradle +++ b/sample-dagger/build.gradle @@ -47,7 +47,7 @@ dependencies { implementation libs.coreKtx implementation libs.dagger implementation libs.fragmentKtx - implementation libs.rxJava + implementation libs.rxJava2 implementation libs.viewModelKtx implementation libs.multidex diff --git a/sample-todo/build.gradle b/sample-todo/build.gradle index e00d03b58..c23403d1e 100644 --- a/sample-todo/build.gradle +++ b/sample-todo/build.gradle @@ -37,8 +37,8 @@ dependencies { implementation libs.recyclerview implementation libs.roomRuntime implementation libs.roomRxJava - implementation libs.rxAndroid - implementation libs.rxJava + implementation libs.rxAndroid2 + implementation libs.rxJava2 implementation libs.multidex testImplementation libs.junit diff --git a/sample/build.gradle b/sample/build.gradle index 412f1fb7b..c0725274e 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -49,9 +49,9 @@ dependencies { implementation libs.recyclerview implementation libs.retrofit implementation libs.retrofitMoshi - implementation libs.retrofitRxJava - implementation libs.rxAndroid - implementation libs.rxJava + implementation libs.retrofitRxJava2 + implementation libs.rxAndroid2 + implementation libs.rxJava2 testImplementation libs.junit } diff --git a/settings.gradle b/settings.gradle index 0a1c3d903..7e324fe28 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,6 +10,7 @@ include ':mvrx-mocking' include ':mvrx-navigation' include ':mvrx-hilt' include ':mvrx-rxjava2' +include ':mvrx-rxjava3' include ':mvrx-launcher' include ':mvrx-testing' @@ -27,4 +28,4 @@ include ':sample-hilt' include ':sample' include ':sample-compose' include ':sample-navigation' -include ':sample-todo' +include ':sample-todo' \ No newline at end of file diff --git a/versions.properties b/versions.properties index 43d2768be..8223ba84f 100644 --- a/versions.properties +++ b/versions.properties @@ -7,6 +7,13 @@ #### suppress inspection "SpellCheckingInspection" for whole file #### suppress inspection "UnusedProperty" for whole file +version.coil-kt=2.4.0 + +version.google.android.material=1.9.0 +## # available=1.10.0-alpha01 +## # available=1.10.0-alpha02 +## # available=1.10.0-alpha03 + version.rxjava2.rxjava=2.2.21 version.rxjava2.rxandroid=2.1.1 @@ -484,3 +491,7 @@ plugin.android=7.2.2 ## # available=8.1.0-alpha08 ## # available=8.1.0-alpha09 ## # available=8.1.0-alpha10 + +version.rxjava3.rxandroid=3.0.2 + +version.rxjava3.rxjava=3.1.6