commit 50ffa4b542f8e4266df9b8e5fee5fb5baa5c92c9
parent fe58dad23dbf1c21182b2ac3150ce176e9303357
Author: Mugurell <Mugurell@users.noreply.github.com>
Date: Mon, 17 Nov 2025 17:52:50 +0000
Bug 1996676 - New APIs to build and survive Stores across activity recreations r=android-reviewers,nalexander,pollymce,matt-tighe
These will allow to create new Stores with potentially new Reducers or
Middlewares but with the old State which would be persisted for a
previously built Store.
Differential Revision: https://phabricator.services.mozilla.com/D270501
Diffstat:
6 files changed, 403 insertions(+), 0 deletions(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
@@ -152,6 +152,7 @@ androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-p
androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" }
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
diff --git a/mobile/android/android-components/components/lib/state/README.md b/mobile/android/android-components/components/lib/state/README.md
@@ -50,6 +50,18 @@ val store = Store<State, Action>(
)
```
+To ensure the store survives across `Activity` recreations there are various helpers like `fragmentStore` which will persist the most recent `State` in a `ViewModel` and then allow to restore it in new `Store` instances.
+This is especially helpful if you need to pass lifecycle aware dependencies to the `Store` `Middleware` as it will allow avoiding memory leaks.
+
+```Kotlin
+val store by fragmentStore(State()) { restoredState ->
+ Store(
+ initialState = restoredState,
+ middleware = listOf(Middleware(requireContext()),
+ )
+}
+```
+
Once the store is created, you can react to changes in the state by registering an observer.
```Kotlin
diff --git a/mobile/android/android-components/components/lib/state/build.gradle b/mobile/android/android-components/components/lib/state/build.gradle
@@ -29,6 +29,8 @@ dependencies {
implementation libs.androidx.fragment
implementation libs.androidx.lifecycle.compose
implementation libs.androidx.lifecycle.process
+ implementation libs.androidx.lifecycle.viewmodel.compose
+ implementation libs.androidx.navigation.fragment
implementation libs.kotlinx.coroutines
testImplementation project(':components:support-test')
diff --git a/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/StoreProvider.kt b/mobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/StoreProvider.kt
@@ -0,0 +1,270 @@
+/* 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.activity.ComponentActivity
+import androidx.annotation.MainThread
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.navigation.NavBackStackEntry
+import mozilla.components.lib.state.Action
+import mozilla.components.lib.state.State
+import mozilla.components.lib.state.Store
+
+/**
+ * Generic [ViewModel] wrapper for persisting the [State] of a particular [Store] between activity recreations.
+ *
+ * @param owner [ViewModelStoreOwner] whose lifecycle will be used to persist the various [State]s.
+ */
+class StoreProvider<T> private constructor(
+ owner: T,
+) : ViewModel() where T : LifecycleOwner {
+ /**
+ * Map of the current [Store]s built by this [StoreProvider].
+ * This is needed to be able to access and persist the latest [State] just before [owner] is destroyed.
+ *
+ * The key used is the name of the [Store<*, *>] java class.
+ */
+ @PublishedApi
+ internal val stores: MutableMap<String, Store<*, *>> = mutableMapOf()
+
+ /**
+ * Map of the currently persisted [State]s.
+ *
+ * The key used is the name of the [Store<*, *>] java class.
+ */
+ @PublishedApi
+ internal val states: MutableMap<String, State> = mutableMapOf()
+
+ init {
+ (owner as LifecycleOwner).lifecycle.addObserver(
+ object : DefaultLifecycleObserver {
+ override fun onDestroy(owner: LifecycleOwner) {
+ states.clear()
+ stores.forEach { states[it.key] = it.value.state }
+ stores.clear()
+ }
+ },
+ )
+ }
+
+ /**
+ * Returns an existing [Store] of the requested type __if__ it was already build
+ * in the current scope (usually, a fragment or an activity).
+ *
+ * This can return `null` if the requested [Store] was __not__ built again after activity recreations
+ * even if it has it's state persisted.
+ */
+ @MainThread
+ inline fun <reified ST : Store<*, *>> get(): ST? {
+ return stores[ST::class.java.name] as? ST
+ }
+
+ /**
+ * Returns an existing [Store] or allows creating a new one in the current scope
+ * (usually, a fragment or an activity).
+ *
+ * The created Store is associated with the given scope and its will be retained as long as the scope is alive
+ * (e.g. if it is an activity, until it is finished or process is killed).
+ *
+ * @param factory Custom builder for the new [Store] instance, used only if a [Store] of this type
+ * was not already built and persisted in the current scope.
+ * This will receive the persisted state (if available) of the previously built [Store] of the same type
+ * allowing to build build a new [Store] instance that will now be persisted.
+ */
+ @MainThread
+ inline fun <reified S : State, reified ST : Store<S, *>> get(
+ noinline factory: (S?) -> ST,
+ ): ST {
+ return stores[ST::class.java.name] as? ST
+ ?: factory(states[ST::class.java.name] as? S).also {
+ stores[ST::class.java.name] = it
+ }
+ }
+
+ /**
+ * Build a new [Store] with the provided [factory] and [initialState].
+ * If this provider already built this store type then the new instance will use the [State] of the previous.
+ */
+ @PublishedApi
+ internal inline fun <reified S : State, reified ST : Store<S, *>> buildStore(
+ initialState: S,
+ factory: (S) -> ST,
+ ): ST {
+ val state = (stores[ST::class.java.name] as? ST)?.state // most up-to-date state
+ ?: (states[ST::class.java.name] as? S) // persisted state
+ ?: initialState
+
+ return factory(state).also {
+ stores[ST::class.java.name] = it
+ }
+ }
+
+ companion object {
+ /**
+ * Get a [StoreProvider] for the current [LifecycleOwner].
+ */
+ @Suppress("UNCHECKED_CAST")
+ val <O> O.storeProvider: StoreProvider<O> where O : LifecycleOwner, O : ViewModelStoreOwner
+ get() = ViewModelProvider(this, DataPersisterFactory(this))[StoreProvider::class.java] as StoreProvider<O>
+
+ /**
+ * Build a [Store] with the provided parameters which will live as long as this [Fragment],
+ * surviving configuration changes and activity recreations until this [Fragment]
+ * is removed from the backstack or until process death.
+ *
+ * @param initialState The initial state until a dispatched [Action] creates a new state.
+ * @param factory A function that receives [initialState] or the persisted state (if available)
+ * of the previously built [Store] of the same type allowing to build build a new [Store] instance
+ * to persist.
+ *
+ * This property can be accessed only after this Fragment is attached i.e., after Fragment.onAttach(),
+ * with access prior to that resulting in IllegalArgumentException.
+ */
+ @MainThread
+ inline fun <reified S : State, A : Action, reified ST : Store<S, A>> Fragment.fragmentStore(
+ initialState: S,
+ noinline factory: (S) -> ST,
+ ): Lazy<ST> = buildPersistentStore(initialState, factory)
+
+ /**
+ * Build a [Store] with the provided parameters which will live as long as this it's parent [AppCompatActivity],
+ * surviving configuration changes and activity recreations until this [AppCompatActivity]
+ * is finished or until process death.
+ *
+ * @param initialState The initial state until a dispatched [Action] creates a new state.
+ * @param factory A function that receives [initialState] or the persisted state (if available)
+ * of the previously built [Store] of the same type allowing to build build a new [Store] instance
+ * to persist.
+ *
+ * This property can be accessed only after this Fragment is attached i.e., after Fragment.onAttach(),
+ * with access prior to that resulting in IllegalArgumentException.
+ */
+ @MainThread
+ inline fun <reified S : State, A : Action, reified ST : Store<S, A>> Fragment.activityStore(
+ initialState: S,
+ noinline factory: (S) -> ST,
+ ): Lazy<ST> = requireActivity().buildPersistentStore(initialState, factory)
+
+ /**
+ * Build a [Store] with the provided parameters which will live as long as this [AppCompatActivity],
+ * surviving configuration changes and activity recreations until this [AppCompatActivity]
+ * is finished or until process death.
+ *
+ * @param initialState The initial state until a dispatched [Action] creates a new state.
+ * @param factory A function that receives [initialState] or the persisted state (if available)
+ * of the previously built [Store] of the same type allowing to build build a new [Store] instance
+ * to persist.
+ *
+ * This property can be accessed only after this Fragment is attached i.e., after Fragment.onAttach(),
+ * with access prior to that resulting in IllegalArgumentException.
+ */
+ @MainThread
+ inline fun <reified S : State, A : Action, reified ST : Store<S, A>> ComponentActivity.activityStore(
+ initialState: S,
+ noinline factory: (S) -> ST,
+ ): Lazy<ST> = buildPersistentStore(initialState, factory)
+
+ /**
+ * Build a [Store] with the provided parameters which will live as long as this the
+ * [Fragment] or [AppCompatActivity] in which this composable is shown, surviving configuration changes
+ * and activity recreations until this [AppCompatActivity] until the [Fragment] is removed from the
+ * backstack, the [AppCompatActivity] is finished or until process death.
+ *
+ * The lifecycle of the created [Store] is managed by having it associated with the current
+ * LocalViewModelStoreOwner and LocalLifecycleOwner available in the current composition.
+ *
+ * @param initialState The initial state until a dispatched [Action] creates a new state.
+ * @param factory A function that receives [initialState] or the persisted state (if available)
+ * of the previously built [Store] of the same type allowing to build build a new [Store] instance
+ * to persist.
+ */
+ @Composable
+ inline fun <reified S : State, A : Action, reified ST : Store<S, A>> composableStore(
+ initialState: S,
+ noinline factory: (S) -> ST,
+ ): Lazy<ST> {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
+ "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
+ }
+
+ return remember(viewModelStoreOwner) {
+ ComposableStoreOwner(lifecycleOwner, viewModelStoreOwner)
+ .buildPersistentStore(initialState, factory)
+ }
+ }
+
+ /**
+ * Build a [Store] with the provided parameters which will live as long as this [NavBackStackEntry],
+ * which may represent a single destination or a nested navigation graph allowing the store to
+ * survive configuration changes and activity recreations until this [NavBackStackEntry]
+ * is removed from the navigation backstack or until process death.
+ *
+ * @param initialState The initial state until a dispatched [Action] creates a new state.
+ * @param factory A function that receives [initialState] or the persisted state (if available)
+ * of the previously built [Store] of the same type allowing to build build a new [Store] instance
+ * to persist.
+ */
+ @MainThread
+ inline fun <reified S : State, A : Action, reified ST : Store<S, A>> NavBackStackEntry.navBackStackStore(
+ initialState: S,
+ noinline factory: (S) -> ST,
+ ): Lazy<ST> = buildPersistentStore(initialState, factory)
+
+ /**
+ * Helper in building a new [Store] with the provided parameters
+ * that will have it's state persisted between Activity recreations.
+ *
+ * If a [State] is already persisted this will be used instead of [initialState] for the new [Store].
+ *
+ * @param initialState The initial state until a dispatched [Action] creates a new state.
+ * @param factory A function that receives [initialState] or the persisted state (if available)
+ * of the previously built [Store] of the same type allowing to build build a new [Store] instance
+ * to persist.
+ */
+ @Suppress("UNCHECKED_CAST")
+ @MainThread
+ @PublishedApi
+ internal inline fun <reified S : State, A : Action, reified ST : Store<S, A>, O> O.buildPersistentStore(
+ initialState: S,
+ noinline factory: (S) -> ST,
+ ): Lazy<ST> where O : LifecycleOwner, O : ViewModelStoreOwner {
+ return lazy(mode = LazyThreadSafetyMode.NONE) {
+ val provider = ViewModelProvider(
+ this,
+ DataPersisterFactory(this),
+ )[StoreProvider::class.java] as StoreProvider<O>
+
+ provider.buildStore(initialState, factory)
+ }
+ }
+
+ @PublishedApi
+ internal class DataPersisterFactory<T>(
+ private val owner: T,
+ ) : ViewModelProvider.Factory where T : LifecycleOwner {
+ @Suppress("UNCHECKED_CAST")
+ override fun <VM : ViewModel> create(modelClass: Class<VM>): VM {
+ return StoreProvider(owner) as VM
+ }
+ }
+
+ @PublishedApi
+ internal class ComposableStoreOwner(
+ lifecycleOwnerDelegate: LifecycleOwner,
+ viewModelOwnerDelegate: ViewModelStoreOwner,
+ ) : LifecycleOwner by lifecycleOwnerDelegate, ViewModelStoreOwner by viewModelOwnerDelegate
+ }
+}
diff --git a/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/StoreProviderTest.kt b/mobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/StoreProviderTest.kt
@@ -0,0 +1,116 @@
+/* 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.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.lib.state.Store
+import mozilla.components.lib.state.TestAction
+import mozilla.components.lib.state.TestState
+import mozilla.components.lib.state.helpers.StoreProvider.Companion.buildPersistentStore
+import mozilla.components.lib.state.helpers.StoreProvider.Companion.storeProvider
+import mozilla.components.lib.state.reducer
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class StoreProviderTest {
+ private val lifecycleOwner = MockedLifecycleOwner(Lifecycle.State.STARTED)
+
+ @Test
+ fun `GIVEN same store type was built before WHEN building a new store THEN reuse the state of the previous store`() {
+ var store = lifecycleOwner.buildPersistentStore(TestState(23)) { TestStore(it) }.value
+ assertEquals(23, store.state.counter)
+ var store2 = lifecycleOwner.buildPersistentStore(TestState(123)) { TestStore2(it) }.value
+ assertEquals(123, store2.state.counter)
+
+ lifecycleOwner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+
+ store = lifecycleOwner.buildPersistentStore(TestState(99)) { TestStore(it) }.value
+ assertEquals(23, store.state.counter)
+ store2 = lifecycleOwner.buildPersistentStore(TestState(999)) { TestStore2(it) }.value
+ assertEquals(123, store2.state.counter)
+ }
+
+ @Test
+ fun `GIVEN same store type was built before WHEN asking for it THEN return the previously built instance`() {
+ val store = lifecycleOwner.buildPersistentStore(TestState(23)) { TestStore(it) }.value
+ val store2 = lifecycleOwner.buildPersistentStore(TestState(123)) { TestStore2(it) }.value
+ val store3 = lifecycleOwner.storeProvider.get<TestState, TestStore3> { TestStore3(TestState(1234)) }
+
+ var result1 = lifecycleOwner.storeProvider.get<TestStore>()
+ var result2: TestStore2? = lifecycleOwner.storeProvider.get()
+ var result3: TestStore3 = lifecycleOwner.storeProvider.get<TestState, TestStore3> { TestStore3(TestState(9999)) }
+ assertEquals(store, result1)
+ assertEquals(store2, result2)
+ assertEquals(store3, result3)
+
+ lifecycleOwner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+
+ result1 = lifecycleOwner.storeProvider.get<TestStore>()
+ result2 = lifecycleOwner.storeProvider.get()
+ result3 = lifecycleOwner.storeProvider.get<TestState, TestStore3> { restoredState ->
+ TestStore3(restoredState ?: TestState(9999))
+ }
+ assertNull(result1)
+ assertNull(result2)
+ assertEquals(store3.state, result3.state)
+ }
+
+ @Test
+ fun `GIVEN a store is updated after being built WHEN building a new store of the same type THEN reuse the latest state`() {
+ val initialState = TestState(23)
+ var store = lifecycleOwner.buildPersistentStore(initialState) { Store(it, ::reducer) }.value
+ assertEquals(23, store.state.counter)
+
+ store.dispatch(TestAction.IncrementAction)
+ val updatedState = initialState.copy(counter = 24)
+
+ lifecycleOwner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+
+ store = lifecycleOwner.buildPersistentStore(initialState) { Store(it, ::reducer) }.value
+ assertEquals(updatedState.counter, store.state.counter)
+ }
+}
+
+private class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner, ViewModelStoreOwner {
+ val lifecycleRegistry = LifecycleRegistry(this).apply {
+ currentState = initialState
+ }
+
+ override val lifecycle: Lifecycle = lifecycleRegistry
+
+ override val viewModelStore: ViewModelStore = ViewModelStore()
+}
+
+private class TestStore(
+ initialState: TestState = TestState(1),
+) : Store<TestState, TestAction>(
+ initialState = initialState,
+ reducer = ::reducer,
+ middleware = emptyList(),
+)
+
+private class TestStore2(
+ initialState: TestState = TestState(2),
+) : Store<TestState, TestAction>(
+ initialState = initialState,
+ reducer = ::reducer,
+ middleware = emptyList(),
+)
+
+private class TestStore3(
+ initialState: TestState = TestState(3),
+) : Store<TestState, TestAction>(
+ initialState = initialState,
+ reducer = ::reducer,
+ middleware = emptyList(),
+)
diff --git a/mobile/android/android-components/docs/changelog.md b/mobile/android/android-components/docs/changelog.md
@@ -14,6 +14,8 @@ permalink: /changelog/
* 🆕 New `EngineViewScrollingDataBehavior` meant to only be used to animate a bottom toolbar/banner in sync with the current webpage [Bug 1991654](https://bugzilla.mozilla.org/show_bug.cgi?id=1991654).
* **concept-engine** and **browser-engine-gecko**
* 🆕 New `verticalScrollPosition` and `verticalScrollDelta` APIs exposing the current scroll position and delta of the webpage [Bug 1990215](https://bugzilla.mozilla.org/show_bug.cgi?id=1990215).
+* **lib-state**
+ * 🆕 New `fragmentStore`, `activityStore`, `composableStore` and `navBackStackStore` APIs available to build a new Store and persist its State in a ViewModel ensuring that it survives Activity recreations. These APIs supersede the existing ones and avoid the possibility of memory leaks. [Bug 1996676](https://bugzilla.mozilla.org/show_bug.cgi?id=1996676).
# 146.0