diff options
Diffstat (limited to 'mobile/android/android-components/components/lib/state')
27 files changed, 2791 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/lib/state/README.md b/mobile/android/android-components/components/lib/state/README.md new file mode 100644 index 0000000000..eb8f54712a --- /dev/null +++ b/mobile/android/android-components/components/lib/state/README.md @@ -0,0 +1,69 @@ +# [Android Components](../../../README.md) > Libraries > State + +A generic library for maintaining the state of a component, screen or application. + +The state library is inspired by existing libraries like [Redux](https://redux.js.org/) and provides a `Store` class to hold application state. + +## Usage + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:lib-state:{latest-version}" +``` + +### Action + +`Action`s represent payloads of information that send data from your application to the `Store`. You can send actions using `store.dispatch()`. An `Action` will usually be a small data class or object describing a change. + +```Kotlin +data class SetVisibility(val visible: Boolean) : Action + +store.dispatch(SetVisibility(true)) +``` + +### Reducer + +`Reducer`s are functions describing how the state should change in response to actions sent to the store. + +They take the previous state and an action as parameters, and return the new state as a result of that action. + +```Kotlin +fun reduce(previousState: State, action: Action) = when (action) { + is SetVisibility -> previousState.copy(toolbarVisible = action.visible) + else -> previousState +} +``` + +### Store + +The `Store` brings together actions and reducers. It holds the application state and allows access to it via the `store.state` getter. It allows state to be updated via `store.dispatch()`, and can have listeners registered through `store.observe()`. + +Stores can easily be created if you have a reducer. + +```Kotlin +val store = Store<State, Action>( + initialState = State(), + reducer = ::reduce +) +``` + +Once the store is created, you can react to changes in the state by registering an observer. + +```Kotlin +store.observe(lifecycleOwner) { state -> + toolbarView.visibility = if (state.toolbarVisible) View.VISIBLE else View.GONE +} +``` + +`store.observe` is lifecycle aware and will automatically unregister when the lifecycle owner (such as an `Activity` or `Fragment`) is destroyed. Instead of a `LifecycleOwner`, a `View` can be supplied instead. + +If you wish to manually control the observer subscription, you can use the `store.observeManually` function. `observeManually` returns a `Subscription` class which has an `unsubscribe` method. Calling `unsubscribe` removes the observer. + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ diff --git a/mobile/android/android-components/components/lib/state/build.gradle b/mobile/android/android-components/components/lib/state/build.gradle new file mode 100644 index 0000000000..4424fce465 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/build.gradle @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } + + namespace 'mozilla.components.lib.state' +} + +tasks.withType(KotlinCompile).configureEach { + kotlinOptions.freeCompilerArgs += [ + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi" + ] +} + +dependencies { + implementation platform(ComponentsDependencies.androidx_compose_bom) + implementation ComponentsDependencies.kotlin_coroutines + implementation ComponentsDependencies.androidx_fragment + implementation ComponentsDependencies.androidx_compose_ui + implementation ComponentsDependencies.androidx_lifecycle_process + + implementation project(':support-base') + implementation project(':support-ktx') + + testImplementation platform(ComponentsDependencies.androidx_compose_bom) + testImplementation project(':support-test') + + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.androidx_compose_ui_test + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines + + androidTestImplementation ComponentsDependencies.androidx_test_junit + androidTestImplementation ComponentsDependencies.androidx_compose_ui_test_manifest + androidTestImplementation ComponentsDependencies.androidx_compose_ui_test +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/lib/state/proguard-rules.pro b/mobile/android/android-components/components/lib/state/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/lib/state/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/lib/state/src/androidTest/java/mozilla/components/lib/state/ext/ComposeExtensionsKtTest.kt b/mobile/android/android-components/components/lib/state/src/androidTest/java/mozilla/components/lib/state/ext/ComposeExtensionsKtTest.kt new file mode 100644 index 0000000000..a97ceebe0d --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/androidTest/java/mozilla/components/lib/state/ext/ComposeExtensionsKtTest.kt @@ -0,0 +1,182 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import androidx.compose.ui.test.junit4.createComposeRule +import kotlinx.coroutines.runBlocking +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class ComposeExtensionsKtTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun usingInitialValue() { + val store = Store( + initialState = TestState(counter = 42), + reducer = ::reducer, + ) + + var value: Int? = null + + rule.setContent { + val composeState = store.observeAsComposableState { state -> state.counter * 2 } + value = composeState.value + } + + assertEquals(84, value) + } + + @Test + fun receivingUpdates() { + val store = Store( + initialState = TestState(counter = 42), + reducer = ::reducer, + ) + + var value: Int? = null + + rule.setContent { + val composeState = store.observeAsComposableState { state -> state.counter * 2 } + value = composeState.value + } + + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + + rule.runOnIdle { + assertEquals(86, value) + } + } + + @Test + fun usingInitialValueWithUpdates() { + val loading = "Loading" + val content = "Content" + val store = Store( + initialState = TestState(counter = 0), + reducer = ::reducer, + ) + + val value = mutableListOf<String>() + + rule.setContent { + val composeState = store.observeAsState( + initialValue = loading, + map = { if (it.counter < 5) loading else content }, + ) + value.add(composeState.value) + } + + rule.runOnIdle { + // Initial value when counter is 0. + assertEquals(listOf("Loading"), value) + } + + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + + rule.runOnIdle { + // Value after 4 increments, aka counter is 4. Note that it doesn't recompose here + // as the mapped value has stayed the same. We have 1 item in the list and not 5. + assertEquals(listOf(loading), value) + } + + // 5th increment + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + + rule.runOnIdle { + assertEquals(listOf(loading, content), value) + assertEquals(content, value.last()) + } + } + + @Test + fun receivingUpdatesForPartialStateUpdateOnly() { + val store = Store( + initialState = TestState(counter = 42), + reducer = ::reducer, + ) + + var value: Int? = null + + rule.setContent { + val composeState = store.observeAsComposableState( + map = { state -> state.counter * 2 }, + observe = { state -> state.text }, + ) + value = composeState.value + } + + assertEquals(84, value) + + store.dispatchBlockingOnIdle(TestAction.IncrementAction) + + rule.runOnIdle { + // State value didn't change because value returned by `observer` function did not change + assertEquals(84, value) + } + + store.dispatchBlockingOnIdle(TestAction.SetTextAction("Hello World")) + + rule.runOnIdle { + // Now, after the value from the observer function changed, we are seeing the new value + assertEquals(86, value) + } + + store.dispatchBlockingOnIdle(TestAction.SetValueAction(23)) + + rule.runOnIdle { + // Observer function result is the same, no state update + assertEquals(86, value) + } + + store.dispatchBlockingOnIdle(TestAction.SetTextAction("Hello World")) + + rule.runOnIdle { + // Text was updated to the same value, observer function result is the same, no state update + assertEquals(86, value) + } + + store.dispatchBlockingOnIdle(TestAction.SetTextAction("Hello World Again")) + + rule.runOnIdle { + // Now, after the value from the observer function changed, we are seeing the new value + assertEquals(46, value) + } + } + + private fun Store<TestState, TestAction>.dispatchBlockingOnIdle(action: TestAction) { + rule.runOnIdle { + val job = dispatch(action) + runBlocking { job.join() } + } + } +} + +fun reducer(state: TestState, action: TestAction): TestState = when (action) { + is TestAction.IncrementAction -> state.copy(counter = state.counter + 1) + is TestAction.DecrementAction -> state.copy(counter = state.counter - 1) + is TestAction.SetValueAction -> state.copy(counter = action.value) + is TestAction.SetTextAction -> state.copy(text = action.text) +} + +data class TestState( + val counter: Int, + val text: String = "", +) : State + +sealed class TestAction : Action { + object IncrementAction : TestAction() + object DecrementAction : TestAction() + data class SetValueAction(val value: Int) : TestAction() + data class SetTextAction(val text: String) : TestAction() +} diff --git a/mobile/android/android-components/components/lib/state/src/main/AndroidManifest.xml b/mobile/android/android-components/components/lib/state/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..aa9d1077cc --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this file, + - You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest> + + <application /> + +</manifest> diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Action.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Action.kt new file mode 100644 index 0000000000..e371ac8929 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Action.kt @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * Generic interface for actions to be dispatched on a [Store]. + * + * Actions are used to send data from the application to a [Store]. The [Store] will use the [Action] to + * derive a new [State]. Actions should describe what happened, while [Reducer]s will describe how the + * state changes. + */ +interface Action diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/DelicateAction.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/DelicateAction.kt new file mode 100644 index 0000000000..18f48b8483 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/DelicateAction.kt @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * + * Marks an [Action] in the [Store] that are **delicate** — + * they have limited use-case and shall ve used with care in general code. + * Any use of a delicate declaration has to be carefully reviewed to make sure it is + * properly used and is not used for non-debugging or testing purposes. + * Carefully read documentation of any declaration marked as `DelicateAction`. + */ +@MustBeDocumented +@Retention(value = AnnotationRetention.BINARY) +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, + message = "This is a delicate Action and should only be used for situations that require debugging or testing." + + " Make sure you fully read and understand documentation of the action that is marked as a delicate Action.", +) +@Target(AnnotationTarget.CLASS) +public annotation class DelicateAction diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Middleware.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Middleware.kt new file mode 100644 index 0000000000..777b8cb77b --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Middleware.kt @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * A [Middleware] sits between the store and the reducer. It provides an extension point between + * dispatching an action, and the moment it reaches the reducer. + * + * A [Middleware] can rewrite an [Action], it can intercept an [Action], dispatch additional + * [Action]s or perform side-effects when an [Action] gets dispatched. + * + * The [Store] will create a chain of [Middleware] instances and invoke them in order. Every + * [Middleware] can decide to continue the chain (by calling `next`), intercept the chain (by not + * invoking `next`). A [Middleware] has no knowledge of what comes before or after it in the chain. + */ +typealias Middleware<S, A> = (context: MiddlewareContext<S, A>, next: (A) -> Unit, action: A) -> Unit + +/** + * The context a Middleware is running in. Allows access to privileged [Store] functionality. It is + * passed to a [Middleware] with every [Action]. + * + * Note that the [MiddlewareContext] should not be passed to other components and calling methods + * on non-[Store] threads may throw an exception. Instead the value of the [store] property, granting + * access to the underlying store, can safely be used outside of the middleware. + */ +interface MiddlewareContext<S : State, A : Action> { + /** + * Returns the current state of the [Store]. + */ + val state: S + + /** + * Dispatches an [Action] synchronously on the [Store]. Other than calling [Store.dispatch], this + * will block and return after all [Store] observers have been notified about the state change. + * The dispatched [Action] will go through the whole chain of middleware again. + * + * This method is particular useful if a middleware wants to dispatch an additional [Action] and + * wait until the [state] has been updated to further process it. + * + * Note that this method should only ever be called from a [Middleware] and the calling thread. + * Calling it from another thread may throw an exception. For dispatching an [Action] from + * asynchronous code in the [Middleware] or another component use [store] which returns a + * reference to the underlying [Store] that offers methods for asynchronous dispatching. + */ + fun dispatch(action: A) + + /** + * Returns a reference to the [Store] the [Middleware] is running in. + */ + val store: Store<S, A> +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Observer.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Observer.kt new file mode 100644 index 0000000000..c5a68d8c17 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Observer.kt @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * Listener called when the state changes in the [Store]. + */ +typealias Observer<S> = (S) -> Unit diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Reducer.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Reducer.kt new file mode 100644 index 0000000000..0cfcf76bb6 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Reducer.kt @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * Reducers specify how the application's [State] changes in response to [Action]s sent to the [Store]. + * + * Remember that actions only describe what happened, but don't describe how the application's state changes. + * Reducers will commonly consist of a `when` statement returning different copies of the [State]. + */ +typealias Reducer<S, A> = (S, A) -> S diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/State.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/State.kt new file mode 100644 index 0000000000..3318ddffe8 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/State.kt @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +/** + * Generic interface for a [State] maintained by a [Store]. + */ +interface State diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Store.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Store.kt new file mode 100644 index 0000000000..025880d780 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Store.kt @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +import android.os.Handler +import android.os.Looper +import androidx.annotation.CheckResult +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import mozilla.components.lib.state.internal.ReducerChainBuilder +import mozilla.components.lib.state.internal.StoreThreadFactory +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors + +/** + * A generic store holding an immutable [State]. + * + * The [State] can only be modified by dispatching [Action]s which will create a new state and notify all registered + * [Observer]s. + * + * @param initialState The initial state until a dispatched [Action] creates a new state. + * @param reducer A function that gets the current [State] and [Action] passed in and will return a new [State]. + * @param middleware Optional list of [Middleware] sitting between the [Store] and the [Reducer]. + * @param threadNamePrefix Optional prefix with which to name threads for the [Store]. If not provided, + * the naming scheme will be deferred to [Executors.defaultThreadFactory] + */ +open class Store<S : State, A : Action>( + initialState: S, + reducer: Reducer<S, A>, + middleware: List<Middleware<S, A>> = emptyList(), + threadNamePrefix: String? = null, +) { + private val threadFactory = StoreThreadFactory(threadNamePrefix) + private val dispatcher = Executors.newSingleThreadExecutor(threadFactory).asCoroutineDispatcher() + private val reducerChainBuilder = ReducerChainBuilder(threadFactory, reducer, middleware) + private val scope = CoroutineScope(dispatcher) + + @VisibleForTesting + internal val subscriptions = Collections.newSetFromMap(ConcurrentHashMap<Subscription<S, A>, Boolean>()) + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + // We want exceptions in the reducer to crash the app and not get silently ignored. Therefore we rethrow the + // exception on the main thread. + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + throw StoreException("Exception while reducing state", throwable) + } + + // Once an exception happened we do not want to accept any further actions. So let's cancel the scope which + // will cancel all jobs and not accept any new ones. + scope.cancel() + } + private val dispatcherWithExceptionHandler = dispatcher + exceptionHandler + + @Volatile private var currentState = initialState + + /** + * The current [State]. + */ + val state: S + get() = currentState + + /** + * Registers an [Observer] function that will be invoked whenever the [State] changes. + * + * It's the responsibility of the caller to keep track of the returned [Subscription] and call + * [Subscription.unsubscribe] to stop observing and avoid potentially leaking memory by keeping an unused [Observer] + * registered. It's is recommend to use one of the `observe` extension methods that unsubscribe automatically. + * + * The created [Subscription] is in paused state until explicitly resumed by calling [Subscription.resume]. + * While paused the [Subscription] will not receive any state updates. Once resumed the [observer] + * will get invoked immediately with the latest state. + * + * @return A [Subscription] object that can be used to unsubscribe from further state changes. + */ + @CheckResult(suggest = "observe") + @Synchronized + fun observeManually(observer: Observer<S>): Subscription<S, A> { + val subscription = Subscription(observer, store = this) + subscriptions.add(subscription) + + return subscription + } + + /** + * Dispatch an [Action] to the store in order to trigger a [State] change. + */ + fun dispatch(action: A) = scope.launch(dispatcherWithExceptionHandler) { + synchronized(this@Store) { + reducerChainBuilder.get(this@Store).invoke(action) + } + } + + /** + * Transitions from the current [State] to the passed in [state] and notifies all observers. + */ + internal fun transitionTo(state: S) { + if (state == currentState) { + // Nothing has changed. + return + } + + currentState = state + subscriptions.forEach { subscription -> subscription.dispatch(state) } + } + + private fun removeSubscription(subscription: Subscription<S, A>) { + subscriptions.remove(subscription) + } + + /** + * A [Subscription] is returned whenever an observer is registered via the [observeManually] method. Calling + * [unsubscribe] on the [Subscription] will unregister the observer. + */ + class Subscription<S : State, A : Action> internal constructor( + internal val observer: Observer<S>, + store: Store<S, A>, + ) { + private val storeReference = WeakReference(store) + internal var binding: Binding? = null + private var active = false + + /** + * Resumes the [Subscription]. The [Observer] will get notified for every state change. + * Additionally it will get invoked immediately with the latest state. + */ + @Synchronized + fun resume() { + active = true + + storeReference.get()?.state?.let(observer) + } + + /** + * Pauses the [Subscription]. The [Observer] will not get notified when the state changes + * until [resume] is called. + */ + @Synchronized + fun pause() { + active = false + } + + /** + * Notifies this subscription's observer of a state change. + * + * @param state the updated state. + */ + @Synchronized + internal fun dispatch(state: S) { + if (active) { + observer.invoke(state) + } + } + + /** + * Unsubscribe from the [Store]. + * + * Calling this method will clear all references and the subscription will not longer be + * active. + */ + @Synchronized + fun unsubscribe() { + active = false + + storeReference.get()?.removeSubscription(this) + storeReference.clear() + + binding?.unbind() + } + + interface Binding { + fun unbind() + } + } +} + +/** + * Exception for otherwise unhandled errors caught while reducing state or + * while managing/notifying observers. + */ +class StoreException(msg: String, val e: Throwable? = null) : Exception(msg, e) diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/ComposeExtensions.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/ComposeExtensions.kt new file mode 100644 index 0000000000..e4633b6239 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/ComposeExtensions.kt @@ -0,0 +1,147 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import androidx.compose.runtime.State as ComposeState + +/** + * Starts observing this [Store] and represents the mapped state (using [map]) via [ComposeState]. + * + * Every time the mapped [Store] state changes, the returned [ComposeState] will be updated causing + * recomposition of every [ComposeState.value] usage. + * + * The [Store] observer will automatically be removed when this composable disposes or the current + * [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state. + */ +@Composable +fun <S : State, A : Action, R> Store<S, A>.observeAsComposableState(map: (S) -> R): ComposeState<R?> { + val lifecycleOwner = LocalLifecycleOwner.current + val state = remember { mutableStateOf<R?>(map(state)) } + + DisposableEffect(this, lifecycleOwner) { + val subscription = observe(lifecycleOwner) { browserState -> + state.value = map(browserState) + } + onDispose { subscription?.unsubscribe() } + } + + return state +} + +/** + * Starts observing this [Store] and represents the mapped state (using [map]) via [ComposeState]. + * + * Every time the mapped [Store] state changes, the returned [ComposeState] will be updated causing + * recomposition of every [ComposeState.value] usage. + * + * The [Store] observer will automatically be removed when this composable disposes or the current + * [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state. + * + * @param initialValue Initial value emitted. + * @param map The applied function to produced the mapped value [R] from [S]. + * @return A non nullable [ComposeState], making the api more reasonable for callers where the + * state is non null. + */ +@Composable +fun <S : State, A : Action, R> Store<S, A>.observeAsState( + initialValue: R, + map: (S) -> R, +): ComposeState<R> { + val lifecycleOwner = LocalLifecycleOwner.current + + return produceState(initialValue = initialValue) { + val subscription = observe(lifecycleOwner) { browserState -> + value = map(browserState) + } + awaitDispose { subscription?.unsubscribe() } + } +} + +/** + * Starts observing this [Store] and represents the mapped state (using [map]) via [ComposeState]. + * + * Everytime the [Store] state changes and the result of the [observe] function changes for this + * state, the returned [ComposeState] will be updated causing recomposition of every + * [ComposeState.value] usage. + * + * The [Store] observer will automatically be removed when this composable disposes or the current + * [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state. + */ +@Composable +fun <S : State, A : Action, O, R> Store<S, A>.observeAsComposableState( + observe: (S) -> O, + map: (S) -> R, +): ComposeState<R?> { + val lifecycleOwner = LocalLifecycleOwner.current + var lastValue = observe(state) + val state = remember { mutableStateOf<R?>(map(state)) } + + DisposableEffect(this, lifecycleOwner) { + val subscription = observe(lifecycleOwner) { browserState -> + val newValue = observe(browserState) + if (newValue != lastValue) { + state.value = map(browserState) + lastValue = newValue + } + } + onDispose { subscription?.unsubscribe() } + } + + return state +} + +/** + * Helper for creating a [Store] scoped to a `@Composable` and whose [State] gets saved and restored + * on process recreation. + */ +@Composable +inline fun <reified S : State, A : Action> composableStore( + crossinline save: (S) -> Parcelable = { state -> + if (state is Parcelable) { + state + } else { + throw NotImplementedError( + "State of store does not implement Parcelable. Either implement Parcelable or pass " + + "custom save function to composableStore()", + ) + } + }, + crossinline restore: (Parcelable) -> S = { parcelable -> + if (parcelable is S) { + parcelable + } else { + throw NotImplementedError( + "Restored parcelable is not of same class as state. Either the state needs to " + + "implement Parcelable or you need to provide a custom restore function to composableStore()", + ) + } + }, + crossinline init: (S?) -> Store<S, A>, +): Store<S, A> { + return rememberSaveable( + saver = Saver( + save = { store -> save(store.state) }, + restore = { parcelable -> + val state = restore(parcelable) + init(state) + }, + ), + init = { init(null) }, + ) +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/Fragment.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/Fragment.kt new file mode 100644 index 0000000000..0eacb1de04 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/Fragment.kt @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.view.View +import androidx.annotation.MainThread +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import mozilla.components.support.ktx.android.view.toScope + +/** + * Helper extension method for consuming [State] from a [Store] sequentially in order inside a + * [Fragment]. The [block] function will get invoked for every [State] update. + * + * This helper will automatically stop observing the [Store] once the [View] of the [Fragment] gets + * detached. The fragment's lifecycle will be used to determine when to resume/pause observing the + * [Store]. + */ +@MainThread +fun <S : State, A : Action> Fragment.consumeFrom(store: Store<S, A>, block: (S) -> Unit) { + val fragment = this + val view = checkNotNull(view) { "Fragment has no view yet. Call from onViewCreated()." } + + val scope = view.toScope() + val channel = store.channel(owner = this) + + scope.launch { + channel.consumeEach { state -> + // We are using a scope that is bound to the view being attached here. It can happen + // that the "view detached" callback gets executed *after* the fragment was detached. If + // a `consumeFrom` runs in exactly this moment then we run inside a detached fragment + // without a `Context` and this can cause a variety of issues/crashes. + // See: https://github.com/mozilla-mobile/android-components/issues/4125 + // + // To avoid this, we check whether the fragment still has an activity and a view + // attached. If not then we run in exactly that moment between fragment detach and view + // detach. It would be better if we could use `viewLifecycleOwner` which is bound to + // onCreateView() and onDestroyView() of the fragment. But: + // - `viewLifecycleOwner` is only available in alpha versions of AndroidX currently. + // - We found a bug where `viewLifecycleOwner.lifecycleScope` is not getting cancelled + // causing this coroutine to run forever. + // See: https://github.com/mozilla-mobile/android-components/issues/3828 + // Once those two issues get resolved we can remove the `isAdded` check and use + // `viewLifecycleOwner.lifecycleScope` instead of the view scope. + // + // In a previous version we tried using `isAdded` and `isDetached` here. But in certain + // situations they reported true/false in situations where no activity was attached to + // the fragment. Therefore we switched to explicitly check for the activity and view here. + if (fragment.activity != null && fragment.view != null) { + block(state) + } + } + } +} + +/** + * Helper extension method for consuming [State] from a [Store] as a [Flow]. + * + * The lifetime of the coroutine scope the [Flow] is launched in, and [block] is executed in, is + * bound to the [View] of the [Fragment]. Once the [View] gets detached, the coroutine scope will + * automatically be cancelled and no longer observe the [Store]. + * + * An optional [LifecycleOwner] can be passed to this method. It will be used to automatically pause + * and resume the [Store] subscription. With that an application can, for example, automatically + * stop updating the UI if the application is in the background. Once the [Lifecycle] switches back + * to at least STARTED state then the latest [State] and further will be passed to the [Flow] again. + * By default, the fragment itself is used as a [LifecycleOwner]. + */ +@MainThread +fun <S : State, A : Action> Fragment.consumeFlow( + from: Store<S, A>, + owner: LifecycleOwner? = this, + block: suspend (Flow<S>) -> Unit, +) { + val fragment = this + val view = checkNotNull(view) { "Fragment has no view yet. Call from onViewCreated()." } + + // It's important to create the flow here directly instead of in the coroutine below, + // as otherwise the fragment could be removed before the subscription is created. + // This would cause us to create an unnecessary subscription leaking the fragment, + // as we only unsubscribe on destroy which already happened. + val flow = from.flow(owner) + + val scope = view.toScope() + scope.launch { + val filtered = flow.filter { + // We ignore state updates if the fragment does not have an activity or view + // attached anymore. + // See comment in [consumeFrom] above. + fragment.activity != null && fragment.view != null + } + + block(filtered) + } +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/StoreExtensions.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/StoreExtensions.kt new file mode 100644 index 0000000000..8250bf1376 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/StoreExtensions.kt @@ -0,0 +1,265 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.view.View +import androidx.annotation.MainThread +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.Observer +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * Registers an [Observer] function that will be invoked whenever the state changes. The [Store.Subscription] + * will be bound to the passed in [LifecycleOwner]. Once the [Lifecycle] state changes to DESTROYED the [Observer] will + * be unregistered automatically. + * + * The [Observer] will get invoked with the current [State] as soon as the [Lifecycle] is in STARTED + * state. + */ +@MainThread +fun <S : State, A : Action> Store<S, A>.observe( + owner: LifecycleOwner, + observer: Observer<S>, +): Store.Subscription<S, A>? { + if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) { + // This owner is already destroyed. No need to register. + return null + } + + val subscription = observeManually(observer) + + subscription.binding = SubscriptionLifecycleBinding(owner, subscription).apply { + owner.lifecycle.addObserver(this) + } + + return subscription +} + +/** + * Registers an [Observer] function that will be invoked whenever the state changes. The [Store.Subscription] + * will be bound to the passed in [View]. Once the [View] gets detached the [Observer] will be unregistered + * automatically. + * + * Note that inside a `Fragment` using [observe] with a `viewLifecycleOwner` may be a better option. + * Only use this implementation if you have only access to a [View] - especially if it can exist + * outside of a `Fragment`. + * + * The [Observer] will get invoked with the current [State] as soon as [View] is attached. + * + * Once the [View] gets detached the [Observer] will get unregistered. It will NOT get automatically + * registered again if the same [View] gets attached again. + */ +@MainThread +fun <S : State, A : Action> Store<S, A>.observe( + view: View, + observer: Observer<S>, +) { + val subscription = observeManually(observer) + + subscription.binding = SubscriptionViewBinding(view, subscription).apply { + view.addOnAttachStateChangeListener(this) + } + + if (view.isAttachedToWindow) { + // This View is already attached. We can resume immediately and do not need to wait for + // onViewAttachedToWindow() getting called. + subscription.resume() + } +} + +/** + * Registers an [Observer] function that will observe the store indefinitely. + * + * Right after registering the [Observer] will be invoked with the current [State]. + */ +fun <S : State, A : Action> Store<S, A>.observeForever( + observer: Observer<S>, +) { + observeManually(observer).resume() +} + +/** + * Creates a conflated [Channel] for observing [State] changes in the [Store]. + * + * The advantage of a [Channel] is that [State] changes can be processed sequentially in order from + * a single coroutine (e.g. on the main thread). + * + * @param owner A [LifecycleOwner] that will be used to determine when to pause and resume the store + * subscription. When the [Lifecycle] is in STOPPED state then no [State] will be received. Once the + * [Lifecycle] switches back to at least STARTED state then the latest [State] and further updates + * will be received. + */ +@ExperimentalCoroutinesApi +@MainThread +fun <S : State, A : Action> Store<S, A>.channel( + owner: LifecycleOwner = ProcessLifecycleOwner.get(), +): ReceiveChannel<S> { + if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) { + // This owner is already destroyed. No need to register. + throw IllegalArgumentException("Lifecycle is already DESTROYED") + } + + val channel = Channel<S>(Channel.CONFLATED) + + val subscription = observeManually { state -> + runBlocking { + try { + channel.send(state) + } catch (e: CancellationException) { + // It's possible for this channel to have been closed concurrently before + // we had a chance to unsubscribe. In this case we can just ignore this + // one subscription and keep going. + } + } + } + + subscription.binding = SubscriptionLifecycleBinding(owner, subscription).apply { + owner.lifecycle.addObserver(this) + } + + channel.invokeOnClose { subscription.unsubscribe() } + + return channel +} + +/** + * Creates a [Flow] for observing [State] changes in the [Store]. + * + * @param owner An optional [LifecycleOwner] that will be used to determine when to pause and resume + * the store subscription. When the [Lifecycle] is in STOPPED state then no [State] will be received. + * Once the [Lifecycle] switches back to at least STARTED state then the latest [State] and further + * updates will be emitted. + */ +@MainThread +fun <S : State, A : Action> Store<S, A>.flow( + owner: LifecycleOwner? = null, +): Flow<S> { + var destroyed = owner?.lifecycle?.currentState == Lifecycle.State.DESTROYED + val ownerDestroyedObserver = object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + destroyed = true + } + } + owner?.lifecycle?.addObserver(ownerDestroyedObserver) + + return channelFlow { + // By the time this block executes the fragment or view could already be destroyed + // so we exit early to avoid creating an unnecessary subscription. This is important + // as otherwise we'd be leaking the owner via the subscription because we only + // unsubscribe on destroy which already happened. + if (destroyed) { + return@channelFlow + } + + owner?.lifecycle?.removeObserver(ownerDestroyedObserver) + + val subscription = observeManually { state -> + runBlocking { + try { + send(state) + } catch (e: CancellationException) { + // It's possible for this channel to have been closed concurrently before + // we had a chance to unsubscribe. In this case we can just ignore this + // one subscription and keep going. + } + } + } + + if (owner == null) { + subscription.resume() + } else { + subscription.binding = SubscriptionLifecycleBinding(owner, subscription).apply { + owner.lifecycle.addObserver(this) + } + } + + awaitClose { + subscription.unsubscribe() + } + }.buffer(Channel.CONFLATED) +} + +/** + * Launches a coroutine in a new [MainScope] and creates a [Flow] for observing [State] changes in + * the [Store] in that scope. Invokes [block] inside that scope and passes the [Flow] to it. + * + * @param owner An optional [LifecycleOwner] that will be used to determine when to pause and resume + * the store subscription. When the [Lifecycle] is in STOPPED state then no [State] will be received. + * Once the [Lifecycle] switches back to at least STARTED state then the latest [State] and further + * updates will be emitted. + * @return The [CoroutineScope] [block] is getting executed in. + */ +@MainThread +fun <S : State, A : Action> Store<S, A>.flowScoped( + owner: LifecycleOwner? = null, + block: suspend (Flow<S>) -> Unit, +): CoroutineScope { + return MainScope().apply { + launch { + block(flow(owner)) + } + } +} + +/** + * GenericLifecycleObserver implementation to bind an observer to a Lifecycle. + */ +private class SubscriptionLifecycleBinding<S : State, A : Action>( + private val owner: LifecycleOwner, + private val subscription: Store.Subscription<S, A>, +) : DefaultLifecycleObserver, Store.Subscription.Binding { + override fun onStart(owner: LifecycleOwner) { + subscription.resume() + } + + override fun onStop(owner: LifecycleOwner) { + subscription.pause() + } + + override fun onDestroy(owner: LifecycleOwner) { + subscription.unsubscribe() + } + + override fun unbind() { + owner.lifecycle.removeObserver(this) + } +} + +/** + * View.OnAttachStateChangeListener implementation to bind an observer to a View. + */ +private class SubscriptionViewBinding<S : State, A : Action>( + private val view: View, + private val subscription: Store.Subscription<S, A>, +) : View.OnAttachStateChangeListener, Store.Subscription.Binding { + override fun onViewAttachedToWindow(v: View) { + subscription.resume() + } + + override fun onViewDetachedFromWindow(view: View) { + subscription.unsubscribe() + } + + override fun unbind() { + view.removeOnAttachStateChangeListener(this) + } +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/View.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/View.kt new file mode 100644 index 0000000000..aafe7d1ed9 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/View.kt @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import mozilla.components.support.ktx.android.view.toScope + +/** + * Helper extension method for consuming [State] from a [Store] sequentially in order scoped to the + * lifetime of the [View]. The [block] function will get invoked for every [State] update. + * + * This helper will automatically stop observing the [Store] once the [View] gets detached. The + * provided [LifecycleOwner] is used to determine when observing should be stopped or resumed. + * + * Inside a [Fragment] prefer to use [Fragment.consumeFrom]. + */ +@ExperimentalCoroutinesApi // Channel +fun <S : State, A : Action> View.consumeFrom( + store: Store<S, A>, + owner: LifecycleOwner, + block: (S) -> Unit, +) { + val scope = toScope() + val channel = store.channel(owner) + + scope.launch { + channel.consumeEach { state -> block(state) } + } +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/AbstractBinding.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/AbstractBinding.kt new file mode 100644 index 0000000000..10a0859192 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/AbstractBinding.kt @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.helpers + +import androidx.annotation.CallSuper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature + +/** + * Helper class for creating small binding classes that are responsible for reacting to state + * changes. + */ +@ExperimentalCoroutinesApi // Flow +abstract class AbstractBinding<in S : State>( + private val store: Store<S, out Action>, +) : LifecycleAwareFeature { + private var scope: CoroutineScope? = null + + @CallSuper + override fun start() { + scope = store.flowScoped { flow -> + onState(flow) + } + } + + @CallSuper + override fun stop() { + scope?.cancel() + } + + /** + * A callback that is invoked when a [Flow] on the [store] is available to use. + */ + abstract suspend fun onState(flow: Flow<S>) +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/ReducerChainBuilder.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/ReducerChainBuilder.kt new file mode 100644 index 0000000000..69ea7dd52b --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/ReducerChainBuilder.kt @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.internal + +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.lib.state.Reducer +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * Builder to lazily create a function that will invoke the chain of [middleware] and finally the + * [reducer]. + */ +internal class ReducerChainBuilder<S : State, A : Action>( + private val storeThreadFactory: StoreThreadFactory, + private val reducer: Reducer<S, A>, + private val middleware: List<Middleware<S, A>>, +) { + private var chain: ((A) -> Unit)? = null + + /** + * Returns a function that will invoke the chain of [middleware] and the [reducer] for the given + * [Store]. + */ + fun get(store: Store<S, A>): (A) -> Unit { + chain?.let { return it } + + return build(store).also { + chain = it + } + } + + private fun build(store: Store<S, A>): (A) -> Unit { + val context = object : MiddlewareContext<S, A> { + override val state: S + get() = store.state + + override fun dispatch(action: A) { + get(store).invoke(action) + } + + override val store: Store<S, A> + get() = store + } + + var chain: (A) -> Unit = { action -> + val state = reducer(store.state, action) + store.transitionTo(state) + } + + val threadCheck: Middleware<S, A> = { _, next, action -> + storeThreadFactory.assertOnThread() + next(action) + } + + (middleware.reversed() + threadCheck).forEach { middleware -> + val next = chain + chain = { action -> middleware(context, next, action) } + } + + return chain + } +} diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/StoreThreadFactory.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/StoreThreadFactory.kt new file mode 100644 index 0000000000..fb9e53d7da --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/StoreThreadFactory.kt @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.internal + +import mozilla.components.lib.state.Store +import mozilla.components.support.base.utils.NamedThreadFactory +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory + +/** + * Custom [ThreadFactory] implementation wrapping [Executors.defaultThreadFactory]/[NamedThreadFactory] + * that allows asserting whether a caller is on the created thread. + * + * For usage with [Executors.newSingleThreadExecutor]: Only the last created thread is kept and + * compared when [assertOnThread] is called. + * + * @param threadNamePrefix Optional prefix with which to name threads for the [Store]. If not provided, + * the naming scheme will be deferred to [Executors.defaultThreadFactory] + */ +internal class StoreThreadFactory( + threadNamePrefix: String?, +) : ThreadFactory { + @Volatile + private var thread: Thread? = null + + private val actualFactory = if (threadNamePrefix != null) { + NamedThreadFactory(threadNamePrefix) + } else { + Executors.defaultThreadFactory() + } + + override fun newThread(r: Runnable): Thread { + return actualFactory.newThread(r).also { + thread = it + } + } + + /** + * Asserts that the calling thread is the thread of this [StoreDispatcher]. Otherwise throws an + * [IllegalThreadStateException]. + */ + fun assertOnThread() { + val currentThread = Thread.currentThread() + val currentThreadId = currentThread.id + val expectedThreadId = thread?.id + + if (currentThreadId == expectedThreadId) { + return + } + + throw IllegalThreadStateException( + "Expected `store` thread, but running on thread `${currentThread.name}`. " + + "Leaked MiddlewareContext or did you mean to use `MiddlewareContext.store.dispatch`?", + ) + } +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreExceptionTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreExceptionTest.kt new file mode 100644 index 0000000000..34adcf511d --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreExceptionTest.kt @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.ext.joinBlocking +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.shadows.ShadowLooper + +@RunWith(AndroidJUnit4::class) +class StoreExceptionTest { + // This test is in a separate class because it needs to run with Robolectric (different runner, slower) while all + // other tests only need a Java VM (fast). + @Test(expected = StoreException::class) + fun `Exception in reducer will be rethrown on main thread`() { + val throwingReducer: (TestState, TestAction) -> TestState = { _, _ -> + throw IllegalStateException("Not reducing today") + } + + val store = Store(TestState(counter = 23), throwingReducer) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + // Wait for the main looper to process the re-thrown exception. + ShadowLooper.idleMainLooper() + + Assert.fail() + } +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreTest.kt new file mode 100644 index 0000000000..715e3e55ba --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreTest.kt @@ -0,0 +1,311 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state + +import mozilla.components.support.test.ext.joinBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class StoreTest { + @Test + fun `Dispatching Action executes reducers and creates new State`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + assertEquals(24, store.state.counter) + + store.dispatch(TestAction.DecrementAction).joinBlocking() + store.dispatch(TestAction.DecrementAction).joinBlocking() + + assertEquals(22, store.state.counter) + } + + @Test + fun `Observer gets notified about state changes`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var observedValue = 0 + + store.observeManually { state -> observedValue = state.counter }.also { + it.resume() + } + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + assertEquals(24, observedValue) + } + + @Test + fun `Observer gets initial value before state changes`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var observedValue = 0 + + store.observeManually { state -> observedValue = state.counter }.also { + it.resume() + } + + assertEquals(23, observedValue) + } + + @Test + fun `Observer does not get notified if state does not change`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var stateChangeObserved = false + + store.observeManually { stateChangeObserved = true }.also { + it.resume() + } + + // Initial state observed + assertTrue(stateChangeObserved) + stateChangeObserved = false + + store.dispatch(TestAction.DoNothingAction).joinBlocking() + + assertFalse(stateChangeObserved) + } + + @Test + fun `Observer does not get notified after unsubscribe`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var observedValue = 0 + + val subscription = store.observeManually { state -> + observedValue = state.counter + }.also { + it.resume() + } + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + assertEquals(24, observedValue) + + store.dispatch(TestAction.DecrementAction).joinBlocking() + + assertEquals(23, observedValue) + + subscription.unsubscribe() + + store.dispatch(TestAction.DecrementAction).joinBlocking() + + assertEquals(23, observedValue) + assertEquals(22, store.state.counter) + } + + @Test + fun `Middleware chain gets executed in order`() { + val incrementMiddleware: Middleware<TestState, TestAction> = { store, next, action -> + if (action == TestAction.DoNothingAction) { + store.dispatch(TestAction.IncrementAction) + } + + next(action) + } + + val doubleMiddleware: Middleware<TestState, TestAction> = { store, next, action -> + if (action == TestAction.DoNothingAction) { + store.dispatch(TestAction.DoubleAction) + } + + next(action) + } + + val store = Store( + TestState(counter = 0), + ::reducer, + listOf( + incrementMiddleware, + doubleMiddleware, + ), + ) + + store.dispatch(TestAction.DoNothingAction).joinBlocking() + + assertEquals(2, store.state.counter) + + store.dispatch(TestAction.DoNothingAction).joinBlocking() + + assertEquals(6, store.state.counter) + + store.dispatch(TestAction.DoNothingAction).joinBlocking() + + assertEquals(14, store.state.counter) + + store.dispatch(TestAction.DecrementAction).joinBlocking() + + assertEquals(13, store.state.counter) + } + + @Test + fun `Middleware can intercept actions`() { + val interceptingMiddleware: Middleware<TestState, TestAction> = { _, _, _ -> + // Do nothing! + } + + val store = Store( + TestState(counter = 0), + ::reducer, + listOf(interceptingMiddleware), + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(0, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(0, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(0, store.state.counter) + } + + @Test + fun `Middleware can rewrite actions`() { + val rewritingMiddleware: Middleware<TestState, TestAction> = { _, next, _ -> + next(TestAction.DecrementAction) + } + + val store = Store( + TestState(counter = 0), + ::reducer, + listOf(rewritingMiddleware), + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-1, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-2, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-3, store.state.counter) + } + + @Test + fun `Middleware can intercept and dispatch other action instead`() { + val rewritingMiddleware: Middleware<TestState, TestAction> = { store, next, action -> + if (action == TestAction.IncrementAction) { + store.dispatch(TestAction.DecrementAction) + } else { + next(action) + } + } + + val store = Store( + TestState(counter = 0), + ::reducer, + listOf(rewritingMiddleware), + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-1, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-2, store.state.counter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(-3, store.state.counter) + } + + @Test + fun `Middleware sees state before and after reducing`() { + var countBefore = -1 + var countAfter = -1 + + val observingMiddleware: Middleware<TestState, TestAction> = { store, next, action -> + countBefore = store.state.counter + next(action) + countAfter = store.state.counter + } + + val store = Store( + TestState(counter = 0), + ::reducer, + listOf(observingMiddleware), + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(0, countBefore) + assertEquals(1, countAfter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(1, countBefore) + assertEquals(2, countAfter) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(2, countBefore) + assertEquals(3, countAfter) + + store.dispatch(TestAction.DecrementAction).joinBlocking() + assertEquals(3, countBefore) + assertEquals(2, countAfter) + } + + @Test + fun `Middleware can catch exceptions in reducer`() { + var caughtException: Exception? = null + + val catchingMiddleware: Middleware<TestState, TestAction> = { _, next, action -> + try { + next(action) + } catch (e: Exception) { + caughtException = e + } + } + + val store = Store( + TestState(counter = 0), + { _: State, _: Action -> throw IOException() }, + listOf(catchingMiddleware), + ) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + assertNotNull(caughtException) + assertTrue(caughtException is IOException) + } +} + +fun reducer(state: TestState, action: TestAction): TestState = when (action) { + is TestAction.IncrementAction -> state.copy(counter = state.counter + 1) + is TestAction.DecrementAction -> state.copy(counter = state.counter - 1) + is TestAction.SetValueAction -> state.copy(counter = action.value) + is TestAction.DoubleAction -> state.copy(counter = state.counter * 2) + is TestAction.DoNothingAction -> state +} + +data class TestState( + val counter: Int, +) : State + +sealed class TestAction : Action { + object IncrementAction : TestAction() + object DecrementAction : TestAction() + object DoNothingAction : TestAction() + object DoubleAction : TestAction() + data class SetValueAction(val value: Int) : TestAction() +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/FragmentKtTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/FragmentKtTest.kt new file mode 100644 index 0000000000..b86e4485f4 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/FragmentKtTest.kt @@ -0,0 +1,301 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.setMain +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.TestAction +import mozilla.components.lib.state.TestState +import mozilla.components.lib.state.reducer +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.mock +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext + +@RunWith(AndroidJUnit4::class) +@ExperimentalCoroutinesApi +class FragmentKtTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Test + @Synchronized + fun `consumeFrom reads states from store`() { + val fragment = mock<Fragment>() + val view = mock<View>() + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + val onAttachListener = argumentCaptor<View.OnAttachStateChangeListener>() + var receivedValue = 0 + var latch = CountDownLatch(1) + + doNothing().`when`(view).addOnAttachStateChangeListener(onAttachListener.capture()) + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + doReturn(view).`when`(fragment).view + doReturn(owner.lifecycle).`when`(fragment).lifecycle + + fragment.consumeFrom(store) { state -> + receivedValue = state.counter + latch.countDown() + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + latch = CountDownLatch(1) + + // View gets detached + onAttachListener.value.onViewDetachedFromWindow(view) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + @Synchronized + fun `consumeFrom does not run when fragment is detached`() { + val fragment = mock<Fragment>() + val view = mock<View>() + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + doReturn(view).`when`(fragment).view + doReturn(owner.lifecycle).`when`(fragment).lifecycle + + fragment.consumeFrom(store) { state -> + receivedValue = state.counter + latch.countDown() + } + + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(23, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + doReturn(null).`when`(fragment).activity + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(28, receivedValue) + } + + @Test + fun `consumeFlow - reads states from store`() { + val fragment = mock<Fragment>() + val view = mock<View>() + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + val onAttachListener = argumentCaptor<View.OnAttachStateChangeListener>() + var receivedValue = 0 + var latch = CountDownLatch(1) + + doNothing().`when`(view).addOnAttachStateChangeListener(onAttachListener.capture()) + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + doReturn(view).`when`(fragment).view + doReturn(owner.lifecycle).`when`(fragment).lifecycle + + fragment.consumeFlow( + from = store, + owner = owner, + ) { flow -> + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + latch = CountDownLatch(1) + + // View gets detached + onAttachListener.value.onViewDetachedFromWindow(view) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + fun `consumeFlow - uses fragment as lifecycle owner by default`() { + val fragment = mock<Fragment>() + val fragmentLifecycleOwner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + val view = mock<View>() + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + val onAttachListener = argumentCaptor<View.OnAttachStateChangeListener>() + var receivedValue = 0 + var latch = CountDownLatch(1) + + doNothing().`when`(view).addOnAttachStateChangeListener(onAttachListener.capture()) + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + doReturn(view).`when`(fragment).view + doReturn(fragmentLifecycleOwner.lifecycle).`when`(fragment).lifecycle + + fragment.consumeFlow( + from = store, + ) { flow -> + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + fragmentLifecycleOwner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + } + + @Test + fun `consumeFlow - creates flow synchronously`() { + val fragment = mock<Fragment>() + val fragmentLifecycle = mock<LifecycleRegistry>() + val view = mock<View>() + val store = Store(TestState(counter = 23), ::reducer) + + doReturn(mock<FragmentActivity>()).`when`(fragment).activity + doReturn(fragmentLifecycle).`when`(fragment).lifecycle + doReturn(view).`when`(fragment).view + + // Verify that we create the flow even if no other coroutine runs past this point + val noopDispatcher = object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + // NOOP + } + } + Dispatchers.setMain(noopDispatcher) + fragment.consumeFlow(store) { flow -> + flow.collect { } + } + + // Only way to verify that store.flow was called without triggering the channelFlow + // producer and in this test we want to make sure we call store.flow before the flow + // is "produced." + verify(fragmentLifecycle).addObserver(any()) + } +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/StoreExtensionsKtTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/StoreExtensionsKtTest.kt new file mode 100644 index 0000000000..c52bdb032e --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/StoreExtensionsKtTest.kt @@ -0,0 +1,572 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.app.Activity +import android.os.Looper +import android.os.Looper.getMainLooper +import android.view.View +import android.view.WindowManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.TestAction +import mozilla.components.lib.state.TestState +import mozilla.components.lib.state.reducer +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +@ExperimentalCoroutinesApi +@OptIn(DelicateCoroutinesApi::class) // GlobalScope usage. +class StoreExtensionsKtTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Test + fun `Observer will not get registered if lifecycle is already destroyed`() = runTestOnMain { + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + + // We cannot set initial DESTROYED state for LifecycleRegistry + // so we simulate lifecycle getting destroyed. + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var stateObserved = false + + store.observe(owner) { stateObserved = true } + store.dispatch(TestAction.IncrementAction).joinBlocking() + + assertFalse(stateObserved) + } + + @Test + fun `Observer will get unregistered if lifecycle gets destroyed`() { + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var stateObserved = false + store.observe(owner) { stateObserved = true } + assertTrue(stateObserved) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(stateObserved) + + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + } + + @Test + fun `non-destroy lifecycle changes do not affect observer registration`() { + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + // Observer does not get invoked since lifecycle is not started + var stateObserved = false + store.observe(owner) { stateObserved = true } + assertFalse(stateObserved) + + // CREATED: Observer does still not get invoked + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.CREATED + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + + // STARTED: Observer gets initial state and observers updates + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(stateObserved) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(stateObserved) + + // RESUMED: Observer continues to get updates + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.RESUMED + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(stateObserved) + + // CREATED: Not observing anymore + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.CREATED + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + + // DESTROYED: Not observing + stateObserved = false + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + } + + @Test + @Synchronized + @ExperimentalCoroutinesApi // Channel + fun `Reading state updates from channel`() = runTestOnMain { + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + val channel = store.channel(owner) + + val job = launch { + channel.consumeEach { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + latch = CountDownLatch(1) + + job.cancelAndJoin() + assertTrue(channel.isClosedForReceive) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test(expected = IllegalArgumentException::class) + @ExperimentalCoroutinesApi // Channel + fun `Creating channel throws if lifecycle is already DESTROYED`() { + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + + // We cannot set initial DESTROYED state for LifecycleRegistry + // so we simulate lifecycle getting destroyed. + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + store.channel(owner) + } + + @Test + @Synchronized + @ExperimentalCoroutinesApi + fun `Reading state updates from Flow with lifecycle owner`() = runTestOnMain { + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + val flow = store.flow(owner) + + val job = coroutinesTestRule.scope.launch { + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + latch = CountDownLatch(1) + + job.cancelAndJoin() + + // Receiving nothing anymore since coroutine is cancelled + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + @ExperimentalCoroutinesApi + fun `Subscription is not added if owner destroyed before flow created`() { + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + val latch = CountDownLatch(1) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + val flow = store.flow(owner) + GlobalScope.launch { + flow.collect { + latch.countDown() + } + } + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertTrue(store.subscriptions.isEmpty()) + } + + @Test + @ExperimentalCoroutinesApi + fun `Subscription is not added if owner destroyed before flow produced`() { + val owner = MockedLifecycleOwner(Lifecycle.State.STARTED) + val latch = CountDownLatch(1) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + val flow = store.flow(owner) + owner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + GlobalScope.launch { + flow.collect { + latch.countDown() + } + } + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertTrue(store.subscriptions.isEmpty()) + } + + @Test + @Synchronized + @ExperimentalCoroutinesApi + fun `Reading state updates from Flow without lifecycle owner`() = runTestOnMain { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + val flow = store.flow() + + val job = GlobalScope.launch { + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Receiving immediately + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(23, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + + latch = CountDownLatch(1) + + job.cancelAndJoin() + + // Receiving nothing anymore since coroutine is cancelled + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + @Synchronized + @ExperimentalCoroutinesApi + fun `Reading state from scoped flow without lifecycle owner`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + val scope = store.flowScoped() { flow -> + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Receiving immediately + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(23, receivedValue) + + // Updating state: Nothing received yet. + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + + scope.cancel() + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + @Synchronized + @ExperimentalCoroutinesApi + fun `Reading state from scoped flow with lifecycle owner`() { + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var receivedValue = 0 + var latch = CountDownLatch(1) + + val scope = store.flowScoped(owner) { flow -> + flow.collect { state -> + receivedValue = state.counter + latch.countDown() + } + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + latch = CountDownLatch(1) + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + + scope.cancel() + + latch = CountDownLatch(1) + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } + + @Test + fun `Observer registered with observeForever will get notified about state changes`() { + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var observedValue = 0 + + store.observeForever { state -> observedValue = state.counter } + assertEquals(23, observedValue) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertEquals(24, observedValue) + + store.dispatch(TestAction.DecrementAction).joinBlocking() + assertEquals(23, observedValue) + } + + @Test + fun `Observer bound to view will get unsubscribed if view gets detached`() { + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + val view = View(testContext) + activity.windowManager.addView(view, WindowManager.LayoutParams(100, 100)) + shadowOf(getMainLooper()).idle() + + assertTrue(view.isAttachedToWindow) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var stateObserved = false + store.observe(view) { stateObserved = true } + assertTrue(stateObserved) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(stateObserved) + + activity.windowManager.removeView(view) + shadowOf(getMainLooper()).idle() + assertFalse(view.isAttachedToWindow) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + } + + @Test + fun `Observer bound to view will not get notified about state changes until the view is attached`() = runTestOnMain { + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + val view = View(testContext) + + assertFalse(view.isAttachedToWindow) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + var stateObserved = false + store.observe(view) { stateObserved = true } + assertFalse(stateObserved) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + + activity.windowManager.addView(view, WindowManager.LayoutParams(100, 100)) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(view.isAttachedToWindow) + assertTrue(stateObserved) + + stateObserved = false + store.observe(view) { stateObserved = true } + assertTrue(stateObserved) + + stateObserved = false + store.observe(view) { stateObserved = true } + assertTrue(stateObserved) + + activity.windowManager.removeView(view) + shadowOf(Looper.getMainLooper()).idle() + + assertFalse(view.isAttachedToWindow) + + stateObserved = false + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(stateObserved) + } +} + +internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner { + val lifecycleRegistry = LifecycleRegistry(this).apply { + currentState = initialState + } + + override val lifecycle: Lifecycle = lifecycleRegistry +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/ViewKtTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/ViewKtTest.kt new file mode 100644 index 0000000000..6dfde6f9fa --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/ViewKtTest.kt @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.ext + +import android.view.View +import androidx.lifecycle.Lifecycle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.TestAction +import mozilla.components.lib.state.TestState +import mozilla.components.lib.state.reducer +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.mock +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doNothing +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +@ExperimentalCoroutinesApi +class ViewKtTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Test + @Synchronized + fun `consumeFrom reads states from store`() { + val view = mock<View>() + val owner = MockedLifecycleOwner(Lifecycle.State.INITIALIZED) + + val store = Store( + TestState(counter = 23), + ::reducer, + ) + + val onAttachListener = argumentCaptor<View.OnAttachStateChangeListener>() + var receivedValue = 0 + var latch = CountDownLatch(1) + doNothing().`when`(view).addOnAttachStateChangeListener(onAttachListener.capture()) + + view.consumeFrom(store, owner) { state -> + receivedValue = state.counter + latch.countDown() + } + + // Nothing received yet. + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Updating state: Nothing received yet. + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(0, receivedValue) + + // Switching to STARTED state: Receiving initial state + owner.lifecycleRegistry.currentState = Lifecycle.State.STARTED + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(24, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(25, receivedValue) + latch = CountDownLatch(1) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertTrue(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + latch = CountDownLatch(1) + + // View gets detached + onAttachListener.value.onViewDetachedFromWindow(view) + + store.dispatch(TestAction.IncrementAction).joinBlocking() + assertFalse(latch.await(1, TimeUnit.SECONDS)) + assertEquals(26, receivedValue) + } +} diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/AbstractBindingTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/AbstractBindingTest.kt new file mode 100644 index 0000000000..5173ddc39e --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/AbstractBindingTest.kt @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.lib.state.helpers + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.TestAction +import mozilla.components.lib.state.TestState +import mozilla.components.lib.state.reducer +import mozilla.components.support.test.ext.joinBlocking +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class AbstractBindingTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Test + fun `binding onState is invoked when a flow is created`() { + val store = Store( + TestState(counter = 0), + ::reducer, + ) + + val binding = TestBinding(store) + + assertFalse(binding.invoked) + + binding.start() + + assertTrue(binding.invoked) + } + + @Test + fun `binding has no state changes when only stop is invoked`() { + val store = Store( + TestState(counter = 0), + ::reducer, + ) + + val binding = TestBinding(store) + + assertFalse(binding.invoked) + + binding.stop() + + assertFalse(binding.invoked) + } + + @Test + fun `binding does not get state updates after stopped`() { + val store = Store( + TestState(counter = 0), + ::reducer, + ) + + var counter = 0 + + val binding = TestBinding(store) { + counter++ + // After we stop, we shouldn't get updates for the third action dispatched. + if (counter >= 3) { + fail() + } + } + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + binding.start() + + store.dispatch(TestAction.IncrementAction).joinBlocking() + + binding.stop() + + store.dispatch(TestAction.IncrementAction).joinBlocking() + } +} + +@ExperimentalCoroutinesApi +class TestBinding( + store: Store<TestState, TestAction>, + private val onStateUpdated: (TestState) -> Unit = {}, +) : AbstractBinding<TestState>(store) { + var invoked = false + override suspend fun onState(flow: Flow<TestState>) { + invoked = true + flow.collect { onStateUpdated(it) } + } +} diff --git a/mobile/android/android-components/components/lib/state/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/lib/state/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/mobile/android/android-components/components/lib/state/src/test/resources/robolectric.properties b/mobile/android/android-components/components/lib/state/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/lib/state/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 |