tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 9857cef5140fec7dc7bb9e913723414d46663691
parent c480a179b2527256d1709061741d142f3bd5ae31
Author: Mugurell <Mugurell@users.noreply.github.com>
Date:   Thu, 20 Nov 2025 09:12:31 +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:
Mgradle/libs.versions.toml | 1+
Mmobile/android/android-components/components/lib/state/README.md | 12++++++++++++
Mmobile/android/android-components/components/lib/state/build.gradle | 2++
Amobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/StoreProvider.kt | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/StoreProviderTest.kt | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/android-components/docs/changelog.md | 2++
6 files changed, 431 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,298 @@ +/* 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( + @PublishedApi + internal var owner: T?, +) : ViewModel() where T : LifecycleOwner { + private var isCleanupConfigured: Boolean = false + + init { + setupCleanup() + } + + /** + * Helper for refreshing the [owner] instance if it was cleared in the meantime, for example in cases like + * the ViewLifecycleOwner from a Fragment being cleared when the user navigates to another fragment but + * then a new one created when the user comes back to the initial Fragment and uses this same ViewModel. + */ + @PublishedApi + internal fun refreshOwner(owner: T) { + if (this.owner == null) { + this.owner = owner + setupCleanup() + } + } + + /** + * 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() + + /** + * 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 + } + } + + @PublishedApi + internal fun setupCleanup() { + if (isCleanupConfigured) { + return + } else { + isCleanupConfigured = true + } + + ((owner as? Fragment)?.viewLifecycleOwner?.lifecycle ?: (owner as LifecycleOwner).lifecycle) + .addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + this@StoreProvider.owner = null + isCleanupConfigured = false + + states.clear() + stores.forEach { states[it.key] = it.value.state } + stores.clear() + } + }, + ) + } + + 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>) + .apply { refreshOwner(this@storeProvider) } + + /** + * 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 should be called only after this Fragment is attached i.e., after Fragment.onAttach(), + * and the result be used i.e., by calling _.value()_ only in or after after Fragment.onCreateView() + * with access prior to that resulting in IllegalStateException. + */ + @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 should be called only after this Fragment is attached i.e., after Fragment.onAttach(), + * with access prior to that resulting in IllegalStateException. + */ + @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 should be called only after this Activity is attached to the Application + * with access prior to that will result in IllegalStateException. + */ + @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) { + storeProvider.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