summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/lib/state
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/android-components/components/lib/state')
-rw-r--r--mobile/android/android-components/components/lib/state/README.md69
-rw-r--r--mobile/android/android-components/components/lib/state/build.gradle69
-rw-r--r--mobile/android/android-components/components/lib/state/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/lib/state/src/androidTest/java/mozilla/components/lib/state/ext/ComposeExtensionsKtTest.kt182
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/AndroidManifest.xml8
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Action.kt14
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/DelicateAction.kt23
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Middleware.kt53
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Observer.kt10
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Reducer.kt13
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/State.kt10
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/Store.kt187
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/ComposeExtensions.kt147
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/Fragment.kt105
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/StoreExtensions.kt265
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/ext/View.kt39
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/AbstractBinding.kt44
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/ReducerChainBuilder.kt67
-rw-r--r--mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/internal/StoreThreadFactory.kt58
-rw-r--r--mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreExceptionTest.kt33
-rw-r--r--mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/StoreTest.kt311
-rw-r--r--mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/FragmentKtTest.kt301
-rw-r--r--mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/StoreExtensionsKtTest.kt572
-rw-r--r--mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/ext/ViewKtTest.kt89
-rw-r--r--mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/AbstractBindingTest.kt98
-rw-r--r--mobile/android/android-components/components/lib/state/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/lib/state/src/test/resources/robolectric.properties1
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** &mdash;
+ * 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