Commit f04bc5b3 authored by Sebastian Kaspari's avatar Sebastian Kaspari
Browse files

browser-state: Move state handling to generic lib-state component.

parent d6b7787a
......@@ -288,6 +288,10 @@ projects:
path: components/lib/push-firebase
description: 'An implementation of concept-push for the Firebase Message Service.'
publish: true
lib-state:
path: components/lib/state
description: 'A library for maintaining application state.'
publish: true
tooling-lint:
path: components/tooling/lint
description: 'Custom Lint checks for using and writing components.'
......
......@@ -206,6 +206,8 @@ _Supporting components with generic helper code._
*[**Public Suffix List**](components/lib/publicsuffixlist/README.md) - A library for reading and using the [public suffix list](https://publicsuffix.org/).
*[**State**](components/lib/state/README.md) - A library for maintaining application state.
* 🔴 [**Push-Firebase**](components/lib/push-firebase/README.md) - A [concept-push](concept/push/README.md) implementation using [Firebase Cloud Messaging](https://firebase.google.com/products/cloud-messaging/).
## Tooling
......
......@@ -25,6 +25,7 @@ dependencies {
implementation project(':concept-engine')
implementation project(':support-utils')
implementation project(':support-ktx')
implementation project(':lib-state')
implementation Dependencies.kotlin_stdlib
implementation Dependencies.kotlin_coroutines
......
......@@ -7,6 +7,7 @@ package mozilla.components.browser.session.action
import mozilla.components.browser.session.state.SessionState
import mozilla.components.browser.session.state.BrowserState
import mozilla.components.concept.engine.HitResult
import mozilla.components.lib.state.Action
/**
* [Action] implementation related to [BrowserState].
......
......@@ -4,7 +4,6 @@
package mozilla.components.browser.session.reducer
import mozilla.components.browser.session.action.Action
import mozilla.components.browser.session.action.BrowserAction
import mozilla.components.browser.session.action.SessionAction
import mozilla.components.browser.session.action.SessionListAction
......@@ -12,7 +11,7 @@ import mozilla.components.browser.session.selector.findSession
import mozilla.components.browser.session.state.BrowserState
import mozilla.components.browser.session.state.SessionState
import mozilla.components.browser.session.store.BrowserStore
import mozilla.components.browser.session.store.Reducer
import mozilla.components.lib.state.Action
/**
* Reducers for [BrowserStore].
......@@ -21,15 +20,7 @@ import mozilla.components.browser.session.store.Reducer
* [BrowserState].
*/
internal object BrowserReducers {
fun get(): List<Reducer> = listOf(
::reduce
)
private fun reduce(state: BrowserState, action: Action): BrowserState {
if (action !is BrowserAction) {
return state
}
fun reduce(state: BrowserState, action: BrowserAction): BrowserState {
return when (action) {
is SessionListAction -> reduceSessionListAction(state, action)
is SessionAction -> reduceSessionAction(state, action)
......
......@@ -4,6 +4,8 @@
package mozilla.components.browser.session.state
import mozilla.components.lib.state.State
/**
* Value type that represents the complete state of the browser/engine.
*
......@@ -13,4 +15,4 @@ package mozilla.components.browser.session.state
data class BrowserState(
val sessions: List<SessionState> = emptyList(),
val selectedSessionId: String? = null
)
) : State
......@@ -4,16 +4,11 @@
package mozilla.components.browser.session.store
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import mozilla.components.browser.session.action.Action
import mozilla.components.browser.session.action.BrowserAction
import mozilla.components.browser.session.reducer.BrowserReducers
import mozilla.components.browser.session.state.BrowserState
import java.util.concurrent.Executors
typealias Reducer = (BrowserState, Action) -> BrowserState
typealias Observer = (BrowserState) -> Unit
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.Store
/**
* The [BrowserStore] holds the [BrowserState] (state tree).
......@@ -22,98 +17,7 @@ typealias Observer = (BrowserState) -> Unit
*/
class BrowserStore(
initialState: BrowserState
) {
private val reducers: List<Reducer> = BrowserReducers.get()
private val storeContext = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val storeScope = CoroutineScope(storeContext)
private val subscriptions: MutableList<Subscription> = mutableListOf()
private var currentState = initialState
/**
* Dispatch an [Action] to the store in order to trigger a [BrowserState] change.
*
*
*/
fun dispatch(action: Action) = storeScope.launch(storeContext) {
dispatchInternal(action)
}
@Synchronized
private fun dispatchInternal(action: Action) {
val newState = reduceState(currentState, action, reducers)
if (newState == currentState) {
// Nothing has changed.
return
}
currentState = newState
synchronized(subscriptions) {
subscriptions.forEach { it.observer.invoke(currentState) }
}
}
/**
* Returns the current state.
*/
val state: BrowserState
get() = currentState
/**
* Registers an observer function that will be invoked whenever the state changes.
*
* @param receiveInitialState If true the observer function will be invoked immediately with the current state.
* @return A subscription object that can be used to unsubscribe from further state changes.
*/
fun observe(receiveInitialState: Boolean = true, observer: Observer): Subscription {
val subscription = Subscription(observer, store = this)
synchronized(subscriptions) {
subscriptions.add(subscription)
}
if (receiveInitialState) {
observer.invoke(currentState)
}
return subscription
}
private fun removeSubscription(subscription: Subscription) {
synchronized(subscriptions) {
subscriptions.remove(subscription)
}
}
/**
* A [Subscription] is returned whenever an observer is registered via the [observe] method. Calling [unsubscribe]
* on the [Subscription] will unregister the observer.
*/
class Subscription internal constructor(
internal val observer: Observer,
private val store: BrowserStore
) {
var binding: Binding? = null
fun unsubscribe() {
store.removeSubscription(this)
binding?.unbind()
}
interface Binding {
fun unbind()
}
}
}
private fun reduceState(state: BrowserState, action: Action, reducers: List<Reducer>): BrowserState {
var current = state
reducers.forEach { reducer ->
current = reducer.invoke(current, action)
}
return current
}
) : Store<BrowserState, BrowserAction>(
initialState,
BrowserReducers::reduce
)
# [Android Components](../../../README.md) > Libraries > State
A generic library for maintaining the state of a component, screen or application.
## 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}"
```
## 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/
/* 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/. */
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion config.compileSdkVersion
defaultConfig {
minSdkVersion config.minSdkVersion
targetSdkVersion config.targetSdkVersion
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation Dependencies.kotlin_stdlib
implementation Dependencies.kotlin_coroutines
implementation Dependencies.androidx_lifecycle_extensions
testImplementation project(':support-test')
testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.androidx_test_junit
testImplementation Dependencies.testing_robolectric
testImplementation Dependencies.testing_coroutines
testImplementation Dependencies.testing_mockito
}
apply from: '../../../publish.gradle'
ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
# 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
<!-- 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 package="mozilla.components.lib.state">
<application />
</manifest>
......@@ -2,15 +2,12 @@
* 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.browser.session.action
import mozilla.components.browser.session.store.BrowserStore
import mozilla.components.browser.session.state.BrowserState
package mozilla.components.lib.state
/**
* Generic interface for actions to be dispatched on a [BrowserStore].
* Generic interface for actions to be dispatched on a [Store].
*
* Actions are used to send data from the application to a [BrowserStore]. The [BrowserStore] will use the [Action] to
* derive a new [BrowserState].
* Actions are used to send data from the application to a [Store]. The [Store] will use the [Action] to
* derive a new [State].
*/
interface Action
/* 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
/* 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.annotation.CheckResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
import java.util.concurrent.Executors
typealias Reducer<S, A> = (S, A) -> S
typealias Observer<S> = (S) -> Unit
/**
* 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].
*/
open class Store<S : State, A : Action>(
initialState: S,
private val reducer: Reducer<S, A>
) {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val scope = CoroutineScope(dispatcher)
private val subscriptions: MutableSet<Subscription<S, A>> = mutableSetOf()
private var currentState = initialState
/**
* The current [State].
*/
val state: S
@Synchronized
get() = currentState
/**
* Registers an [Observer] function that will be invoked whenever the [State] changes.
*
* Right after registering the [Observer] will be invoked with the current [State].
*
* 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.
*
* @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)
synchronized(subscriptions) {
subscriptions.add(subscription)
}
observer.invoke(currentState)
return subscription
}
/**
* Dispatch an [Action] to the store in order to trigger a [State] change.
*/
fun dispatch(action: A) = scope.launch(dispatcher) {
dispatchInternal(action)
}
@Synchronized
private fun dispatchInternal(action: A) {
val newState = reducer(currentState, action)
if (newState == currentState) {
// Nothing has changed.
return
}
currentState = newState
synchronized(subscriptions) {
subscriptions.forEach { it.observer.invoke(currentState) }
}
}
private fun removeSubscription(subscription: Subscription<S, A>) {
synchronized(subscriptions) {
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
fun unsubscribe() {
storeReference.get()?.removeSubscription(this)
binding?.unbind()
}
interface Binding {
fun unbind()
}
}
}
......@@ -2,57 +2,84 @@
* 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.browser.session.ext
package mozilla.components.lib.state.ext
import android.view.View
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.OnLifecycleEvent
import mozilla.components.browser.session.store.BrowserStore
import mozilla.components.browser.session.store.Observer
import androidx.lifecycle.ProcessLifecycleOwner
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 [BrowserStore.Subscription]
* 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.
*
* Right after registering the [Observer] will be invoked with the current [State].
*/
fun BrowserStore.observe(
fun <S : State, A : Action> Store<S, A>.observe(
owner: LifecycleOwner,
receiveInitialState: Boolean = true,
observer: Observer
): BrowserStore.Subscription {
return observe(receiveInitialState, observer).apply {
binding = LifecycleBoundObserver(owner, this)
observer: Observer<S>
) {
if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) {
// This owner is already destroyed. No need to register.
return
}
val subscription = observeManually(observer)
subscription.binding = SubscriptionLifecycleBinding(owner, subscription).apply {
owner.lifecycle.addObserver(this)
}
}
/**
* Registers an [Observer] function that will be invoked whenever the state changes. The [BrowserStore.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.
*
* Right after registering the [Observer] will be invoked with the current [State].
*/
fun BrowserStore.observe(
fun <S : State, A : Action> Store<S, A>.observe(
view: View,
observer: Observer,
receiveInitialState: Boolean = true
): BrowserStore.Subscription {
return observe(receiveInitialState, observer).apply {
binding = ViewBoundObserver(view, this)
observer: Observer<S>
) {
val subscription = observeManually(observer)
subscription.binding = SubscriptionViewBinding(view, subscription).apply {
view.addOnAttachStateChangeListener(this)
}
}
/**
* 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>
) {
observe(ProcessLifecycleOwner.get(), observer)
}
/**
* GenericLifecycleObserver implementation to bind an observer to a Lifecycle.
*/
private class LifecycleBoundObserver(
private class SubscriptionLifecycleBinding<S : State, A : Action>(
private val owner: LifecycleOwner,
private val subscription: BrowserStore.Subscription
) : LifecycleObserver, BrowserStore.Subscription.Binding {
private val subscription: Store.Subscription<S, A>
) : LifecycleObserver, Store.Subscription.Binding {
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
override fun unbind() {
fun onDestroy() {
subscription.unsubscribe()
}
override fun unbind() {
owner.lifecycle.removeObserver(this)
}
}
......@@ -60,10 +87,10 @@ private class LifecycleBoundObserver(
/**
* View.OnAttachStateChangeListener implementation to bind an observer to a View.
*/
private class ViewBoundObserver(
private class SubscriptionViewBinding<S : State, A : Action>(
private val view: View,
private val subscription: BrowserStore.Subscription
) : View.OnAttachStateChangeListener, BrowserStore.Subscription.Binding {
private val subscription: Store.Subscription<S, A>
) : View.OnAttachStateChangeListener, Store.Subscription.Binding {
override fun onViewAttachedToWindow(v: View?) = Unit
override fun onViewDetachedFromWindow(view: View) {
......