diff --git a/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/AbstractBinding.kt b/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/AbstractBinding.kt new file mode 100644 index 00000000000..1d9ff516a77 --- /dev/null +++ b/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( + private val store: Store +) : 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) +} diff --git a/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/AbstractBindingTest.kt b/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/AbstractBindingTest.kt new file mode 100644 index 00000000000..8b8450e73e1 --- /dev/null +++ b/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/AbstractBindingTest.kt @@ -0,0 +1,100 @@ +/* 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 kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.TestCoroutineDispatcher +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(TestCoroutineDispatcher()) + + @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, + private val onStateUpdated: (TestState) -> Unit = {} +) : AbstractBinding(store) { + var invoked = false + override suspend fun onState(flow: Flow) { + invoked = true + flow.collect { onStateUpdated(it) } + } +} diff --git a/docs/changelog.md b/docs/changelog.md index b20596e529e..9508831dc60 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -49,6 +49,16 @@ permalink: /changelog/ * 🌟️ New APIs for managing keys - `ManagedKey`, `KeyProvider` and `KeyRecoveryHandler`. * 🌟️ `AutofillCreditCardsAddressesStorage` implements these APIs for managing keys for credit card storage. +* **lib-state** + * 🌟️ Added `AbstractBinding` for simple features that want to observe changes to the `State` in a `Store` without needing to manually manage the CoroutineScope. This can now be handled like other `LifecycleAwareFeature` implementations: + ```kotlin + class SimpleFeature(store: BrowserStore) : AbstractBinding(store) { + override suspend fun onState(flow: Flow) { + // Interact with flowable state. + } + } + ``` + # 75.0.0 * [Commits](https://github.com/mozilla-mobile/android-components/compare/v74.0.0...v75.0.0)