tor-browser

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

commit f717645abdf56b04bae6aaa510a03e2a65debcef
parent 4dcb947682526e27fbb8196b1bf8634657bc8df5
Author: Norisz Fay <nfay@mozilla.com>
Date:   Tue, 18 Nov 2025 04:03:38 +0200

Revert "Bug 1996643 , Bug 1996676 - part 9 - Remove the Fenix specific StoreProvider in favor of the upstream one r=android-reviewers,matt-tighe,nalexander" for causing fenix-debug failures

This reverts commit 29b0098fccac21323bcba0ddf411ce6df24734ef.

Revert "Bug 1996643 - part 8 - Migrate from the LifecycleHolder idiom within DoH settings r=android-reviewers,matt-tighe,nalexander"

This reverts commit 2c8ade6308ae273882889432db500bd72ca4b8c8.

Revert "Bug 1996643 - part 7 - Migrate from the LifecycleHolder idiom within saved logins r=android-reviewers,matt-tighe,nalexander"

This reverts commit 5d4c722bec61a35d028bb1357492ac1f255dad73.

Revert "Bug 1996643 - part 6 - Migrate from the LifecycleHolder idiom within composable bookmarks r=android-reviewers,matt-tighe,nalexander"

This reverts commit 1f77a5472a4fd3fde7f3937791b5ef5983a52c19.

Revert "Bug 1996643 - part 5 - Migrate from the environment idiom within SettingsSearchStore r=android-reviewers,matt-tighe"

This reverts commit a49e6d408f5a4e7f09d27065648ee468f1dbf835.

Revert "Bug 1996643 - part 4 - Migrate from the environment idiom within AddressStore r=android-reviewers,matt-tighe,nalexander"

This reverts commit aff8db9d245cd2abf6eb5c4dd01cd95cf4d7b362.

Revert "Bug 1996643 - part 3 - Migrate from the environment idiom within BrowserScreenStore r=android-reviewers,matt-tighe,nalexander"

This reverts commit 1aac42db3a1bd617c4864aee4be43ce395231ea7.

Revert "Bug 1996643 - part 2 - Migrate from the environment idiom for the composable toolbar r=android-reviewers,skhan,nalexander"

This reverts commit 24cc01672db4982ab737dee40244e2b4969b5ba6.

Revert "Bug 1996643 - part 1 - Remove lazyStore in favor of the new StoreProvider APIs r=android-reviewers,nalexander"

This reverts commit 90fa454a60177b0fa2fed413525dfbc99b814f42.

Revert "Bug 1996676 - New APIs to build and survive Stores across activity recreations r=android-reviewers,nalexander,pollymce,matt-tighe"

This reverts commit 50ffa4b542f8e4266df9b8e5fee5fb5baa5c92c9.

Diffstat:
Mgradle/libs.versions.toml | 1-
Mmobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarStore.kt | 4+++-
Amobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/Environment.kt | 23+++++++++++++++++++++++
Mmobile/android/android-components/components/lib/state/README.md | 12------------
Mmobile/android/android-components/components/lib/state/build.gradle | 2--
Dmobile/android/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/StoreProvider.kt | 270-------------------------------------------------------------------------------
Dmobile/android/android-components/components/lib/state/src/test/java/mozilla/components/lib/state/helpers/StoreProviderTest.kt | 116-------------------------------------------------------------------------------
Mmobile/android/android-components/docs/changelog.md | 3---
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarkFragment.kt | 156+++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksStore.kt | 21+++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/EditBookmarkFragment.kt | 48+++++++++++++++++++++++++++++-------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt | 27++++++++++-----------------
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserScreenStoreBuilder.kt | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserToolbarStoreBuilder.kt | 89+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/store/BrowserScreenAction.kt | 13+++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/store/BrowserScreenMiddleware.kt | 28+++++++++++++++++++++-------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/store/BrowserScreenStore.kt | 31++++++++++++++++++++++++++++++-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationFragment.kt | 6+++---
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationStore.kt | 2--
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/StoreProvider.kt | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarEnvironment.kt | 38++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddleware.kt | 240++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/CustomTabBrowserToolbarMiddleware.kt | 91++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/CustomTabToolbarEnvironment.kt | 28++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/debugsettings/gleandebugtools/GleanDebugToolsFragment.kt | 14++++++--------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/downloads/listscreen/DownloadFragment.kt | 10++++------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsFragment.kt | 10++++++----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragment.kt | 10++++++----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt | 4++--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/store/HomeToolbarStoreBuilder.kt | 52+++++++++++++++++++++++++++-------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddleware.kt | 176+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt | 109+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragment.kt | 18++++++++++--------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragment.kt | 16+++++++++-------
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/LifecycleHolder.kt | 25+++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesFragment.kt | 8++++----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/OnboardingFragment.kt | 6++----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/store/OnboardingStore.kt | 7++-----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/view/ManagePrivacyPreferencesDialogFragment.kt | 21+++++++++------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/reviewprompt/CustomReviewPromptBottomSheetFragment.kt | 10++++------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserStoreToFenixSearchMapperMiddleware.kt | 24++++++++++++++++++------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddleware.kt | 59++++++++++++++++++++++++++++++++++++-----------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarSearchStatusSyncMiddleware.kt | 44++++++++++++++++++++++++++++++++------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddleware.kt | 48+++++++++++++++++++++++++++++++++---------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/FenixSearchMiddleware.kt | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/SearchFragmentStore.kt | 13+++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarComposable.kt | 64++++++++++++++++++++++++++++++++++------------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt | 26++++++++++++++------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressEditorFragment.kt | 38++++++++++++++++----------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressManagementFragment.kt | 8++++----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressAction.kt | 5+++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressMiddleware.kt | 13+++++++------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressReducer.kt | 2+-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressStructureMiddleware.kt | 3++-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt | 10++++++----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/AutofillSettingFragment.kt | 6++++--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/SecureScreen.kt | 12+++++++-----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementFragment.kt | 8++++----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/doh/DohSettingsFragment.kt | 43++++++++++++++++++++++++++++---------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/doh/DohSettingsStore.kt | 23+++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt | 12+++++++-----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt | 11+++++++----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt | 14++++++++------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt | 60++++++++++++++++++++++++++++++++++++------------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsStore.kt | 3+++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/CookieBannerPanelDialogFragment.kt | 26++++++++++++++------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchAction.kt | 7+++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchEnvironment.kt | 24++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchFragment.kt | 26+++++++++++++++++++++-----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddleware.kt | 48++++++++++++++++++++++++++++++------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchStore.kt | 1+
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt | 26++++++++++++--------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagementFragment.kt | 26++++++++++++--------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/store/TermsOfUsePromptStore.kt | 3+--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/ui/TermsOfUseBottomSheetFragment.kt | 6++----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragment.kt | 26++++++++++++++------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/webcompat/ui/WebCompatReporterFragment.kt | 32+++++++++++---------------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/store/BrowserScreenMiddlewareTest.kt | 36+++++++++++++++++++++++++++++++++---
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/store/BrowserScreenStoreKtTest.kt | 17++++++++++++++++-
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/store/BrowserScreenStoreTest.kt | 8+++++++-
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/StoreProviderTest.kt | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddlewareTest.kt | 684++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/CustomTabBrowserToolbarMiddlewareTest.kt | 56++++++++++++++++++++++++++++++++++++++++----------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddlewareTest.kt | 347++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserStoreToFenixSearchMapperMiddlewareTest.kt | 40+++++++++++++++++++++++++++++++++-------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddlewareTest.kt | 102+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarSearchStatusSyncMiddlewareTest.kt | 54+++++++++++++++++++++++++++++++++++++++++++++---------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddlewareTest.kt | 70+++++++++++++++++++++++++++++++++-------------------------------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/FenixSearchMiddlewareTest.kt | 65+++++++++++++++++++++++++++++++++++------------------------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddlewareTest.kt | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mmobile/android/fenix/docs/architectureexample/HistoryFragmentExample.kt | 18++++++++++--------
91 files changed, 2687 insertions(+), 1805 deletions(-)

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -152,7 +152,6 @@ 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/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarStore.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarStore.kt @@ -19,7 +19,7 @@ import mozilla.components.lib.state.Store /** * [Store] for maintaining the state of the browser toolbar. */ -class BrowserToolbarStore( +open class BrowserToolbarStore( initialState: BrowserToolbarState = BrowserToolbarState(), middleware: List<Middleware<BrowserToolbarState, BrowserToolbarAction>> = emptyList(), ) : Store<BrowserToolbarState, BrowserToolbarAction>( @@ -140,6 +140,8 @@ private fun reduce(state: BrowserToolbarState, action: BrowserToolbarAction): Br ), ) + is EnvironmentRehydrated, + is EnvironmentCleared, is BrowserToolbarEvent, -> { // no-op diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/Environment.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/Environment.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.compose.browser.toolbar.store + +/** + * The current environment in which the browser toolbar is used allowing access to various + * other application features that the toolbar integrates with. + */ +interface Environment + +/** + * Signals a new valid [Environment] has been set. + * + * @property environment The new [Environment]. + */ +data class EnvironmentRehydrated(val environment: Environment) : BrowserToolbarAction + +/** + * Signals the current [Environment] is not valid anymore. + */ +data object EnvironmentCleared : BrowserToolbarAction diff --git a/mobile/android/android-components/components/lib/state/README.md b/mobile/android/android-components/components/lib/state/README.md @@ -50,18 +50,6 @@ 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,8 +29,6 @@ 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 @@ -1,270 +0,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/. */ - -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 @@ -1,116 +0,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/. */ - -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,9 +14,6 @@ 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). - * ⚠️ **Breaking change**: The `lazyStore` API was removed in favor of the new `fragmentStore`, `activityStore` and `composableStore` APIs. [Bug 1996676](https://bugzilla.mozilla.org/show_bug.cgi?id=1996676). # 146.0 diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarkFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarkFragment.kt @@ -15,7 +15,8 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.getSystemService import androidx.fragment.app.Fragment -import androidx.lifecycle.coroutineScope +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDirections import androidx.navigation.NavHostController @@ -23,18 +24,21 @@ import androidx.navigation.fragment.findNavController import mozilla.components.browser.state.state.searchEngines import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.Mode -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R import org.mozilla.fenix.components.QrScanFenixFeature +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.VoiceSearchFeature import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint import org.mozilla.fenix.components.appstate.AppAction import org.mozilla.fenix.components.metrics.MetricsUtils import org.mozilla.fenix.components.search.BOOKMARKS_SEARCH_ENGINE_ID +import org.mozilla.fenix.components.toolbar.BrowserToolbarEnvironment import org.mozilla.fenix.ext.bookmarkStorage import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.nav @@ -47,6 +51,7 @@ import org.mozilla.fenix.search.BrowserToolbarSearchMiddleware import org.mozilla.fenix.search.BrowserToolbarSearchStatusSyncMiddleware import org.mozilla.fenix.search.BrowserToolbarToFenixSearchMapperMiddleware import org.mozilla.fenix.search.FenixSearchMiddleware +import org.mozilla.fenix.search.SearchFragmentAction import org.mozilla.fenix.search.SearchFragmentState import org.mozilla.fenix.search.SearchFragmentStore import org.mozilla.fenix.search.createInitialSearchFragmentState @@ -84,20 +89,22 @@ class BookmarkFragment : Fragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) val toolbarStore = buildToolbarStore() val searchStore = buildSearchStore(toolbarStore) - val buildStore = { composeNavController: NavHostController -> - val homeActivity = (requireActivity() as HomeActivity) - val navController = this@BookmarkFragment.findNavController() + val buildStore = { navController: NavHostController -> + val store = StoreProvider.get(this@BookmarkFragment) { + val lifecycleHolder = LifecycleHolder( + context = requireContext(), + navController = this@BookmarkFragment.findNavController(), + composeNavController = navController, + homeActivity = (requireActivity() as HomeActivity), + ) - val store by fragmentStore( - BookmarksState.default.copy( - sortOrder = BookmarksListSortOrder.fromString( - value = requireContext().settings().bookmarkListSortOrder, - default = BookmarksListSortOrder.Alphabetical(true), - ), - ), - ) { BookmarksStore( - initialState = it, + initialState = BookmarksState.default.copy( + sortOrder = BookmarksListSortOrder.fromString( + value = requireContext().settings().bookmarkListSortOrder, + default = BookmarksListSortOrder.Alphabetical(true), + ), + ), middleware = listOf( // NB: Order matters — this middleware must be first to intercept actions // related to private mode and trigger verification before any other middleware runs. @@ -120,24 +127,24 @@ class BookmarkFragment : Fragment() { false } else { val wasPreviousAppDestinationHome = - navController + lifecycleHolder.navController .previousBackStackEntry?.destination?.id == R.id.homeFragment val browsingMode = - homeActivity.browsingModeManager.mode + lifecycleHolder.homeActivity.browsingModeManager.mode wasPreviousAppDestinationHome || browsingMode.isPrivate }, - getNavController = { composeNavController }, - exitBookmarks = { navController.popBackStack() }, + getNavController = { lifecycleHolder.composeNavController }, + exitBookmarks = { lifecycleHolder.navController.popBackStack() }, navigateToBrowser = { - navController.navigate(R.id.browserFragment) + lifecycleHolder.navController.navigate(R.id.browserFragment) }, navigateToSearch = { - navController.navigate( + lifecycleHolder.navController.navigate( NavGraphDirections.actionGlobalSearchDialog(sessionId = null), ) }, navigateToSignIntoSync = { - navController + lifecycleHolder.navController .navigate( BookmarkFragmentDirections.actionGlobalTurnOnSync( entrypoint = FenixFxAEntryPoint.BookmarkView, @@ -145,7 +152,7 @@ class BookmarkFragment : Fragment() { ) }, shareBookmarks = { bookmarks -> - navController.nav( + lifecycleHolder.navController.nav( R.id.bookmarkFragment, BookmarkFragmentDirections.actionGlobalShareFragment( data = bookmarks.asShareDataArray(), @@ -155,16 +162,16 @@ class BookmarkFragment : Fragment() { showTabsTray = ::showTabTray, resolveFolderTitle = { friendlyRootTitle( - context = context, + context = lifecycleHolder.context, node = it, - rootTitles = composeRootTitles(context), + rootTitles = composeRootTitles(lifecycleHolder.context), ) ?: "" }, getBrowsingMode = { - homeActivity.browsingModeManager.mode + lifecycleHolder.homeActivity.browsingModeManager.mode }, saveBookmarkSortOrder = { - context.settings().bookmarkListSortOrder = + lifecycleHolder.context.settings().bookmarkListSortOrder = it.asString }, lastSavedFolderCache = context.settings().lastSavedFolderCache, @@ -175,9 +182,17 @@ class BookmarkFragment : Fragment() { }, ), ), + lifecycleHolder = lifecycleHolder, ) } + store.lifecycleHolder?.apply { + this.navController = this@BookmarkFragment.findNavController() + this.composeNavController = navController + this.homeActivity = (requireActivity() as HomeActivity) + this.context = requireContext() + } + store } setContent { @@ -209,30 +224,39 @@ class BookmarkFragment : Fragment() { // Default empty store. This is not used without the composable toolbar. BrowserToolbarStore(BrowserToolbarState(mode = Mode.EDIT)) } - else -> fragmentStore(BrowserToolbarState(mode = Mode.EDIT)) { - val lifecycleScope = viewLifecycleOwner.lifecycle.coroutineScope - + else -> StoreProvider.get(this) { BrowserToolbarStore( - initialState = it, + initialState = BrowserToolbarState(mode = Mode.EDIT), middleware = listOf( - BrowserToolbarSearchStatusSyncMiddleware( - appStore = requireComponents.appStore, - browsingModeManager = (requireActivity() as HomeActivity).browsingModeManager, - scope = lifecycleScope, - ), + BrowserToolbarSearchStatusSyncMiddleware(requireComponents.appStore), BrowserToolbarSearchMiddleware( - uiContext = requireActivity(), appStore = requireComponents.appStore, browserStore = requireComponents.core.store, components = requireComponents, + settings = requireComponents.settings, + ), + ), + ) + }.also { + it.dispatch( + EnvironmentRehydrated( + BrowserToolbarEnvironment( + context = requireContext(), + fragment = this, navController = findNavController(), browsingModeManager = (requireActivity() as HomeActivity).browsingModeManager, - settings = requireComponents.settings, - scope = lifecycleScope, ), ), ) - }.value + + viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + it.dispatch(EnvironmentCleared) + } + }, + ) + } } private fun buildSearchStore( @@ -242,31 +266,19 @@ class BookmarkFragment : Fragment() { // Default empty store. This is not used without the composable toolbar. SearchFragmentStore(SearchFragmentState.EMPTY) } - else -> fragmentStore( - createInitialSearchFragmentState( - activity = requireActivity() as HomeActivity, - components = requireComponents, - tabId = null, - pastedText = null, - searchAccessPoint = MetricsUtils.Source.NONE, - ), - ) { - val lifecycleScope = viewLifecycleOwner.lifecycle.coroutineScope - + else -> StoreProvider.get(this) { SearchFragmentStore( - initialState = it, + initialState = createInitialSearchFragmentState( + activity = requireActivity() as HomeActivity, + components = requireComponents, + tabId = null, + pastedText = null, + searchAccessPoint = MetricsUtils.Source.NONE, + ), middleware = listOf( - BrowserToolbarToFenixSearchMapperMiddleware( - toolbarStore = toolbarStore, - browsingModeManager = (requireActivity() as HomeActivity).browsingModeManager, - scope = lifecycleScope, - ), - BrowserStoreToFenixSearchMapperMiddleware( - browserStore = requireComponents.core.store, - scope = lifecycleScope, - ), + BrowserToolbarToFenixSearchMapperMiddleware(toolbarStore), + BrowserStoreToFenixSearchMapperMiddleware(requireComponents.core.store), FenixSearchMiddleware( - uiContext = requireActivity(), engine = requireComponents.core.engine, useCases = requireComponents.useCases, nimbusComponents = requireComponents.nimbus, @@ -274,13 +286,29 @@ class BookmarkFragment : Fragment() { appStore = requireComponents.appStore, browserStore = requireComponents.core.store, toolbarStore = toolbarStore, - navController = this@BookmarkFragment.findNavController(), + ), + ), + ) + }.also { + it.dispatch( + SearchFragmentAction.EnvironmentRehydrated( + SearchFragmentStore.Environment( + context = requireContext(), + viewLifecycleOwner = viewLifecycleOwner, browsingModeManager = (requireActivity() as HomeActivity).browsingModeManager, - scope = lifecycleScope, + navController = findNavController(), ), ), ) - }.value + + viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + it.dispatch(SearchFragmentAction.EnvironmentCleared) + } + }, + ) + } } override fun onResume() { diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksStore.kt @@ -4,9 +4,28 @@ package org.mozilla.fenix.bookmarks +import android.content.Context +import androidx.navigation.NavController import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.Reducer import mozilla.components.lib.state.Store +import org.mozilla.fenix.HomeActivity + +/** + * A helper class to be able to change the reference to objects that get replaced when the activity + * gets recreated. + * + * @property context the android [Context] + * @property navController A [NavController] for interacting with the androidx navigation library. + * @property composeNavController A [NavController] for navigating within the local Composable nav graph. + * @property homeActivity so that we can reference openToBrowserAndLoad and browsingMode :( + */ +internal class LifecycleHolder( + var context: Context, + var navController: NavController, + var composeNavController: NavController, + var homeActivity: HomeActivity, +) /** * A Store for handling [BookmarksState] and dispatching [BookmarksAction]. @@ -14,12 +33,14 @@ import mozilla.components.lib.state.Store * @param initialState The initial state for the Store. * @param reducer Reducer to handle state updates based on dispatched actions. * @param middleware Middleware to handle side-effects in response to dispatched actions. + * @property lifecycleHolder a hack to box the references to objects that get recreated with the activity. * @param bookmarkToLoad The guid of a bookmark to load when landing on the edit screen. */ internal class BookmarksStore( initialState: BookmarksState = BookmarksState.default, reducer: Reducer<BookmarksState, BookmarksAction> = ::bookmarksReducer, middleware: List<Middleware<BookmarksState, BookmarksAction>> = listOf(), + val lifecycleHolder: LifecycleHolder? = null, bookmarkToLoad: String? = null, ) : Store<BookmarksState, BookmarksAction>( initialState = initialState, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/EditBookmarkFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/EditBookmarkFragment.kt @@ -18,9 +18,9 @@ import androidx.navigation.fragment.navArgs import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.store.Mode -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint import org.mozilla.fenix.components.appstate.AppAction import org.mozilla.fenix.ext.bookmarkStorage @@ -47,19 +47,22 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { ): View? { return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - val buildStore = { composeNavController: NavHostController -> - val homeActivity = (requireActivity() as HomeActivity) - val navController = findNavController() + val buildStore = { navController: NavHostController -> val isSignedIntoSync = requireComponents .backgroundServices.accountManager.authenticatedAccount() != null - val store by fragmentStore( - BookmarksState.default.copy( - isSignedIntoSync = isSignedIntoSync, - ), - ) { + val store = StoreProvider.get(this@EditBookmarkFragment) { + val lifecycleHolder = LifecycleHolder( + context = requireContext(), + navController = this@EditBookmarkFragment.findNavController(), + composeNavController = navController, + homeActivity = (requireActivity() as HomeActivity), + ) + BookmarksStore( - initialState = it, + initialState = BookmarksState.default.copy( + isSignedIntoSync = isSignedIntoSync, + ), middleware = listOf( BookmarksMiddleware( bookmarksStorage = requireContext().bookmarkStorage, @@ -70,16 +73,16 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { openBookmarksInNewTab = if (settings().enableHomepageAsNewTab) { false } else { - homeActivity.browsingModeManager.mode.isPrivate + lifecycleHolder.homeActivity.browsingModeManager.mode.isPrivate }, - getNavController = { composeNavController }, - exitBookmarks = { navController.popBackStack() }, + getNavController = { lifecycleHolder.composeNavController }, + exitBookmarks = { lifecycleHolder.navController.popBackStack() }, navigateToBrowser = { - navController.navigate(R.id.browserFragment) + lifecycleHolder.navController.navigate(R.id.browserFragment) }, navigateToSearch = { }, navigateToSignIntoSync = { - navController + lifecycleHolder.navController .navigate( BookmarkFragmentDirections.actionGlobalTurnOnSync( entrypoint = FenixFxAEntryPoint.BookmarkView, @@ -87,7 +90,7 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { ) }, shareBookmarks = { bookmarks -> - navController.nav( + lifecycleHolder.navController.nav( R.id.bookmarkFragment, BookmarkFragmentDirections.actionGlobalShareFragment( data = bookmarks.asShareDataArray(), @@ -97,13 +100,13 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { showTabsTray = { }, resolveFolderTitle = { friendlyRootTitle( - context = context, + context = lifecycleHolder.context, node = it, - rootTitles = composeRootTitles(context), + rootTitles = composeRootTitles(lifecycleHolder.context), ) ?: "" }, getBrowsingMode = { - homeActivity.browsingModeManager.mode + lifecycleHolder.homeActivity.browsingModeManager.mode }, lastSavedFolderCache = context.settings().lastSavedFolderCache, saveBookmarkSortOrder = {}, @@ -114,9 +117,16 @@ class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) { }, ), ), + lifecycleHolder = lifecycleHolder, bookmarkToLoad = args.guidToEdit, ) } + store.lifecycleHolder?.apply { + this.navController = this@EditBookmarkFragment.findNavController() + this.composeNavController = navController + this.homeActivity = (requireActivity() as HomeActivity) + this.context = requireContext() + } store } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -125,7 +125,6 @@ import mozilla.components.feature.webauthn.WebAuthnFeature import mozilla.components.lib.state.ext.consumeFlow import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.flowScoped -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.service.sync.autofill.DefaultCreditCardValidationDelegate import mozilla.components.service.sync.logins.DefaultLoginValidationDelegate import mozilla.components.service.sync.logins.LoginsApiException @@ -164,8 +163,6 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.permissions.FenixSitePermissionLearnMoreUrlProvider import org.mozilla.fenix.browser.readermode.DefaultReaderModeController import org.mozilla.fenix.browser.readermode.ReaderModeController -import org.mozilla.fenix.browser.store.BrowserScreenMiddleware -import org.mozilla.fenix.browser.store.BrowserScreenState import org.mozilla.fenix.browser.store.BrowserScreenStore import org.mozilla.fenix.browser.tabstrip.TabStrip import org.mozilla.fenix.components.Components @@ -364,7 +361,7 @@ abstract class BaseBrowserFragment : @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) internal var webAppToolbarShouldBeVisible = true - protected val browserScreenStore by buildBrowserScreenStore() + protected lateinit var browserScreenStore: BrowserScreenStore private val homeViewModel: HomeScreenViewModel by activityViewModels() private var downloadDialog: AlertDialog? = null @@ -445,6 +442,8 @@ abstract class BaseBrowserFragment : // DO NOT ADD ANYTHING ABOVE THIS getProfilerTime CALL! val profilerStartTime = requireComponents.core.engine.profiler?.getProfilerTime() + browserScreenStore = buildBrowserScreenStore() + initializeUI(view) appLinksFeature.set( @@ -1392,7 +1391,7 @@ abstract class BaseBrowserFragment : store: BrowserStore, readerModeController: DefaultReaderModeController, ): BrowserToolbarComposable { - val toolbarStore by buildToolbarStore(activity, readerModeController) + val toolbarStore = buildToolbarStore(activity, readerModeController) browserNavigationBar = BrowserNavigationBar( @@ -1480,29 +1479,23 @@ abstract class BaseBrowserFragment : modifier: Modifier, ) = AwesomeBarComposable( activity = activity, - fragment = this, modifier = modifier, components = requireComponents, appStore = requireComponents.appStore, browserStore = requireComponents.core.store, toolbarStore = toolbarStore, navController = findNavController(), + lifecycleOwner = this, showScrimWhenNoSuggestions = true, ).also { awesomeBarComposable = it } - private fun buildBrowserScreenStore() = fragmentStore(BrowserScreenState()) { - BrowserScreenStore( - middleware = listOf( - BrowserScreenMiddleware( - uiContext = requireContext(), - crashReporter = requireContext().components.analytics.crashReporter, - fragmentManager = childFragmentManager, - ), - ), - ) - } + private fun buildBrowserScreenStore() = BrowserScreenStoreBuilder.build( + context = requireContext(), + lifecycleOwner = this, + fragmentManager = childFragmentManager, + ) private fun buildToolbarStore( activity: HomeActivity, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserScreenStoreBuilder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserScreenStoreBuilder.kt @@ -0,0 +1,61 @@ +/* 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 org.mozilla.fenix.browser + +import android.content.Context +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import org.mozilla.fenix.browser.store.BrowserScreenAction.EnvironmentCleared +import org.mozilla.fenix.browser.store.BrowserScreenAction.EnvironmentRehydrated +import org.mozilla.fenix.browser.store.BrowserScreenMiddleware +import org.mozilla.fenix.browser.store.BrowserScreenStore +import org.mozilla.fenix.browser.store.BrowserScreenStore.Environment +import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.ext.components + +/** + * Delegate for building the [BrowserScreenStore]. + */ +object BrowserScreenStoreBuilder { + + /** + * Builds the [BrowserScreenStore]. + * + * @param context [Context] needed for various Android interactions. + * @param lifecycleOwner [Fragment] which will have it's lifecycle observed for configuring + * lifecycle related operation. + * @param fragmentManager [FragmentManager] used for managing child fragments. + */ + fun build( + context: Context, + lifecycleOwner: Fragment, + fragmentManager: FragmentManager, + ) = StoreProvider.get(lifecycleOwner) { + BrowserScreenStore( + middleware = listOf( + BrowserScreenMiddleware(context.components.analytics.crashReporter), + ), + ) + }.also { + it.dispatch( + EnvironmentRehydrated( + Environment( + context = context, + viewLifecycleOwner = lifecycleOwner, + fragmentManager = fragmentManager, + ), + ), + ) + lifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + it.dispatch(EnvironmentCleared) + } + }, + ) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserToolbarStoreBuilder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserToolbarStoreBuilder.kt @@ -6,8 +6,8 @@ package org.mozilla.fenix.browser import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.coroutineScope import androidx.navigation.NavController import mozilla.components.browser.state.state.CustomTabSessionState import mozilla.components.browser.state.store.BrowserStore @@ -17,19 +17,21 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteractio import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.store.DisplayState -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.browser.readermode.ReaderModeController import org.mozilla.fenix.browser.store.BrowserScreenStore import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.Components +import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.components.toolbar.BrowserToolbarEnvironment import org.mozilla.fenix.components.toolbar.BrowserToolbarMiddleware import org.mozilla.fenix.components.toolbar.BrowserToolbarTelemetryMiddleware import org.mozilla.fenix.components.toolbar.CustomTabBrowserToolbarMiddleware +import org.mozilla.fenix.components.toolbar.CustomTabToolbarEnvironment import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.isTallWindow -import org.mozilla.fenix.ext.isWideWindow import org.mozilla.fenix.search.BrowserToolbarSearchMiddleware import org.mozilla.fenix.search.BrowserToolbarSearchStatusSyncMiddleware import org.mozilla.fenix.utils.Settings @@ -71,68 +73,46 @@ object BrowserToolbarStoreBuilder { readerModeController: ReaderModeController, settings: Settings, customTabSession: CustomTabSessionState? = null, - ) = fragment.fragmentStore( - BrowserToolbarState( - displayState = DisplayState( - pageOrigin = PageOrigin( - hint = R.string.search_hint, - title = null, - url = null, - onClick = object : BrowserToolbarEvent {}, + ) = StoreProvider.get(fragment) { + BrowserToolbarStore( + initialState = BrowserToolbarState( + displayState = DisplayState( + pageOrigin = PageOrigin( + hint = R.string.search_hint, + title = null, + url = null, + onClick = object : BrowserToolbarEvent {}, + ), ), ), - ), - ) { - val lifecycleScope = fragment.viewLifecycleOwner.lifecycle.coroutineScope - - BrowserToolbarStore( - initialState = it, middleware = when (customTabSession) { null -> listOf( BrowserToolbarMiddleware( - uiContext = activity, appStore = appStore, browserScreenStore = browserScreenStore, browserStore = browserStore, permissionsStorage = components.core.geckoSitePermissionsStorage, cookieBannersStorage = components.core.cookieBannersStorage, - bookmarksStorage = activity.components.core.bookmarksStorage, trackingProtectionUseCases = components.useCases.trackingProtectionUseCases, useCases = components.useCases, nimbusComponents = components.nimbus, clipboard = activity.components.clipboardHandler, publicSuffixList = components.publicSuffixList, settings = settings, - navController = navController, - browsingModeManager = browsingModeManager, - readerModeController = readerModeController, - browserAnimator = browserAnimator, - thumbnailsFeature = thumbnailsFeature, - isWideScreen = { fragment.isWideWindow() }, - isTallScreen = { fragment.isTallWindow() }, - scope = lifecycleScope, - ), - BrowserToolbarSearchStatusSyncMiddleware( - appStore = appStore, - browsingModeManager = browsingModeManager, - scope = lifecycleScope, + bookmarksStorage = activity.components.core.bookmarksStorage, ), + BrowserToolbarSearchStatusSyncMiddleware(appStore), BrowserToolbarSearchMiddleware( - uiContext = activity, appStore = appStore, browserStore = browserStore, components = components, - navController = navController, - browsingModeManager = browsingModeManager, settings = settings, - scope = lifecycleScope, ), BrowserToolbarTelemetryMiddleware(), ) else -> listOf( CustomTabBrowserToolbarMiddleware( - uiContext = activity, requireNotNull(customTabSession).id, browserStore = browserStore, appStore = appStore, @@ -142,13 +122,40 @@ object BrowserToolbarStoreBuilder { trackingProtectionUseCases = components.useCases.trackingProtectionUseCases, publicSuffixList = components.publicSuffixList, clipboard = activity.components.clipboardHandler, - navController = navController, - closeTabDelegate = { activity.finishAndRemoveTask() }, settings = settings, - scope = lifecycleScope, ), ) }, ) + }.also { + it.dispatch( + EnvironmentRehydrated( + when (customTabSession) { + null -> BrowserToolbarEnvironment( + context = activity, + fragment = fragment, + navController = navController, + browsingModeManager = browsingModeManager, + browserAnimator = browserAnimator, + thumbnailsFeature = thumbnailsFeature, + readerModeController = readerModeController, + ) + else -> CustomTabToolbarEnvironment( + context = activity, + viewLifecycleOwner = fragment.viewLifecycleOwner, + navController = navController, + closeTabDelegate = { activity.finishAndRemoveTask() }, + ) + }, + ), + ) + + fragment.viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + it.dispatch(EnvironmentCleared) + } + }, + ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/store/BrowserScreenAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/store/BrowserScreenAction.kt @@ -7,12 +7,25 @@ package org.mozilla.fenix.browser.store import mozilla.components.lib.state.Action import org.mozilla.fenix.browser.PageTranslationStatus import org.mozilla.fenix.browser.ReaderModeStatus +import org.mozilla.fenix.browser.store.BrowserScreenStore.Environment /** * Actions related to the browser screen. */ sealed class BrowserScreenAction : Action { /** + * Signals a new valid [Environment] has been set. + * + * @property environment The new [Environment]. + */ + data class EnvironmentRehydrated(val environment: Environment) : BrowserScreenAction() + + /** + * Signals the current [Environment] is not valid anymore. + */ + data object EnvironmentCleared : BrowserScreenAction() + + /** * [Action] for when the last private tab is about to be closed. * * @property tabId Id of the tab that was just closed. diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/store/BrowserScreenMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/store/BrowserScreenMiddleware.kt @@ -7,7 +7,6 @@ package org.mozilla.fenix.browser.store import android.content.Context import android.view.Gravity import androidx.annotation.VisibleForTesting -import androidx.fragment.app.FragmentManager import mozilla.components.concept.base.crash.Breadcrumb import mozilla.components.feature.downloads.ui.DownloadCancelDialogFragment import mozilla.components.lib.crash.CrashReporter @@ -17,21 +16,22 @@ import mozilla.components.lib.state.Store import org.mozilla.fenix.R import org.mozilla.fenix.browser.store.BrowserScreenAction.CancelPrivateDownloadsOnPrivateTabsClosedAccepted import org.mozilla.fenix.browser.store.BrowserScreenAction.ClosingLastPrivateTab +import org.mozilla.fenix.browser.store.BrowserScreenAction.EnvironmentCleared +import org.mozilla.fenix.browser.store.BrowserScreenAction.EnvironmentRehydrated +import org.mozilla.fenix.browser.store.BrowserScreenStore.Environment import org.mozilla.fenix.ext.pixelSizeFor import org.mozilla.fenix.theme.ThemeManager /** * [Middleware] responsible for handling actions related to the browser screen. * - * @param uiContext [Context] used for various system interactions. * @param crashReporter [CrashReporter] for recording crashes. - * @param fragmentManager [FragmentManager] to use for showing other fragments. */ class BrowserScreenMiddleware( - private val uiContext: Context, private val crashReporter: CrashReporter, - private val fragmentManager: FragmentManager, ) : Middleware<BrowserScreenState, BrowserScreenAction> { + @VisibleForTesting + internal var environment: Environment? = null override fun invoke( context: MiddlewareContext<BrowserScreenState, BrowserScreenAction>, @@ -39,6 +39,18 @@ class BrowserScreenMiddleware( action: BrowserScreenAction, ) { when (action) { + is EnvironmentRehydrated -> { + next(action) + + environment = action.environment + } + + is EnvironmentCleared -> { + next(action) + + environment = null + } + is ClosingLastPrivateTab -> { next(action) @@ -58,12 +70,14 @@ class BrowserScreenMiddleware( downloadCount: Int, tabId: String?, ) { + val environment = environment ?: return + crashReporter.recordCrashBreadcrumb( Breadcrumb("DownloadCancelDialogFragment shown in browser screen"), ) - val dialog = createDownloadCancelDialog(uiContext, store, downloadCount, tabId) + val dialog = createDownloadCancelDialog(environment.context, store, downloadCount, tabId) - dialog.show(fragmentManager, CANCEL_PRIVATE_DOWNLOADS_DIALOG_FRAGMENT_TAG) + dialog.show(environment.fragmentManager, CANCEL_PRIVATE_DOWNLOADS_DIALOG_FRAGMENT_TAG) } /** diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/store/BrowserScreenStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/store/BrowserScreenStore.kt @@ -4,11 +4,16 @@ package org.mozilla.fenix.browser.store +import android.content.Context +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LifecycleOwner import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.Store import org.mozilla.fenix.browser.store.BrowserScreenAction.CancelPrivateDownloadsOnPrivateTabsClosedAccepted import org.mozilla.fenix.browser.store.BrowserScreenAction.ClosingLastPrivateTab import org.mozilla.fenix.browser.store.BrowserScreenAction.CustomTabColorsUpdated +import org.mozilla.fenix.browser.store.BrowserScreenAction.EnvironmentCleared +import org.mozilla.fenix.browser.store.BrowserScreenAction.EnvironmentRehydrated import org.mozilla.fenix.browser.store.BrowserScreenAction.PageTranslationStatusUpdated import org.mozilla.fenix.browser.store.BrowserScreenAction.ReaderModeStatusUpdated @@ -25,7 +30,23 @@ class BrowserScreenStore( initialState = initialState, reducer = ::reduce, middleware = middleware, -) +) { + /** + * The current environment of the browser screen allowing access to various + * other application features that this integrates with. + * + * This is Activity/Fragment lifecycle dependent and should be handled carefully to avoid memory leaks. + * + * @property context [Context] used for various system interactions. + * @property viewLifecycleOwner [LifecycleOwner] depending on which lifecycle related operations will be scheduled. + * @property fragmentManager [FragmentManager] to use for showing other fragments. + */ + data class Environment( + val context: Context, + val viewLifecycleOwner: LifecycleOwner, + val fragmentManager: FragmentManager, + ) +} private fun reduce(state: BrowserScreenState, action: BrowserScreenAction): BrowserScreenState = when (action) { is ClosingLastPrivateTab -> state.copy( @@ -45,4 +66,12 @@ private fun reduce(state: BrowserScreenState, action: BrowserScreenAction): Brow is CustomTabColorsUpdated -> state.copy( customTabColors = action.customTabColors, ) + + is EnvironmentRehydrated, + is EnvironmentCleared, + -> { + // no-op + // Expected to be handled in middlewares set by integrators. + state + } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationFragment.kt @@ -15,8 +15,8 @@ import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.storeProvider import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentCreateCollectionBinding import org.mozilla.fenix.ext.requireComponents @@ -42,9 +42,9 @@ class CollectionCreationFragment : DialogFragment() { _binding = FragmentCreateCollectionBinding.inflate(inflater, container, false) val args: CollectionCreationFragmentArgs by navArgs() - collectionCreationStore = storeProvider.get { restoredState -> + collectionCreationStore = StoreProvider.get(this) { CollectionCreationStore( - restoredState ?: createInitialCollectionCreationState( + createInitialCollectionCreationState( browserState = requireComponents.core.store.state, tabCollectionStorage = requireComponents.core.tabCollectionStorage, publicSuffixList = requireComponents.publicSuffixList, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/collections/CollectionCreationStore.kt @@ -58,8 +58,6 @@ fun createInitialCollectionCreationState( selectedTabIds: Array<String>?, selectedTabCollectionId: Long, ): CollectionCreationState { - println("Mugurel: create initial state") - val tabs = browserState.getTabs(tabIds, publicSuffixList) val selectedTabs = if (selectedTabIds != null) { browserState.getTabs(selectedTabIds, publicSuffixList).toSet() diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/StoreProvider.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/StoreProvider.kt @@ -0,0 +1,90 @@ +/* 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 org.mozilla.fenix.components + +import androidx.annotation.MainThread +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import mozilla.components.lib.state.Store + +/** + * Generic ViewModel wrapper of a [Store] helping to persist it across process/activity recreations. + * + * @param createStore [Store] factory receiving also the [ViewModel.viewModelScope] associated with this [ViewModel]. + */ +class StoreProvider<T : Store<*, *>>( + createStore: (CoroutineScope) -> T, +) : ViewModel() { + + @VisibleForTesting + @PublishedApi + internal val store: T = createStore(viewModelScope) + + companion object { + /** + * Returns an existing [Store] instance or creates a new one scoped to a [ViewModelStoreOwner]. + * + * @see [ViewModelProvider.get]. + */ + inline fun <reified T : Store<*, *>> get( + owner: ViewModelStoreOwner, + noinline createStore: (CoroutineScope) -> T, + ): T { + val factory = StoreProviderFactory(createStore) + val viewModel: StoreProvider<*> = + ViewModelProvider(owner, factory).get(T::class.java.name, StoreProvider::class.java) + return viewModel.store as T + } + } +} + +/** + * [ViewModel] factory to create [StoreProvider] instances that will wrap a [Store] instance + * helping to persist it across process/activity recreations. + * + * @param createStore [Store] factory receiving also the [ViewModel.viewModelScope] associated with this [ViewModel]. + */ +@VisibleForTesting +class StoreProviderFactory<T : Store<*, *>>( + private val createStore: (CoroutineScope) -> T, +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun <VM : ViewModel> create(modelClass: Class<VM>): VM { + return StoreProvider(createStore) as VM + } +} + +/** + * Helper function for lazy creation of a [Store] instance scoped to a [ViewModelStoreOwner]. + * + * @param createStore [Store] factory receiving also the [ViewModel.viewModelScope] associated with this [ViewModel]. + * + * Example: + * ``` + * val store by lazy { scope -> + * MyStore( + * middleware = listOf( + * MyMiddleware( + * settings = requireComponents.settings, + * ... + * scope = scope, + * ), + * ) + * ) + * } + */ +@MainThread +inline fun <reified T : Store<*, *>> ViewModelStoreOwner.lazyStore( + noinline createStore: (CoroutineScope) -> T, +): Lazy<T> { + return lazy(mode = LazyThreadSafetyMode.NONE) { + StoreProvider.get(this, createStore) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarEnvironment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarEnvironment.kt @@ -0,0 +1,38 @@ +/* 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 org.mozilla.fenix.components.toolbar + +import android.content.Context +import androidx.fragment.app.Fragment +import androidx.navigation.NavController +import mozilla.components.browser.thumbnails.BrowserThumbnails +import mozilla.components.compose.browser.toolbar.store.Environment +import org.mozilla.fenix.browser.BrowserAnimator +import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager +import org.mozilla.fenix.browser.readermode.ReaderModeController + +/** + * The current environment in which the browser toolbar is used allowing access to various + * other application features that the toolbar integrates with. + * + * This is Activity/Fragment lifecycle dependent and should be handled carefully to avoid memory leaks. + * + * @property context [Context] used for various system interactions. + * @property fragment Hosting [Fragment] used as the lifecycle owner and context for UI operations. + * @property navController [NavController] to use for navigating to other in-app destinations. + * @property browsingModeManager [BrowsingModeManager] for querying the current browsing mode. + * @property browserAnimator Helper for animating the browser content when navigating to other screens. + * @property thumbnailsFeature [BrowserThumbnails] for requesting screenshots of the current tab. + * @property readerModeController [ReaderModeController] for showing or hiding the reader view UX. + */ +data class BrowserToolbarEnvironment( + val context: Context, + val fragment: Fragment, + val navController: NavController, + val browsingModeManager: BrowsingModeManager, + val browserAnimator: BrowserAnimator? = null, + val thumbnailsFeature: () -> BrowserThumbnails? = { null }, + val readerModeController: ReaderModeController? = null, +) : Environment diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddleware.kt @@ -4,12 +4,12 @@ package org.mozilla.fenix.components.toolbar -import android.content.Context import android.os.Build import androidx.annotation.VisibleForTesting -import androidx.navigation.NavController +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -26,7 +26,6 @@ import mozilla.components.browser.state.selector.selectedTab import mozilla.components.browser.state.state.content.ShareResourceState import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine import mozilla.components.browser.state.store.BrowserStore -import mozilla.components.browser.thumbnails.BrowserThumbnails import mozilla.components.compose.browser.toolbar.concept.Action import mozilla.components.compose.browser.toolbar.concept.Action.ActionButton import mozilla.components.compose.browser.toolbar.concept.Action.ActionButtonRes @@ -54,6 +53,8 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.B import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.BrowserToolbarMenuButton.Text.StringResText import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.BrowserToolbarMenuDivider import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.ProgressBarConfig import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.engine.EngineSession.LoadUrlFlags @@ -83,13 +84,10 @@ import org.mozilla.fenix.GleanMetrics.Events import org.mozilla.fenix.GleanMetrics.ReaderMode import org.mozilla.fenix.GleanMetrics.Translations import org.mozilla.fenix.R -import org.mozilla.fenix.browser.BrowserAnimator import org.mozilla.fenix.browser.BrowserFragmentDirections import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingMode.Normal import org.mozilla.fenix.browser.browsingmode.BrowsingMode.Private -import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager -import org.mozilla.fenix.browser.readermode.ReaderModeController import org.mozilla.fenix.browser.store.BrowserScreenAction import org.mozilla.fenix.browser.store.BrowserScreenStore import org.mozilla.fenix.components.AppStore @@ -123,6 +121,8 @@ import org.mozilla.fenix.components.toolbar.TabCounterInteractions.AddNewTab import org.mozilla.fenix.components.toolbar.TabCounterInteractions.CloseCurrentTab import org.mozilla.fenix.components.toolbar.TabCounterInteractions.TabCounterClicked import org.mozilla.fenix.components.toolbar.TabCounterInteractions.TabCounterLongClicked +import org.mozilla.fenix.ext.isTallWindow +import org.mozilla.fenix.ext.isWideWindow import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.navigateSafe import org.mozilla.fenix.nimbus.FxNimbus @@ -182,57 +182,42 @@ internal sealed class PageEndActionsInteractions : BrowserToolbarEvent { /** * [Middleware] responsible for configuring and handling interactions with the composable toolbar. * - * @param uiContext [Context] used for various system interactions. * @param appStore [AppStore] allowing to integrate with other features of the applications. * @param browserScreenStore [BrowserScreenStore] used for integration with other browser screen functionalities. * @param browserStore [BrowserStore] to sync from. * @param permissionsStorage [SitePermissionsStorage] to find currently selected tab site permissions. * @param cookieBannersStorage [CookieBannersStorage] to get the current status of cookie banner ui mode. - * @param bookmarksStorage [BookmarksStorage] to read and write bookmark data related to the current site. - * @param trackingProtectionUseCases [TrackingProtectionUseCases] allowing to query tracking protection data - * of the current tab. + * @param trackingProtectionUseCases [TrackingProtectionUseCases] allowing to query + * tracking protection data of the current tab. * @param useCases [UseCases] helping this integrate with other features of the applications. - * @param sessionUseCases [SessionUseCases] for interacting with the current session. * @param nimbusComponents [NimbusComponents] used for accessing Nimbus events to use in telemetry. * @param clipboard [ClipboardHandler] to use for reading from device's clipboard. * @param publicSuffixList [PublicSuffixList] used to obtain the base domain of the current site. * @param settings [Settings] for accessing user preferences. - * @param navController [NavController] to use for navigating to other in-app destinations. - * @param browsingModeManager [BrowsingModeManager] for querying the current browsing mode. - * @param readerModeController [ReaderModeController] for showing or hiding the reader view UX. - * @param browserAnimator Helper for animating the browser content when navigating to other screens. - * @param thumbnailsFeature [BrowserThumbnails] for requesting screenshots of the current tab. - * @param isWideScreen Callback for checking if the screen is wide. - * @param isTallScreen Callback for checking if the screen is tall. - * @param scope [CoroutineScope] used for running long running operations in background. + * @param sessionUseCases [SessionUseCases] for interacting with the current session. + * @param bookmarksStorage [BookmarksStorage] to read and write bookmark data related to the current site. * @param ioDispatcher [CoroutineDispatcher] to use for IO operations. */ @Suppress("LargeClass", "LongParameterList", "TooManyFunctions") class BrowserToolbarMiddleware( - private val uiContext: Context, private val appStore: AppStore, private val browserScreenStore: BrowserScreenStore, private val browserStore: BrowserStore, private val permissionsStorage: SitePermissionsStorage, private val cookieBannersStorage: CookieBannersStorage, - private val bookmarksStorage: BookmarksStorage, private val trackingProtectionUseCases: TrackingProtectionUseCases, private val useCases: UseCases, - private val sessionUseCases: SessionUseCases = SessionUseCases(browserStore), private val nimbusComponents: NimbusComponents, private val clipboard: ClipboardHandler, private val publicSuffixList: PublicSuffixList, private val settings: Settings, - private val navController: NavController, - private val browsingModeManager: BrowsingModeManager, - private val readerModeController: ReaderModeController, - private val browserAnimator: BrowserAnimator, - private val thumbnailsFeature: () -> BrowserThumbnails?, - private val isWideScreen: () -> Boolean, - private val isTallScreen: () -> Boolean, - private val scope: CoroutineScope, + private val sessionUseCases: SessionUseCases = SessionUseCases(browserStore), + private val bookmarksStorage: BookmarksStorage, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : Middleware<BrowserToolbarState, BrowserToolbarAction> { + @VisibleForTesting + internal var environment: BrowserToolbarEnvironment? = null + @Suppress("LongMethod", "CyclomaticComplexMethod", "NestedBlockDepth", "ReturnCount", "CognitiveComplexMethod") override fun invoke( context: MiddlewareContext<BrowserToolbarState, BrowserToolbarAction>, @@ -245,13 +230,22 @@ class BrowserToolbarMiddleware( appStore.dispatch(SearchEnded) + updateStartPageActions(context) + } + + is EnvironmentRehydrated -> { + next(action) + + environment = action.environment as? BrowserToolbarEnvironment + updateStartBrowserActions(context) updateStartPageActions(context) updateCurrentPageOrigin(context) - updateEndPageActions(context) - - scope.launch { + environment?.fragment?.viewLifecycleOwner?.lifecycleScope?.launch { updateEndBrowserActions(context) + } + updateEndPageActions(context) + environment?.fragment?.viewLifecycleOwner?.lifecycleScope?.launch { updateNavigationActions(context) } @@ -271,12 +265,18 @@ class BrowserToolbarMiddleware( observePermissionHighlightsUpdates(context) } + is EnvironmentCleared -> { + next(action) + + environment = null + } + is StartPageActions.SiteInfoClicked -> { onSiteInfoClicked() } is MenuClicked -> { - navController.nav( + environment?.navController?.nav( R.id.browserFragment, BrowserFragmentDirections.actionGlobalMenuDialogFragment( accesspoint = MenuAccessPoint.Browser, @@ -287,28 +287,30 @@ class BrowserToolbarMiddleware( } is TabCounterClicked -> { - thumbnailsFeature()?.requestScreenshot() + runWithinEnvironment { + thumbnailsFeature()?.requestScreenshot() - if (settings.tabManagerEnhancementsEnabled) { - navController.nav( - R.id.browserFragment, - BrowserFragmentDirections.actionGlobalTabManagementFragment( - page = when (browsingModeManager.mode) { - Normal -> Page.NormalTabs - Private -> Page.PrivateTabs - }, - ), - ) - } else { - navController.nav( - R.id.browserFragment, - BrowserFragmentDirections.actionGlobalTabsTrayFragment( - page = when (browsingModeManager.mode) { - Normal -> Page.NormalTabs - Private -> Page.PrivateTabs - }, - ), - ) + if (settings.tabManagerEnhancementsEnabled) { + navController.nav( + R.id.browserFragment, + BrowserFragmentDirections.actionGlobalTabManagementFragment( + page = when (browsingModeManager.mode) { + Normal -> Page.NormalTabs + Private -> Page.PrivateTabs + }, + ), + ) + } else { + navController.nav( + R.id.browserFragment, + BrowserFragmentDirections.actionGlobalTabsTrayFragment( + page = when (browsingModeManager.mode) { + Normal -> Page.NormalTabs + Private -> Page.PrivateTabs + }, + ), + ) + } } next(action) @@ -332,11 +334,14 @@ class BrowserToolbarMiddleware( } if (!selectedTab.content.private) { - navController.navigate( - BrowserFragmentDirections.actionGlobalHome( - sessionToDelete = selectedTab.id, - ), - ) + runWithinEnvironment { + navController.navigate( + BrowserFragmentDirections.actionGlobalHome( + sessionToDelete = selectedTab.id, + ), + ) + return@let + } } val privateDownloads = browserStore.state.downloads.filter { @@ -350,11 +355,13 @@ class BrowserToolbarMiddleware( ), ) } else { - navController.navigate( - BrowserFragmentDirections.actionGlobalHome( - sessionToDelete = selectedTab.id, - ), - ) + runWithinEnvironment { + navController.navigate( + BrowserFragmentDirections.actionGlobalHome( + sessionToDelete = selectedTab.id, + ), + ) + } } } } @@ -365,12 +372,14 @@ class BrowserToolbarMiddleware( val selectedTab = browserStore.state.selectedTab ?: return val searchTerms = selectedTab.content.searchTerms if (searchTerms.isBlank()) { - navController.navigate( - BrowserFragmentDirections.actionGlobalHome( - focusOnAddressBar = true, - sessionToStartSearchFor = selectedTab.id, - ), - ) + runWithinEnvironment { + navController.navigate( + BrowserFragmentDirections.actionGlobalHome( + focusOnAddressBar = true, + sessionToStartSearchFor = selectedTab.id, + ), + ) + } } else { context.dispatch(SearchQueryUpdated(BrowserToolbarQuery(searchTerms))) appStore.dispatch(SearchStarted(selectedTab.id)) @@ -391,7 +400,7 @@ class BrowserToolbarMiddleware( appStore.dispatch(URLCopiedToClipboard) } } - is PasteFromClipboardClicked -> { + is PasteFromClipboardClicked -> runWithinEnvironment { context.dispatch(SearchQueryUpdated(BrowserToolbarQuery(clipboard.text.orEmpty()))) appStore.dispatch(SearchStarted(browserStore.state.selectedTabId)) } @@ -427,7 +436,7 @@ class BrowserToolbarMiddleware( searchTermOrURL = it, newTab = false, searchEngine = searchEngine, - private = browsingModeManager?.mode == Private, + private = environment?.browsingModeManager?.mode == Private, ) } ?: run { Logger("BrowserOriginContextMenu").error("Clipboard contains URL but unable to read text") @@ -454,15 +463,15 @@ class BrowserToolbarMiddleware( next(action) } - is ReaderModeClicked -> { + is ReaderModeClicked -> runWithinEnvironment { when (action.isActive) { true -> { ReaderMode.closed.record(NoExtras()) - readerModeController.hideReaderView() + readerModeController?.hideReaderView() } false -> { ReaderMode.opened.record(NoExtras()) - readerModeController.showReaderView() + readerModeController?.showReaderView() } } @@ -473,10 +482,12 @@ class BrowserToolbarMiddleware( Translations.action.record(Translations.ActionExtra("main_flow_toolbar")) appStore.dispatch(SnackbarDismissed) - navController.navigateSafe( - resId = R.id.browserFragment, - directions = BrowserFragmentDirections.actionBrowserFragmentToTranslationsDialogFragment(), - ) + runWithinEnvironment { + navController.navigateSafe( + resId = R.id.browserFragment, + directions = BrowserFragmentDirections.actionBrowserFragmentToTranslationsDialogFragment(), + ) + } } is RefreshClicked -> { @@ -503,7 +514,7 @@ class BrowserToolbarMiddleware( val selectedTab = browserStore.state.selectedTab selectedTab?.let { - scope.launch(ioDispatcher) { + environment?.fragment?.viewLifecycleOwner?.lifecycleScope?.launch(ioDispatcher) { val parentGuid = settings.lastSavedFolderCache.getGuid() ?: BookmarkRoot.Mobile.id val parentNode = bookmarksStorage.getBookmark(parentGuid).getOrNull() val guidToEdit = useCases.bookmarksUseCases.addBookmark( @@ -525,10 +536,10 @@ class BrowserToolbarMiddleware( next(action) } - is EditBookmarkClicked -> { + is EditBookmarkClicked -> runWithinEnvironment { val selectedTab = browserStore.state.selectedTab ?: return - scope.launch(Dispatchers.Main) { + environment?.fragment?.viewLifecycleOwner?.lifecycleScope?.launch(Dispatchers.Main) { val guidToEdit: String? = withContext(ioDispatcher) { bookmarksStorage .getBookmarksWithUrl(selectedTab.content.url) @@ -551,7 +562,7 @@ class BrowserToolbarMiddleware( next(action) } - is ShareClicked -> { + is ShareClicked -> runWithinEnvironment { val selectedTab = browserStore.state.selectedTab ?: return if (selectedTab.content.url.isContentUrl()) { browserStore.dispatch( @@ -579,7 +590,7 @@ class BrowserToolbarMiddleware( next(action) } - is HomepageClicked -> { + is HomepageClicked -> runWithinEnvironment { if (settings.enableHomepageAsNewTab) { useCases.fenixBrowserUseCases.navigateToHomepage() } else { @@ -595,15 +606,18 @@ class BrowserToolbarMiddleware( } } - private fun showTabHistory() = navController.nav( - R.id.browserFragment, - BrowserFragmentDirections.actionGlobalTabHistoryDialogFragment( - activeSessionId = null, - ), - ) + private fun showTabHistory() = runWithinEnvironment { + navController.nav( + R.id.browserFragment, + BrowserFragmentDirections.actionGlobalTabHistoryDialogFragment( + activeSessionId = null, + ), + ) + } private fun onSiteInfoClicked() { val tab = browserStore.state.selectedTab ?: return + val scope = environment?.fragment?.viewLifecycleOwner?.lifecycleScope ?: return scope.launch(ioDispatcher) { val sitePermissions: SitePermissions? = tab.content.url.getOrigin()?.let { origin -> permissionsStorage.findSitePermissionsBy(origin, private = tab.content.private) @@ -648,7 +662,7 @@ class BrowserToolbarMiddleware( cookieBannerUIMode = cookieBannerUIMode, ) } - navController.nav( + environment?.navController?.nav( R.id.browserFragment, directions, ) @@ -713,7 +727,7 @@ class BrowserToolbarMiddleware( * - The navigation buttons (forward, back, and refresh) are always shown on the left side of the address bar. */ private fun buildStartBrowserActions(): List<Action> { - val isWideScreen = isWideScreen() + val isWideScreen = environment?.fragment?.isWideWindow() == true return listOf( ToolbarActionConfig(ToolbarAction.Back) { isWideScreen }, @@ -731,7 +745,7 @@ class BrowserToolbarMiddleware( * - The page action buttons (Share and Translate), which were removed from smaller devices, are shown again. */ private fun buildEndPageActions(): List<Action> { - val isWideScreen = isWideScreen() + val isWideScreen = environment?.fragment?.isWideWindow() == true val tabStripEnabled = settings.isTabStripEnabled val translateShortcutEnabled = settings.toolbarSimpleShortcutKey == ShortcutType.TRANSLATE val shareShortcutEnabled = settings.toolbarSimpleShortcutKey == ShortcutType.SHARE @@ -756,8 +770,8 @@ class BrowserToolbarMiddleware( } private fun buildEndBrowserActions(isBookmarked: Boolean): List<Action> { - val isWideWindow = isWideScreen() - val isTallWindow = isTallScreen() + val isWideWindow = environment?.fragment?.isWideWindow() == true + val isTallWindow = environment?.fragment?.isTallWindow() == true val tabStripEnabled = settings.isTabStripEnabled val shouldUseExpandedToolbar = settings.shouldUseExpandedToolbar val useCustomPrimary = settings.shouldShowToolbarCustomization @@ -800,8 +814,9 @@ class BrowserToolbarMiddleware( * - The toolbar redesign customization option is also hidden. */ private fun buildNavigationActions(isBookmarked: Boolean): List<Action> { - val isWideWindow = isWideScreen() - val isTallWindow = isTallScreen() + val environment = environment ?: return emptyList() + val isWideWindow = environment.fragment.isWideWindow() + val isTallWindow = environment.fragment.isTallWindow() val shouldUseExpandedToolbar = settings.shouldUseExpandedToolbar val useCustomPrimary = settings.shouldShowToolbarCustomization val primarySlotAction = mapShortcutToAction( @@ -872,7 +887,7 @@ class BrowserToolbarMiddleware( private fun openNewTab( browsingMode: BrowsingMode, - ) { + ) = runWithinEnvironment { if (settings.enableHomepageAsNewTab) { useCases.fenixBrowserUseCases.addNewHomepageTab( private = browsingMode.isPrivate, @@ -944,7 +959,7 @@ class BrowserToolbarMiddleware( private fun updateCurrentPageOrigin( context: MiddlewareContext<BrowserToolbarState, BrowserToolbarAction>, - ) = scope.launch { + ) = environment?.fragment?.viewLifecycleOwner?.lifecycleScope?.launch { val url = browserStore.state.selectedTab?.content?.url?.let { it.applyRegistrableDomainSpan(publicSuffixList) } @@ -1079,7 +1094,17 @@ class BrowserToolbarMiddleware( private inline fun <S : State, A : MVIAction> Store<S, A>.observeWhileActive( crossinline observe: suspend (Flow<S>.() -> Unit), - ): Job = scope.launch { flow().observe() } + ): Job? = environment?.fragment?.viewLifecycleOwner?.run { + lifecycleScope.launch { + repeatOnLifecycle(RESUMED) { + flow().observe() + } + } + } + + private inline fun runWithinEnvironment( + block: BrowserToolbarEnvironment.() -> Unit, + ) = environment?.let { block(it) } @VisibleForTesting internal enum class ToolbarAction { @@ -1115,12 +1140,12 @@ class BrowserToolbarMiddleware( ): Action = when (toolbarAction) { ToolbarAction.NewTab -> ActionButtonRes( drawableResId = iconsR.drawable.mozac_ic_plus_24, - contentDescription = if (browsingModeManager?.mode == Private) { + contentDescription = if (environment?.browsingModeManager?.mode == Private) { R.string.home_screen_shortcut_open_new_private_tab_2 } else { R.string.home_screen_shortcut_open_new_tab_2 }, - onClick = if (browsingModeManager?.mode == Private) { + onClick = if (environment?.browsingModeManager?.mode == Private) { AddNewPrivateTab(source) } else { AddNewTab(source) @@ -1202,13 +1227,14 @@ class BrowserToolbarMiddleware( ) ToolbarAction.TabCounter -> { - val isInPrivateMode = browsingModeManager.mode.isPrivate + val environment = requireNotNull(environment) + val isInPrivateMode = environment.browsingModeManager.mode.isPrivate val tabsCount = browserStore.state.getNormalOrPrivateTabs(isInPrivateMode).size val tabCounterDescription = if (isInPrivateMode) { - uiContext.getString(tabcounterR.string.mozac_tab_counter_private, tabsCount.toString()) + environment.context.getString(tabcounterR.string.mozac_tab_counter_private, tabsCount.toString()) } else { - uiContext.getString(tabcounterR.string.mozac_tab_counter_open_tab_tray, tabsCount.toString()) + environment.context.getString(tabcounterR.string.mozac_tab_counter_open_tab_tray, tabsCount.toString()) } TabCounterAction( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/CustomTabBrowserToolbarMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/CustomTabBrowserToolbarMiddleware.kt @@ -4,16 +4,16 @@ package org.mozilla.fenix.components.toolbar -import android.content.Context import android.content.Intent import android.os.Build import androidx.annotation.VisibleForTesting import androidx.appcompat.content.res.AppCompatResources import androidx.core.graphics.drawable.toDrawable import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.ViewModel -import androidx.navigation.NavController -import kotlinx.coroutines.CoroutineScope +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -38,6 +38,8 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.Init import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.ProgressBarConfig import mozilla.components.concept.engine.cookiehandling.CookieBannersStorage import mozilla.components.concept.engine.permission.SitePermissions @@ -95,7 +97,6 @@ private const val CUSTOM_BUTTON_CLICK_RETURN_CODE = 0 * * This is also a [ViewModel] allowing to be easily persisted between activity restarts. * - * @param uiContext [Context] used for various system interactions. * @param customTabId [String] of the custom tab in which the toolbar is shown. * @param browserStore [BrowserStore] to sync from. * @param appStore [AppStore] allowing to integrate with other features of the applications. @@ -106,14 +107,10 @@ private const val CUSTOM_BUTTON_CLICK_RETURN_CODE = 0 * tracking protection data of the current tab. * @param publicSuffixList [PublicSuffixList] used to obtain the base domain of the current site. * @param clipboard [ClipboardHandler] to use for reading from device's clipboard. - * @param navController [NavController] to use for navigating to other in-app destinations. - * @param closeTabDelegate Callback for when the current custom tab needs to be closed. * @param settings [Settings] for accessing user preferences. - * @param scope [CoroutineScope] used for running long running operations in background. */ @Suppress("LongParameterList") class CustomTabBrowserToolbarMiddleware( - private val uiContext: Context, private val customTabId: String, private val browserStore: BrowserStore, private val appStore: AppStore, @@ -123,11 +120,10 @@ class CustomTabBrowserToolbarMiddleware( private val trackingProtectionUseCases: TrackingProtectionUseCases, private val publicSuffixList: PublicSuffixList, private val clipboard: ClipboardHandler, - private val navController: NavController, - private val closeTabDelegate: () -> Unit, private val settings: Settings, - private val scope: CoroutineScope, -) : Middleware<BrowserToolbarState, BrowserToolbarAction> { +) : Middleware<BrowserToolbarState, BrowserToolbarAction>, ViewModel() { + @VisibleForTesting + internal var environment: CustomTabToolbarEnvironment? = null private val customTab get() = browserStore.state.findCustomTab(customTabId) private var wasTitleShown = false @@ -144,10 +140,17 @@ class CustomTabBrowserToolbarMiddleware( val customTab = customTab updateStartPageActions(context, customTab) + updateEndBrowserActions(context, customTab) + } + + is EnvironmentRehydrated -> { + next(action) + + environment = action.environment as? CustomTabToolbarEnvironment + updateStartBrowserActions(context, customTab) updateCurrentPageOrigin(context, customTab) updateEndPageActions(context, customTab) - updateEndBrowserActions(context, customTab) observePageLoadUpdates(context) observePageOriginUpdates(context) @@ -155,13 +158,19 @@ class CustomTabBrowserToolbarMiddleware( observePageTrackingProtectionUpdates(context) } + is EnvironmentCleared -> { + next(action) + + environment = null + } + is CloseClicked -> { Toolbar.buttonTapped.record( Toolbar.ButtonTappedExtra(source = SOURCE_CUSTOM_BAR, item = ACTION_CLOSE_CLICKED), ) useCases.remove(customTabId) - closeTabDelegate() + environment?.closeTabDelegate() } is SiteInfoClicked -> { @@ -169,15 +178,16 @@ class CustomTabBrowserToolbarMiddleware( Toolbar.ButtonTappedExtra(source = SOURCE_CUSTOM_BAR, item = ACTION_SECURITY_INDICATOR_CLICKED), ) + val environment = environment ?: return val customTab = requireNotNull(customTab) - scope.launch(Dispatchers.IO) { + environment.viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { val sitePermissions: SitePermissions? = customTab.content.url.getOrigin()?.let { origin -> permissionsStorage.findSitePermissionsBy(origin, private = customTab.content.private) } - scope.launch(Dispatchers.Main) { + environment.viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { trackingProtectionUseCases.containsException(customTabId) { isExcepted -> - scope.launch { + environment.viewLifecycleOwner.lifecycleScope.launch { val cookieBannerUIMode = cookieBannersStorage.getCookieBannerUIMode( tab = customTab, isFeatureEnabledInPrivateMode = settings.shouldUseCookieBannerPrivateMode, @@ -215,7 +225,7 @@ class CustomTabBrowserToolbarMiddleware( cookieBannerUIMode = cookieBannerUIMode, ) } - navController.nav( + environment.navController.nav( R.id.externalAppBrowserFragment, directions, ) @@ -229,9 +239,10 @@ class CustomTabBrowserToolbarMiddleware( Toolbar.buttonTapped.record( Toolbar.ButtonTappedExtra(source = SOURCE_CUSTOM_BAR, item = ACTION_SITE_CUSTOM_CLICKED), ) + val environment = environment ?: return val customTab = customTab customTab?.config?.actionButtonConfig?.pendingIntent?.send( - uiContext, + environment.context, CUSTOM_BUTTON_CLICK_RETURN_CODE, Intent(null, customTab.content.url.toUri()), ) @@ -242,7 +253,7 @@ class CustomTabBrowserToolbarMiddleware( Toolbar.ButtonTappedExtra(source = SOURCE_CUSTOM_BAR, item = ACTION_SHARE_CLICKED), ) val customTab = customTab - navController.navigate( + environment?.navController?.navigate( NavGraphDirections.actionGlobalShareFragment( sessionId = customTabId, data = arrayOf( @@ -260,13 +271,15 @@ class CustomTabBrowserToolbarMiddleware( Toolbar.buttonTapped.record( Toolbar.ButtonTappedExtra(source = SOURCE_CUSTOM_BAR, item = ACTION_MENU_CLICKED), ) - navController.nav( - R.id.externalAppBrowserFragment, - BrowserFragmentDirections.actionGlobalMenuDialogFragment( - accesspoint = MenuAccessPoint.External, - customTabSessionId = customTabId, - ), - ) + runWithinEnvironment { + navController.nav( + R.id.externalAppBrowserFragment, + BrowserFragmentDirections.actionGlobalMenuDialogFragment( + accesspoint = MenuAccessPoint.External, + customTabSessionId = customTabId, + ), + ) + } } is CopyToClipboardClicked -> { @@ -353,7 +366,7 @@ class CustomTabBrowserToolbarMiddleware( context: MiddlewareContext<BrowserToolbarState, BrowserToolbarAction>, customTab: CustomTabSessionState?, ) { - scope.launch { + environment?.viewLifecycleOwner?.lifecycleScope?.launch { context.dispatch( BrowserDisplayToolbarAction.PageOriginUpdated( PageOrigin( @@ -387,6 +400,7 @@ class CustomTabBrowserToolbarMiddleware( ) private fun buildStartBrowserActions(customTab: CustomTabSessionState?): List<Action> { + val environment = environment ?: return emptyList() val customTabConfig = customTab?.config val customIconBitmap = customTabConfig?.closeButtonIcon @@ -395,12 +409,12 @@ class CustomTabBrowserToolbarMiddleware( ActionButton( drawable = when (customIconBitmap) { null -> AppCompatResources.getDrawable( - uiContext, iconsR.drawable.mozac_ic_cross_24, + environment.context, iconsR.drawable.mozac_ic_cross_24, ) - else -> customIconBitmap.toDrawable(uiContext.resources) + else -> customIconBitmap.toDrawable(environment.context.resources) }, - contentDescription = uiContext.getString( + contentDescription = environment.context.getString( customtabsR.string.mozac_feature_customtabs_exit_button, ), onClick = CloseClicked, @@ -444,6 +458,7 @@ class CustomTabBrowserToolbarMiddleware( } private fun buildEndPageActions(customTab: CustomTabSessionState?): List<ActionButton> { + val environment = environment ?: return emptyList() val customButtonConfig = customTab?.config?.actionButtonConfig val customButtonIcon = customButtonConfig?.icon @@ -451,7 +466,7 @@ class CustomTabBrowserToolbarMiddleware( null -> emptyList() else -> listOf( ActionButton( - drawable = customButtonIcon.toDrawable(uiContext.resources), + drawable = customButtonIcon.toDrawable(environment.context.resources), shouldTint = customTab.content.private || customButtonConfig.tint, contentDescription = customButtonConfig.description, onClick = CustomButtonClicked, @@ -524,7 +539,17 @@ class CustomTabBrowserToolbarMiddleware( private inline fun <S : State, A : MVIAction> Store<S, A>.observeWhileActive( crossinline observe: suspend (Flow<S>.() -> Unit), - ): Job = scope.launch { flow().observe() } + ): Job? = environment?.viewLifecycleOwner?.run { + lifecycleScope.launch { + repeatOnLifecycle(RESUMED) { + flow().observe() + } + } + } + + private inline fun runWithinEnvironment( + block: CustomTabToolbarEnvironment.() -> Unit, + ) = environment?.let { block(it) } /** * Static functionalities of the [BrowserToolbarMiddleware]. diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/CustomTabToolbarEnvironment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/CustomTabToolbarEnvironment.kt @@ -0,0 +1,28 @@ +/* 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 org.mozilla.fenix.components.toolbar + +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.NavController +import mozilla.components.compose.browser.toolbar.store.Environment + +/** + * The current environment in which the browser toolbar is used allowing access to various + * other application features that the toolbar integrates with. + * + * This is Activity/Fragment lifecycle dependent and should be handled carefully to avoid memory leaks. + * + * @property context [Context] to access application resources and interact with other system functionalities. + * @property viewLifecycleOwner [LifecycleOwner] depending on which lifecycle related operations will be scheduled. + * @property navController [NavController] to use for navigating to other in-app destinations. + * @property closeTabDelegate Callback for when the current custom tab needs to be closed. + */ +data class CustomTabToolbarEnvironment( + val context: Context, + val viewLifecycleOwner: LifecycleOwner, + val navController: NavController, + val closeTabDelegate: () -> Unit, +) : Environment diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/debugsettings/gleandebugtools/GleanDebugToolsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/debugsettings/gleandebugtools/GleanDebugToolsFragment.kt @@ -27,9 +27,9 @@ import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.compose.content import androidx.navigation.fragment.findNavController -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.telemetry.glean.Glean import org.mozilla.fenix.R +import org.mozilla.fenix.components.lazyStore import org.mozilla.fenix.debugsettings.gleandebugtools.ui.GleanDebugToolsScreen import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.theme.FirefoxTheme @@ -40,14 +40,12 @@ import mozilla.components.ui.icons.R as iconsR */ class GleanDebugToolsFragment : Fragment() { - private val store by fragmentStore( - GleanDebugToolsState( - logPingsToConsoleEnabled = Glean.getLogPings(), - debugViewTag = Glean.getDebugViewTag() ?: "", - ), - ) { + private val store by lazyStore { GleanDebugToolsStore( - initialState = it, + initialState = GleanDebugToolsState( + logPingsToConsoleEnabled = Glean.getLogPings(), + debugViewTag = Glean.getDebugViewTag() ?: "", + ), middlewares = listOf( GleanDebugToolsMiddleware( gleanDebugToolsStorage = DefaultGleanDebugToolsStorage(), diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/downloads/listscreen/DownloadFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/downloads/listscreen/DownloadFragment.kt @@ -10,13 +10,11 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.compose.content -import androidx.lifecycle.viewModelScope import androidx.navigation.fragment.findNavController import mozilla.components.feature.downloads.AbstractFetchDownloadService -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore -import mozilla.components.lib.state.helpers.StoreProvider.Companion.storeProvider import org.mozilla.fenix.components.appstate.AppAction import org.mozilla.fenix.components.appstate.SupportedMenuNotifications +import org.mozilla.fenix.components.lazyStore import org.mozilla.fenix.compose.snackbar.Snackbar import org.mozilla.fenix.compose.snackbar.SnackbarState import org.mozilla.fenix.downloads.getCannotOpenFileErrorMessage @@ -42,11 +40,11 @@ class DownloadFragment : Fragment() { ) } - private val downloadStore by fragmentStore(DownloadUIState.INITIAL) { + private val downloadStore by lazyStore { viewModelScope -> DownloadUIStore( - initialState = it, + initialState = DownloadUIState.INITIAL, middleware = DownloadUIMiddlewareProvider.provideMiddleware( - coroutineScope = storeProvider.viewModelScope, + coroutineScope = viewModelScope, applicationContext = requireContext().applicationContext, ), ) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/exceptions/login/LoginExceptionsFragment.kt @@ -14,8 +14,8 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.plus import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentExceptionsBinding import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.showToolbar @@ -44,9 +44,11 @@ class LoginExceptionsFragment : Fragment() { container, false, ) - exceptionsStore = fragmentStore(ExceptionsFragmentState(items = emptyList())) { - ExceptionsFragmentStore(it) - }.value + exceptionsStore = StoreProvider.get(this) { + ExceptionsFragmentStore( + ExceptionsFragmentState(items = emptyList()), + ) + } exceptionsInteractor = DefaultLoginExceptionsInteractor( ioScope = viewLifecycleOwner.lifecycleScope + Dispatchers.IO, loginExceptionStorage = requireComponents.core.loginExceptionStorage, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/exceptions/trackingprotection/TrackingProtectionExceptionsFragment.kt @@ -10,9 +10,9 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentExceptionsBinding import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.showToolbar @@ -42,9 +42,11 @@ class TrackingProtectionExceptionsFragment : Fragment() { container, false, ) - exceptionsStore = fragmentStore(ExceptionsFragmentState(items = emptyList())) { - ExceptionsFragmentStore(it) - }.value + exceptionsStore = StoreProvider.get(this) { + ExceptionsFragmentStore( + ExceptionsFragmentState(items = emptyList()), + ) + } exceptionsInteractor = DefaultTrackingProtectionExceptionsInteractor( activity = activity as HomeActivity, exceptionsStore = exceptionsStore, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -601,7 +601,7 @@ class HomeFragment : Fragment() { private fun buildToolbar(activity: HomeActivity): FenixHomeToolbar = when (activity.settings().shouldUseComposableToolbar) { true -> { - val toolbarStore by buildToolbarStore(activity) + val toolbarStore = buildToolbarStore(activity) homeNavigationBar = HomeNavigationBar( context = activity, @@ -1387,13 +1387,13 @@ class HomeFragment : Fragment() { ) = context?.let { AwesomeBarComposable( activity = requireActivity() as HomeActivity, - fragment = this, modifier = modifier, components = requireComponents, appStore = requireComponents.appStore, browserStore = requireComponents.core.store, toolbarStore = toolbarStore, navController = findNavController(), + lifecycleOwner = this, tabId = args.sessionToStartSearchFor, searchAccessPoint = args.searchAccessPoint, ).also { diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/store/HomeToolbarStoreBuilder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/store/HomeToolbarStoreBuilder.kt @@ -6,19 +6,19 @@ package org.mozilla.fenix.home.store import android.content.Context import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.coroutineScope import androidx.navigation.NavController import mozilla.components.browser.state.store.BrowserStore import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.components.AppStore +import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.components.toolbar.BrowserToolbarEnvironment import org.mozilla.fenix.ext.components -import org.mozilla.fenix.ext.isTallWindow -import org.mozilla.fenix.ext.isWideWindow -import org.mozilla.fenix.ext.settings import org.mozilla.fenix.home.toolbar.BrowserToolbarMiddleware import org.mozilla.fenix.home.toolbar.BrowserToolbarTelemetryMiddleware import org.mozilla.fenix.search.BrowserToolbarSearchMiddleware @@ -45,42 +45,44 @@ object HomeToolbarStoreBuilder { appStore: AppStore, browserStore: BrowserStore, browsingModeManager: BrowsingModeManager, - ) = fragment.fragmentStore(BrowserToolbarState()) { - val lifecycleScope = fragment.viewLifecycleOwner.lifecycle.coroutineScope - + ) = StoreProvider.get(fragment) { BrowserToolbarStore( - initialState = it, + initialState = BrowserToolbarState(), middleware = listOf( - BrowserToolbarSearchStatusSyncMiddleware( - appStore = appStore, - browsingModeManager = browsingModeManager, - scope = lifecycleScope, - ), + BrowserToolbarSearchStatusSyncMiddleware(appStore), BrowserToolbarMiddleware( - uiContext = context, appStore = appStore, browserStore = browserStore, clipboard = context.components.clipboardHandler, useCases = context.components.useCases, - navController = navController, - browsingModeManager = browsingModeManager, - settings = context.settings(), - isWideScreen = { fragment.isWideWindow() }, - isTallScreen = { fragment.isTallWindow() }, - scope = lifecycleScope, ), BrowserToolbarSearchMiddleware( - uiContext = context, appStore = appStore, browserStore = browserStore, components = context.components, - navController = navController, - browsingModeManager = browsingModeManager, settings = context.components.settings, - scope = lifecycleScope, ), BrowserToolbarTelemetryMiddleware(), ), ) + }.also { + it.dispatch( + EnvironmentRehydrated( + BrowserToolbarEnvironment( + context = context, + fragment = fragment, + navController = navController, + browsingModeManager = browsingModeManager, + ), + ), + ) + + fragment.viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + it.dispatch(EnvironmentCleared) + } + }, + ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddleware.kt @@ -4,10 +4,10 @@ package org.mozilla.fenix.home.toolbar -import android.content.Context import androidx.annotation.VisibleForTesting -import androidx.navigation.NavController -import kotlinx.coroutines.CoroutineScope +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -42,6 +42,8 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.B import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.BrowserToolbarMenuButton.Icon.DrawableResIcon import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.BrowserToolbarMenuButton.Text.StringResText import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.Mode import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.lib.state.Middleware @@ -57,13 +59,16 @@ import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingMode.Normal import org.mozilla.fenix.browser.browsingmode.BrowsingMode.Private -import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.UseCases import org.mozilla.fenix.components.appstate.AppAction.SearchAction.SearchStarted import org.mozilla.fenix.components.appstate.SupportedMenuNotifications import org.mozilla.fenix.components.menu.MenuAccessPoint +import org.mozilla.fenix.components.toolbar.BrowserToolbarEnvironment +import org.mozilla.fenix.ext.isTallWindow +import org.mozilla.fenix.ext.isWideWindow import org.mozilla.fenix.ext.nav +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.home.HomeFragmentDirections import org.mozilla.fenix.home.toolbar.DisplayActions.FakeClicked import org.mozilla.fenix.home.toolbar.DisplayActions.MenuClicked @@ -76,7 +81,6 @@ import org.mozilla.fenix.search.BrowserToolbarSearchMiddleware import org.mozilla.fenix.search.ext.searchEngineShortcuts import org.mozilla.fenix.settings.ShortcutType import org.mozilla.fenix.tabstray.Page -import org.mozilla.fenix.utils.Settings import mozilla.components.lib.state.Action as MVIAction import mozilla.components.ui.icons.R as iconsR import mozilla.components.ui.tabcounter.R as tabcounterR @@ -101,32 +105,19 @@ internal sealed class PageOriginInteractions : BrowserToolbarEvent { /** * [Middleware] responsible for configuring and handling interactions with the composable toolbar. * - * @param uiContext [Context] used for various system interactions. * @param appStore [AppStore] to sync from. * @param browserStore [BrowserStore] to sync from. * @param clipboard [ClipboardHandler] to use for reading from device's clipboard. * @param useCases [UseCases] helping this integrate with other features of the applications. - * @param navController [NavController] to use for navigating to other in-app destinations. - * @param browsingModeManager [BrowsingModeManager] for querying the current browsing mode. - * @param settings [Settings] for accessing application settings. - * @param isWideScreen Callback for checking if the screen is wide. - * @param isTallScreen Callback for checking if the screen is tall. - * @param scope [CoroutineScope] used for running long running operations in background. */ -@Suppress("LongParameterList") class BrowserToolbarMiddleware( - private val uiContext: Context, private val appStore: AppStore, private val browserStore: BrowserStore, private val clipboard: ClipboardHandler, private val useCases: UseCases, - private val navController: NavController, - private val browsingModeManager: BrowsingModeManager, - private val settings: Settings, - private val isWideScreen: () -> Boolean, - private val isTallScreen: () -> Boolean, - private val scope: CoroutineScope, ) : Middleware<BrowserToolbarState, BrowserToolbarAction> { + @VisibleForTesting + internal var environment: BrowserToolbarEnvironment? = null private var syncCurrentSearchEngineJob: Job? = null private var observeBrowserSearchStateJob: Job? = null @@ -140,11 +131,17 @@ class BrowserToolbarMiddleware( is Init -> { next(action) + updatePageOrigin(context) + } + + is EnvironmentRehydrated -> { + next(action) + + environment = action.environment as? BrowserToolbarEnvironment + if (context.state.mode == Mode.DISPLAY) { observeSearchStateUpdates(context) } - - updatePageOrigin(context) updateEndBrowserActions(context) updateNavigationActions(context) updateToolbarActionsBasedOnOrientation(context) @@ -152,6 +149,12 @@ class BrowserToolbarMiddleware( updateMenuHighlight(context) } + is EnvironmentCleared -> { + next(action) + + environment = null + } + is EnterEditMode -> { next(action) @@ -165,39 +168,43 @@ class BrowserToolbarMiddleware( } is MenuClicked -> { - navController.nav( - R.id.homeFragment, - HomeFragmentDirections.actionGlobalMenuDialogFragment( - accesspoint = MenuAccessPoint.Home, - ), - ) - next(action) - } - - is TabCounterClicked -> { - if (settings.tabManagerEnhancementsEnabled) { + runWithinEnvironment { navController.nav( R.id.homeFragment, - NavGraphDirections.actionGlobalTabManagementFragment( - page = when (browsingModeManager.mode) { - Normal -> Page.NormalTabs - Private -> Page.PrivateTabs - }, - ), - ) - } else { - navController.nav( - R.id.homeFragment, - NavGraphDirections.actionGlobalTabsTrayFragment( - page = when (browsingModeManager.mode) { - Normal -> Page.NormalTabs - Private -> Page.PrivateTabs - }, + HomeFragmentDirections.actionGlobalMenuDialogFragment( + accesspoint = MenuAccessPoint.Home, ), ) } next(action) } + + is TabCounterClicked -> { + runWithinEnvironment { + if (this.context.settings().tabManagerEnhancementsEnabled) { + navController.nav( + R.id.homeFragment, + NavGraphDirections.actionGlobalTabManagementFragment( + page = when (browsingModeManager.mode) { + Normal -> Page.NormalTabs + Private -> Page.PrivateTabs + }, + ), + ) + } else { + navController.nav( + R.id.homeFragment, + NavGraphDirections.actionGlobalTabsTrayFragment( + page = when (browsingModeManager.mode) { + Normal -> Page.NormalTabs + Private -> Page.PrivateTabs + }, + ), + ) + } + } + next(action) + } is AddNewTab -> { openNewTab(context, Normal) next(action) @@ -215,16 +222,18 @@ class BrowserToolbarMiddleware( openNewTab(context, searchTerms = clipboard.text) } is LoadFromClipboardClicked -> { - clipboard.extractURL()?.let { - useCases.fenixBrowserUseCases.loadUrlOrSearch( - searchTermOrURL = it, - newTab = true, - private = browsingModeManager.mode == Private, - searchEngine = reconcileSelectedEngine(), - ) - navController.navigate(R.id.browserFragment) - } ?: run { - Logger("HomeOriginContextMenu").error("Clipboard contains URL but unable to read text") + runWithinEnvironment { + clipboard.extractURL()?.let { + useCases.fenixBrowserUseCases.loadUrlOrSearch( + searchTermOrURL = it, + newTab = true, + private = browsingModeManager.mode == Private, + searchEngine = reconcileSelectedEngine(), + ) + navController.navigate(R.id.browserFragment) + } ?: run { + Logger("HomeOriginContextMenu").error("Clipboard contains URL but unable to read text") + } } } @@ -237,9 +246,11 @@ class BrowserToolbarMiddleware( browsingMode: BrowsingMode? = null, searchTerms: String? = null, ) { - browsingMode?.let { browsingModeManager.mode = it } - context.dispatch(SearchQueryUpdated(BrowserToolbarQuery(searchTerms ?: ""))) - appStore.dispatch(SearchStarted()) + runWithinEnvironment { + browsingMode?.let { browsingModeManager.mode = it } + context.dispatch(SearchQueryUpdated(BrowserToolbarQuery(searchTerms ?: ""))) + appStore.dispatch(SearchStarted()) + } } private fun observeSearchStateUpdates(context: MiddlewareContext<BrowserToolbarState, BrowserToolbarAction>) { @@ -301,20 +312,22 @@ class BrowserToolbarMiddleware( } private fun buildStartPageActions(selectedSearchEngine: SearchEngine?): List<Action> { + val environment = environment ?: return emptyList() + return listOfNotNull( BrowserToolbarSearchMiddleware.buildSearchSelector( selectedSearchEngine = selectedSearchEngine, searchEngineShortcuts = browserStore.state.search.searchEngineShortcuts, - resources = uiContext.resources, + resources = environment.context.resources, ), ) } private fun buildEndBrowserActions(): List<Action> { - val isWideWindow = isWideScreen() - val isTallWindow = isTallScreen() - val tabStripEnabled = settings.isTabStripEnabled - val shouldUseExpandedToolbar = settings.shouldUseExpandedToolbar + val isWideWindow = environment?.fragment?.isWideWindow() == true + val isTallWindow = environment?.fragment?.isTallWindow() == true + val tabStripEnabled = environment?.context?.settings()?.isTabStripEnabled == true + val shouldUseExpandedToolbar = environment?.context?.settings()?.shouldUseExpandedToolbar == true return listOf( HomeToolbarActionConfig(HomeToolbarAction.TabCounter) { @@ -351,8 +364,10 @@ class BrowserToolbarMiddleware( * - The toolbar redesign customization option is also hidden. */ private fun buildNavigationActions(): List<Action> { - val isWideWindow = isWideScreen() - val isTallWindow = isTallScreen() + val environment = environment ?: return emptyList() + val settings = environment.context.settings() + val isWideWindow = environment.fragment.isWideWindow() + val isTallWindow = environment.fragment.isTallWindow() val shouldUseExpandedToolbar = settings.shouldUseExpandedToolbar val useCustomPrimary = settings.shouldShowToolbarCustomization && shouldUseExpandedToolbar val primarySlotAction = mapShortcutToAction( @@ -384,7 +399,7 @@ class BrowserToolbarMiddleware( private fun buildTabCounterMenu(source: Source): CombinedEventAndMenu? { return CombinedEventAndMenu(TabCounterLongClicked(source)) { - when (browsingModeManager.mode) { + when (environment?.browsingModeManager?.mode) { Private -> listOf( BrowserToolbarMenuButton( icon = DrawableResIcon(iconsR.drawable.mozac_ic_plus_24), @@ -444,7 +459,17 @@ class BrowserToolbarMiddleware( private inline fun <S : State, A : MVIAction> Store<S, A>.observeWhileActive( crossinline observe: suspend (Flow<S>.() -> Unit), - ): Job = scope.launch { flow().observe() } + ): Job? = environment?.fragment?.viewLifecycleOwner?.run { + lifecycleScope.launch { + repeatOnLifecycle(RESUMED) { + flow().observe() + } + } + } + + private inline fun runWithinEnvironment( + block: BrowserToolbarEnvironment.() -> Unit, + ) = environment?.let { block(it) } private fun reconcileSelectedEngine(): SearchEngine? = appStore.state.searchState.selectedSearchEngine?.searchEngine @@ -473,13 +498,14 @@ class BrowserToolbarMiddleware( source: Source = Source.AddressBar, ): Action = when (action) { HomeToolbarAction.TabCounter -> { - val isInPrivateMode = browsingModeManager.mode.isPrivate + val environment = requireNotNull(environment) + val isInPrivateMode = environment.browsingModeManager.mode.isPrivate val tabsCount = browserStore.state.getNormalOrPrivateTabs(isInPrivateMode).size val tabCounterDescription = if (isInPrivateMode) { - uiContext.getString(tabcounterR.string.mozac_tab_counter_private, tabsCount.toString()) + environment.context.getString(tabcounterR.string.mozac_tab_counter_private, tabsCount.toString()) } else { - uiContext.getString(tabcounterR.string.mozac_tab_counter_open_tab_tray, tabsCount.toString()) + environment.context.getString(tabcounterR.string.mozac_tab_counter_open_tab_tray, tabsCount.toString()) } TabCounterAction( @@ -518,12 +544,12 @@ class BrowserToolbarMiddleware( HomeToolbarAction.NewTab -> ActionButtonRes( drawableResId = iconsR.drawable.mozac_ic_plus_24, - contentDescription = if (browsingModeManager.mode == Private) { + contentDescription = if (environment?.browsingModeManager?.mode == Private) { R.string.home_screen_shortcut_open_new_private_tab_2 } else { R.string.home_screen_shortcut_open_new_tab_2 }, - onClick = if (browsingModeManager.mode == Private) { + onClick = if (environment?.browsingModeManager?.mode == Private) { AddNewPrivateTab(source) } else { AddNewTab(source) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt @@ -47,8 +47,9 @@ import androidx.core.view.MenuProvider import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.DialogFragment +import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDirections import androidx.navigation.NavOptions @@ -81,13 +82,14 @@ import mozilla.components.compose.browser.toolbar.BrowserToolbar import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.Mode import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.flowScoped import mozilla.components.lib.state.ext.observeAsComposableState -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.view.hideKeyboard @@ -101,11 +103,13 @@ import org.mozilla.fenix.addons.showSnackBar import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.QrScanFenixFeature +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.VoiceSearchFeature import org.mozilla.fenix.components.appstate.AppAction import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider import org.mozilla.fenix.components.metrics.MetricsUtils import org.mozilla.fenix.components.search.HISTORY_SEARCH_ENGINE_ID +import org.mozilla.fenix.components.toolbar.BrowserToolbarEnvironment import org.mozilla.fenix.databinding.FragmentHistoryBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getRootView @@ -128,6 +132,7 @@ import org.mozilla.fenix.search.BrowserToolbarSearchMiddleware import org.mozilla.fenix.search.BrowserToolbarSearchStatusSyncMiddleware import org.mozilla.fenix.search.BrowserToolbarToFenixSearchMapperMiddleware import org.mozilla.fenix.search.FenixSearchMiddleware +import org.mozilla.fenix.search.SearchFragmentAction import org.mozilla.fenix.search.SearchFragmentAction.SuggestionClicked import org.mozilla.fenix.search.SearchFragmentAction.SuggestionSelected import org.mozilla.fenix.search.SearchFragmentStore @@ -142,8 +147,8 @@ private const val MATERIAL_DESIGN_SCRIM = "#52000000" @SuppressWarnings("TooManyFunctions", "LargeClass") class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler, MenuProvider { private lateinit var historyStore: HistoryFragmentStore - private lateinit var searchStore: SearchFragmentStore - private val toolbarStore by buildToolbarStore() + private lateinit var toolbarStore: BrowserToolbarStore + private val searchStore by lazy { buildSearchStore(toolbarStore) } private lateinit var historyProvider: DefaultPagedHistoryProvider @@ -196,18 +201,16 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler, ): View { _binding = FragmentHistoryBinding.inflate(inflater, container, false) val view = binding.root - historyStore = fragmentStore(HistoryFragmentState.initial) { + historyStore = StoreProvider.get(this) { HistoryFragmentStore( - initialState = it, + initialState = HistoryFragmentState.initial, middleware = listOf( HistoryTelemetryMiddleware( isInPrivateMode = requireComponents.appStore.state.mode == BrowsingMode.Private, ), ), ) - }.value - searchStore = buildSearchStore(toolbarStore).value - + } _historyView = HistoryView( container = binding.historyLayout, onZeroItemsLoaded = { @@ -293,6 +296,7 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler, requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) if (requireContext().settings().shouldUseComposableToolbar) { + toolbarStore = buildToolbarStore() qrScanFenixFeature = QrScanFenixFeature.register(this, qrScanLauncher) voiceSearchFeature = VoiceSearchFeature.register(this, voiceSearchLauncher) } @@ -794,61 +798,56 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler, }.create().withCenterAlignedButtons() } - private fun buildToolbarStore() = fragmentStore( - BrowserToolbarState(mode = Mode.EDIT), - ) { - val lifecycleScope = viewLifecycleOwner.lifecycle.coroutineScope - + private fun buildToolbarStore() = StoreProvider.get(this) { BrowserToolbarStore( - initialState = it, + initialState = BrowserToolbarState(mode = Mode.EDIT), middleware = listOf( BrowserToolbarSyncToHistoryMiddleware(historyStore), - BrowserToolbarSearchStatusSyncMiddleware( - appStore = requireComponents.appStore, - browsingModeManager = (requireActivity() as HomeActivity).browsingModeManager, - scope = lifecycleScope, - ), + BrowserToolbarSearchStatusSyncMiddleware(requireComponents.appStore), BrowserToolbarSearchMiddleware( - uiContext = requireActivity(), appStore = requireComponents.appStore, browserStore = requireComponents.core.store, components = requireComponents, + settings = requireComponents.settings, + ), + ), + ) + }.also { + it.dispatch( + EnvironmentRehydrated( + BrowserToolbarEnvironment( + context = requireContext(), + fragment = this, navController = findNavController(), browsingModeManager = (requireActivity() as HomeActivity).browsingModeManager, - settings = requireComponents.settings, - scope = lifecycleScope, ), ), ) + + viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + it.dispatch(EnvironmentCleared) + } + }, + ) } private fun buildSearchStore( toolbarStore: BrowserToolbarStore, - ) = fragmentStore( - createInitialSearchFragmentState( - activity = requireActivity() as HomeActivity, - components = requireComponents, - tabId = null, - pastedText = null, - searchAccessPoint = MetricsUtils.Source.NONE, - ), - ) { - val lifecycleScope = viewLifecycleOwner.lifecycle.coroutineScope - + ) = StoreProvider.get(this) { SearchFragmentStore( - initialState = it, + initialState = createInitialSearchFragmentState( + activity = requireActivity() as HomeActivity, + components = requireComponents, + tabId = null, + pastedText = null, + searchAccessPoint = MetricsUtils.Source.NONE, + ), middleware = listOf( - BrowserToolbarToFenixSearchMapperMiddleware( - toolbarStore = toolbarStore, - browsingModeManager = (requireActivity() as HomeActivity).browsingModeManager, - scope = lifecycleScope, - ), - BrowserStoreToFenixSearchMapperMiddleware( - browserStore = requireComponents.core.store, - scope = lifecycleScope, - ), + BrowserToolbarToFenixSearchMapperMiddleware(toolbarStore), + BrowserStoreToFenixSearchMapperMiddleware(requireComponents.core.store), FenixSearchMiddleware( - uiContext = requireActivity(), engine = requireComponents.core.engine, useCases = requireComponents.useCases, nimbusComponents = requireComponents.nimbus, @@ -856,12 +855,28 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler, appStore = requireComponents.appStore, browserStore = requireComponents.core.store, toolbarStore = toolbarStore, - navController = findNavController(), + ), + ), + ) + }.also { + it.dispatch( + SearchFragmentAction.EnvironmentRehydrated( + SearchFragmentStore.Environment( + context = requireContext(), + viewLifecycleOwner = viewLifecycleOwner, browsingModeManager = (requireActivity() as HomeActivity).browsingModeManager, - scope = lifecycleScope, + navController = findNavController(), ), ), ) + + viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + it.dispatch(SearchFragmentAction.EnvironmentCleared) + } + }, + ) } companion object { diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragment.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.map import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.flowScoped -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.ktx.kotlin.toShortUrl import mozilla.components.ui.widgets.withCenterAlignedButtons @@ -36,6 +35,7 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.addons.showSnackBar import org.mozilla.fenix.browser.browsingmode.BrowsingMode +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentHistoryMetadataGroupBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.nav @@ -86,13 +86,15 @@ class HistoryMetadataGroupFragment : _binding = FragmentHistoryMetadataGroupBinding.inflate(inflater, container, false) val historyItems = args.historyMetadataItems.filterIsInstance<History.Metadata>() - historyMetadataGroupStore = fragmentStore( - HistoryMetadataGroupFragmentState( - items = historyItems, - pendingDeletionItems = requireContext().components.appStore.state.pendingDeletionHistoryItems, - isEmpty = historyItems.isEmpty(), - ), - ) { HistoryMetadataGroupFragmentStore(it) }.value + historyMetadataGroupStore = StoreProvider.get(this) { + HistoryMetadataGroupFragmentStore( + HistoryMetadataGroupFragmentState( + items = historyItems, + pendingDeletionItems = requireContext().components.appStore.state.pendingDeletionHistoryItems, + isEmpty = historyItems.isEmpty(), + ), + ) + } interactor = DefaultHistoryMetadataGroupInteractor( controller = DefaultHistoryMetadataGroupController( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/recentlyclosed/RecentlyClosedFragment.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.map import mozilla.components.browser.state.state.recover.RecoverableTab import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.flowScoped -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.telemetry.glean.private.NoExtras import org.mozilla.fenix.BrowserDirection @@ -29,6 +28,7 @@ import org.mozilla.fenix.GleanMetrics.RecentlyClosedTabs import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentRecentlyClosedTabsBinding import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.setTextColor @@ -106,12 +106,14 @@ class RecentlyClosedFragment : savedInstanceState: Bundle?, ): View { val binding = FragmentRecentlyClosedTabsBinding.inflate(inflater, container, false) - recentlyClosedFragmentStore = fragmentStore( - RecentlyClosedFragmentState( - items = listOf(), - selectedTabs = emptySet(), - ), - ) { RecentlyClosedFragmentStore(it) }.value + recentlyClosedFragmentStore = StoreProvider.get(this) { + RecentlyClosedFragmentStore( + RecentlyClosedFragmentState( + items = listOf(), + selectedTabs = emptySet(), + ), + ) + } recentlyClosedController = DefaultRecentlyClosedController( navController = findNavController(), browserStore = requireComponents.core.store, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/LifecycleHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/LifecycleHolder.kt @@ -0,0 +1,25 @@ +/* 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 org.mozilla.fenix.lifecycle + +import android.content.Context +import androidx.navigation.NavController +import org.mozilla.fenix.HomeActivity + +/** + * A helper class to be able to change the reference to objects that get replaced when the activity + * gets recreated. + * + * @property context the android [Context] + * @property navController A [NavController] for interacting with the androidx navigation library. + * @property composeNavController A [NavController] for navigating within the local Composable nav graph. + * @property homeActivity so that we can reference openToBrowserAndLoad and browsingMode :( + */ +class LifecycleHolder( + var context: Context, + var navController: NavController, + var composeNavController: NavController, + var homeActivity: HomeActivity, +) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesFragment.kt @@ -15,9 +15,9 @@ import androidx.navigation.fragment.navArgs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.support.base.log.logger.Logger import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.compose.core.Action import org.mozilla.fenix.compose.snackbar.Snackbar import org.mozilla.fenix.compose.snackbar.SnackbarState @@ -49,9 +49,9 @@ class NimbusBranchesFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - nimbusBranchesStore = fragmentStore(NimbusBranchesState(branches = emptyList())) { - NimbusBranchesStore(it) - }.value + nimbusBranchesStore = StoreProvider.get(this) { + NimbusBranchesStore(NimbusBranchesState(branches = emptyList())) + } controller = NimbusBranchesController( isTelemetryEnabled = { requireContext().settings().isTelemetryEnabled }, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/OnboardingFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/OnboardingFragment.kt @@ -24,7 +24,6 @@ import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.service.nimbus.evalJexlSafe import mozilla.components.service.nimbus.messaging.use import mozilla.components.support.base.feature.ViewBoundFeatureWrapper @@ -36,6 +35,7 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint import org.mozilla.fenix.components.initializeGlean +import org.mozilla.fenix.components.lazyStore import org.mozilla.fenix.components.startMetricsIfEnabled import org.mozilla.fenix.compose.LinkTextState import org.mozilla.fenix.ext.components @@ -50,7 +50,6 @@ import org.mozilla.fenix.nimbus.FxNimbus import org.mozilla.fenix.onboarding.redesign.view.OnboardingScreenRedesign import org.mozilla.fenix.onboarding.store.DefaultOnboardingPreferencesRepository import org.mozilla.fenix.onboarding.store.OnboardingPreferencesMiddleware -import org.mozilla.fenix.onboarding.store.OnboardingState import org.mozilla.fenix.onboarding.store.OnboardingStore import org.mozilla.fenix.onboarding.view.Caption import org.mozilla.fenix.onboarding.view.ManagePrivacyPreferencesDialogFragment @@ -92,9 +91,8 @@ class OnboardingFragment : Fragment() { } private val telemetryRecorder by lazy { OnboardingTelemetryRecorder() } - private val onboardingStore by fragmentStore(OnboardingState()) { + private val onboardingStore by lazyStore { OnboardingStore( - initialState = it, middleware = listOf( OnboardingPreferencesMiddleware( repository = DefaultOnboardingPreferencesRepository( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/store/OnboardingStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/store/OnboardingStore.kt @@ -58,12 +58,9 @@ sealed interface OnboardingAction : Action { * A [Store] that holds the [OnboardingState] for the onboarding pages and reduces [OnboardingAction]s * dispatched to the store. */ -class OnboardingStore( - initialState: OnboardingState = OnboardingState(), - middleware: List<Middleware<OnboardingState, OnboardingAction>> = emptyList(), -) : +class OnboardingStore(middleware: List<Middleware<OnboardingState, OnboardingAction>> = emptyList()) : Store<OnboardingState, OnboardingAction>( - initialState = initialState, + initialState = OnboardingState(), reducer = ::reducer, middleware = middleware, ) { diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/view/ManagePrivacyPreferencesDialogFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/view/ManagePrivacyPreferencesDialogFragment.kt @@ -9,7 +9,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.fragment.app.DialogFragment import androidx.fragment.compose.content -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore +import org.mozilla.fenix.components.lazyStore import org.mozilla.fenix.ext.settings import org.mozilla.fenix.onboarding.ManagePrivacyPreferencesDialog import org.mozilla.fenix.onboarding.store.DefaultPrivacyPreferencesRepository @@ -28,18 +28,15 @@ import org.mozilla.fenix.theme.FirefoxTheme */ class ManagePrivacyPreferencesDialogFragment : DialogFragment() { - private val repository = DefaultPrivacyPreferencesRepository( - settings = requireContext().settings(), - ) - - private val store by fragmentStore( - PrivacyPreferencesState( - crashReportingEnabled = repository.getPreference(PreferenceType.CrashReporting), - usageDataEnabled = repository.getPreference(PreferenceType.UsageData), - ), - ) { + private val store by lazyStore { + val repository = DefaultPrivacyPreferencesRepository( + settings = requireContext().settings(), + ) PrivacyPreferencesStore( - initialState = it, + initialState = PrivacyPreferencesState( + crashReportingEnabled = repository.getPreference(PreferenceType.CrashReporting), + usageDataEnabled = repository.getPreference(PreferenceType.UsageData), + ), middlewares = listOf( PrivacyPreferencesMiddleware(repository), PrivacyPreferencesTelemetryMiddleware(), diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/reviewprompt/CustomReviewPromptBottomSheetFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/reviewprompt/CustomReviewPromptBottomSheetFragment.kt @@ -14,14 +14,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.fragment.compose.content import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewModelScope import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetDialogFragment import kotlinx.coroutines.launch import mozilla.components.lib.state.ext.observeAsState -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore -import mozilla.components.lib.state.helpers.StoreProvider.Companion.storeProvider import org.mozilla.fenix.R +import org.mozilla.fenix.components.lazyStore import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.reviewprompt.CustomReviewPromptAction.LeaveFeedbackButtonClicked import org.mozilla.fenix.reviewprompt.CustomReviewPromptAction.NegativePrePromptButtonClicked @@ -33,11 +31,11 @@ import com.google.android.material.R as materialR /** A bottom sheet fragment for displaying [CustomReviewPrompt]. */ class CustomReviewPromptBottomSheetFragment : BottomSheetDialogFragment() { - private val store by fragmentStore(CustomReviewPromptState.PrePrompt) { + private val store by lazyStore { viewModelScope -> CustomReviewPromptStore( - initialState = it, + initialState = CustomReviewPromptState.PrePrompt, middleware = listOf( - CustomReviewPromptNavigationMiddleware(storeProvider.viewModelScope), + CustomReviewPromptNavigationMiddleware(viewModelScope), CustomReviewPromptTelemetryMiddleware(), ), ) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserStoreToFenixSearchMapperMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserStoreToFenixSearchMapperMiddleware.kt @@ -5,7 +5,9 @@ package org.mozilla.fenix.search import androidx.annotation.VisibleForTesting -import kotlinx.coroutines.CoroutineScope +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -17,7 +19,8 @@ import mozilla.components.lib.state.MiddlewareContext import mozilla.components.lib.state.State import mozilla.components.lib.state.Store import mozilla.components.lib.state.ext.flow -import org.mozilla.fenix.search.SearchFragmentAction.Init +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentCleared +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentRehydrated import org.mozilla.fenix.search.SearchFragmentAction.UpdateSearchState import org.mozilla.fenix.search.SearchFragmentStore.Environment import mozilla.components.lib.state.Action as MVIAction @@ -26,11 +29,9 @@ import mozilla.components.lib.state.Action as MVIAction * [SearchFragmentStore] [Middleware] to synchronize search related details from [BrowserStore]. * * @param browserStore The [BrowserStore] to sync from. - * @param scope [CoroutineScope] used for running long running operations in background. */ class BrowserStoreToFenixSearchMapperMiddleware( private val browserStore: BrowserStore, - private val scope: CoroutineScope, ) : Middleware<SearchFragmentState, SearchFragmentAction> { @VisibleForTesting internal var environment: Environment? = null @@ -43,8 +44,13 @@ class BrowserStoreToFenixSearchMapperMiddleware( ) { next(action) - if (action is Init) { + if (action is EnvironmentRehydrated) { + environment = action.environment + observeBrowserSearchState(context) + } else if (action is EnvironmentCleared) { + observeBrowserSearchStateJob?.cancel() + environment = null } } @@ -62,5 +68,11 @@ class BrowserStoreToFenixSearchMapperMiddleware( private inline fun <S : State, A : MVIAction> Store<S, A>.observeWhileActive( crossinline observe: suspend (Flow<S>.() -> Unit), - ): Job = scope.launch { flow().observe() } + ): Job? = environment?.viewLifecycleOwner?.run { + lifecycleScope.launch { + repeatOnLifecycle(RESUMED) { + flow().observe() + } + } + } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddleware.kt @@ -4,15 +4,16 @@ package org.mozilla.fenix.search -import android.content.Context import android.content.Intent import android.content.res.Resources import android.speech.RecognizerIntent import androidx.annotation.VisibleForTesting import androidx.core.graphics.drawable.toDrawable +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher @@ -43,7 +44,6 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.CommitUrl import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.EnterEditMode import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.ExitEditMode -import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.Init import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarMenu import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem @@ -51,6 +51,8 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.B import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.BrowserToolbarMenuDivider import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.toolbar.AutocompleteProvider @@ -68,7 +70,7 @@ import org.mozilla.fenix.GleanMetrics.Toolbar import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R import org.mozilla.fenix.browser.BrowserFragmentDirections -import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager +import org.mozilla.fenix.browser.browsingmode.BrowsingMode.Private import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.appstate.AppAction @@ -82,6 +84,7 @@ import org.mozilla.fenix.components.metrics.MetricsUtils import org.mozilla.fenix.components.search.BOOKMARKS_SEARCH_ENGINE_ID import org.mozilla.fenix.components.search.HISTORY_SEARCH_ENGINE_ID import org.mozilla.fenix.components.search.TABS_SEARCH_ENGINE_ID +import org.mozilla.fenix.components.toolbar.BrowserToolbarEnvironment import org.mozilla.fenix.ext.toolbarHintRes import org.mozilla.fenix.search.EditPageEndActionsInteractions.ClearSearchClicked import org.mozilla.fenix.search.EditPageEndActionsInteractions.QrScannerClicked @@ -135,28 +138,21 @@ internal sealed class EditPageEndActionsInteractions : BrowserToolbarEvent { * [BrowserToolbarStore] middleware handling the configuration of the composable toolbar * while in edit mode. * - * @param uiContext [Context] used for various system interactions. * @param appStore [AppStore] used for querying and updating application state. * @param browserStore [BrowserStore] used for querying and updating browser state. * @param components [Components] for accessing other functionalities of the application. - * @param navController [NavController] to use for navigating to other in-app destinations. - * @param browsingModeManager [BrowsingModeManager] for querying the current browsing mode. * @param settings [Settings] for accessing application settings. - * @param scope [CoroutineScope] used for running long running operations in background. * @param autocompleteDispatcher [CoroutineContext] used for querying autocomplete suggestions. */ -@Suppress("LongParameterList") class BrowserToolbarSearchMiddleware( - private val uiContext: Context, private val appStore: AppStore, private val browserStore: BrowserStore, private val components: Components, - private val navController: NavController, - private val browsingModeManager: BrowsingModeManager, private val settings: Settings, - private val scope: CoroutineScope, private val autocompleteDispatcher: CoroutineContext = defaultAutocompleteDispatcher, ) : Middleware<BrowserToolbarState, BrowserToolbarAction> { + @VisibleForTesting + internal var environment: BrowserToolbarEnvironment? = null private var syncCurrentSearchEngineJob: Job? = null private var syncAvailableSearchEnginesJob: Job? = null private var observeQRScannerInputJob: Job? = null @@ -174,12 +170,18 @@ class BrowserToolbarSearchMiddleware( } when (action) { - is Init -> { + is EnvironmentRehydrated -> { + environment = action.environment as? BrowserToolbarEnvironment + if (context.state.isEditMode()) { syncCurrentSearchEngine(context) } } + is EnvironmentCleared -> { + environment = null + } + is EnterEditMode -> { refreshConfigurationAfterSearchEngineChange( context = context, @@ -215,7 +217,7 @@ class BrowserToolbarSearchMiddleware( context.dispatch(SearchQueryUpdated(BrowserToolbarQuery(""))) appStore.dispatch(SearchEnded) browserStore.dispatch(EngagementFinished(abandoned = true)) - navController.navigate( + environment?.navController?.navigate( BrowserFragmentDirections.actionGlobalSearchEngineFragment(), ) } @@ -233,6 +235,8 @@ class BrowserToolbarSearchMiddleware( return } + val navController = environment?.navController ?: return + when (action.text) { "about:crashes" -> { // The list of past crashes can be accessed via "settings > about", but desktop and @@ -318,7 +322,7 @@ class BrowserToolbarSearchMiddleware( searchTermOrURL = text, newTab = newTab, forceSearch = !isDefaultEngine, - private = browsingModeManager.mode.isPrivate, + private = environment?.browsingModeManager?.mode == Private, searchEngine = searchEngine, ) @@ -367,8 +371,10 @@ class BrowserToolbarSearchMiddleware( selectedSearchEngine: SearchEngine?, searchEngineShortcuts: List<SearchEngine>, ) { + val environment = environment ?: return + val searchSelector = buildSearchSelector( - selectedSearchEngine, searchEngineShortcuts, uiContext.resources, + selectedSearchEngine, searchEngineShortcuts, environment.context.resources, ) context.dispatch( SearchActionsStartUpdated( @@ -420,7 +426,7 @@ class BrowserToolbarSearchMiddleware( val isBackspacing = query.previous?.startsWith(query.current) == true && query.previous?.length == query.current.length + 1 if (shouldCheckForSuggestions && !isBackspacing) { - updateAutocompleteJob = scope.launch { + updateAutocompleteJob = environment?.fragment?.viewLifecycleOwner?.lifecycleScope?.launch { context.dispatch( BrowserEditToolbarAction.AutocompleteSuggestionUpdated( withContext(autocompleteDispatcher) { @@ -545,9 +551,9 @@ class BrowserToolbarSearchMiddleware( searchTermOrURL = it.qrScannerState.lastScanData, newTab = appStore.state.searchState.sourceTabId == null, flags = EngineSession.LoadUrlFlags.external(), - private = browsingModeManager.mode.isPrivate, + private = environment?.browsingModeManager?.mode == Private, ) - navController.navigate(R.id.action_global_browser) + environment?.navController?.navigate(R.id.action_global_browser) } } } @@ -575,13 +581,20 @@ class BrowserToolbarSearchMiddleware( } @VisibleForTesting - internal fun isSpeechRecognitionAvailable() = + internal fun isSpeechRecognitionAvailable() = environment?.context?.let { Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) - .resolveActivity(uiContext.packageManager) != null + .resolveActivity(it.packageManager) != null + } ?: false private inline fun <S : State, A : MVIAction> Store<S, A>.observeWhileActive( crossinline observe: suspend (Flow<S>.() -> Unit), - ): Job = scope.launch { flow().observe() } + ): Job? = environment?.fragment?.viewLifecycleOwner?.run { + lifecycleScope.launch { + repeatOnLifecycle(RESUMED) { + flow().observe() + } + } + } /** * Static functionalities of the [BrowserToolbarSearchMiddleware]. diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarSearchStatusSyncMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarSearchStatusSyncMiddleware.kt @@ -4,36 +4,42 @@ package org.mozilla.fenix.search -import kotlinx.coroutines.CoroutineScope +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.launch import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.PrivateModeUpdated import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.EnterEditMode import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.ExitEditMode -import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.Init import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store import mozilla.components.lib.state.ext.flow -import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.appstate.AppAction.SearchAction.SearchEnded +import org.mozilla.fenix.components.toolbar.BrowserToolbarEnvironment +import mozilla.components.lib.state.Action as MVIAction /** * [Middleware] for synchronizing whether a search is active between [BrowserToolbarStore] and [AppStore]. * * @param appStore [AppStore] through which the toolbar updates can be integrated with other application features. - * @param browsingModeManager [BrowsingModeManager] for querying the current browsing mode. - * @param scope [CoroutineScope] used for running long running operations in background. */ class BrowserToolbarSearchStatusSyncMiddleware( private val appStore: AppStore, - private val browsingModeManager: BrowsingModeManager, - private val scope: CoroutineScope, ) : Middleware<BrowserToolbarState, BrowserToolbarAction> { + @VisibleForTesting + internal var environment: BrowserToolbarEnvironment? = null private var syncSearchActiveJob: Job? = null override fun invoke( @@ -43,9 +49,14 @@ class BrowserToolbarSearchStatusSyncMiddleware( ) { next(action) - if (action is Init) { + if (action is EnvironmentRehydrated) { + environment = action.environment as? BrowserToolbarEnvironment syncSearchActive(context) } + if (action is EnvironmentCleared) { + syncSearchActiveJob?.cancel() + environment = null + } if (action is ExitEditMode) { // Only support the toolbar triggering exiting search mode in the application. @@ -56,13 +67,12 @@ class BrowserToolbarSearchStatusSyncMiddleware( } private fun syncSearchActive(context: MiddlewareContext<BrowserToolbarState, BrowserToolbarAction>) { - syncSearchActiveJob = scope.launch { - appStore.flow() - .distinctUntilChangedBy { it.searchState.isSearchActive } + syncSearchActiveJob = appStore.observeWhileActive { + distinctUntilChangedBy { it.searchState.isSearchActive } .collect { if (it.searchState.isSearchActive) { context.dispatch( - PrivateModeUpdated(browsingModeManager.mode.isPrivate), + PrivateModeUpdated(environment?.browsingModeManager?.mode?.isPrivate == true), ) context.dispatch(EnterEditMode) } else { @@ -71,4 +81,14 @@ class BrowserToolbarSearchStatusSyncMiddleware( } } } + + private inline fun <S : State, A : MVIAction> Store<S, A>.observeWhileActive( + crossinline observe: suspend (Flow<S>.() -> Unit), + ): Job? = environment?.fragment?.viewLifecycleOwner?.run { + lifecycleScope.launch { + repeatOnLifecycle(RESUMED) { + flow().observe() + } + } + } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddleware.kt @@ -4,8 +4,12 @@ package org.mozilla.fenix.search -import kotlinx.coroutines.CoroutineScope +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.map @@ -16,25 +20,27 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.store.Mode import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store import mozilla.components.lib.state.ext.flow -import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager -import org.mozilla.fenix.search.SearchFragmentAction.Init +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentCleared +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentRehydrated import org.mozilla.fenix.search.SearchFragmentAction.SearchStarted +import org.mozilla.fenix.search.SearchFragmentStore.Environment +import mozilla.components.lib.state.Action as MVIAction /** * [SearchFragmentStore] [Middleware] to synchronize search related details from [BrowserToolbarStore]. * * @param toolbarStore The [BrowserToolbarStore] to sync from. - * @param browsingModeManager [BrowsingModeManager] for querying the current browsing mode. - * @param scope [CoroutineScope] used for running long running operations in background. * @param browserStore The [BrowserStore] to sync from. */ class BrowserToolbarToFenixSearchMapperMiddleware( private val toolbarStore: BrowserToolbarStore, - private val browsingModeManager: BrowsingModeManager, - private val scope: CoroutineScope, private val browserStore: BrowserStore? = null, ) : Middleware<SearchFragmentState, SearchFragmentAction> { + @VisibleForTesting + internal var environment: Environment? = null private var syncSearchStartedJob: Job? = null private var syncSearchQueryJob: Job? = null @@ -43,12 +49,16 @@ class BrowserToolbarToFenixSearchMapperMiddleware( next: (SearchFragmentAction) -> Unit, action: SearchFragmentAction, ) { - if (action is Init) { + if (action is EnvironmentRehydrated) { + environment = action.environment + syncSearchStatus(context) if (toolbarStore.state.isEditMode()) { syncUserQuery(context) } + } else if (action is EnvironmentCleared) { + environment = null } next(action) @@ -56,9 +66,8 @@ class BrowserToolbarToFenixSearchMapperMiddleware( private fun syncSearchStatus(context: MiddlewareContext<SearchFragmentState, SearchFragmentAction>) { syncSearchStartedJob?.cancel() - syncSearchStartedJob = scope.launch { - toolbarStore.flow() - .distinctUntilChangedBy { it.mode } + syncSearchStartedJob = toolbarStore.observeWhileActive { + distinctUntilChangedBy { it.mode } .collect { if (it.mode == Mode.EDIT) { val editState = toolbarStore.state.editState @@ -66,7 +75,7 @@ class BrowserToolbarToFenixSearchMapperMiddleware( SearchStarted( selectedSearchEngine = null, isUserSelected = true, - inPrivateMode = browsingModeManager.mode.isPrivate, + inPrivateMode = environment?.browsingModeManager?.mode?.isPrivate == true, searchStartedForCurrentUrl = editState.isQueryPrefilled && browserStore?.state?.selectedTab?.content?.url == editState.query.current, ), @@ -82,9 +91,8 @@ class BrowserToolbarToFenixSearchMapperMiddleware( private fun syncUserQuery(context: MiddlewareContext<SearchFragmentState, SearchFragmentAction>) { syncSearchQueryJob?.cancel() - syncSearchQueryJob = scope.launch { - toolbarStore.flow() - .map { it.editState.query } + syncSearchQueryJob = toolbarStore.observeWhileActive { + map { it.editState.query } .distinctUntilChanged() .collect { query -> val isSearchStartedForCurrentUrl = context.state.searchStartedForCurrentUrl @@ -104,4 +112,14 @@ class BrowserToolbarToFenixSearchMapperMiddleware( private fun stopSyncingUserQuery() { syncSearchQueryJob?.cancel() } + + private inline fun <S : State, A : MVIAction> Store<S, A>.observeWhileActive( + crossinline observe: suspend (Flow<S>.() -> Unit), + ): Job? = environment?.viewLifecycleOwner?.run { + lifecycleScope.launch { + repeatOnLifecycle(RESUMED) { + flow().observe() + } + } + } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/FenixSearchMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/FenixSearchMiddleware.kt @@ -4,10 +4,10 @@ package org.mozilla.fenix.search -import android.content.Context import androidx.annotation.VisibleForTesting -import androidx.navigation.NavController -import kotlinx.coroutines.CoroutineScope +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -37,7 +37,6 @@ import org.mozilla.fenix.GleanMetrics.History import org.mozilla.fenix.GleanMetrics.Toolbar import org.mozilla.fenix.R import org.mozilla.fenix.browser.browsingmode.BrowsingMode -import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.NimbusComponents import org.mozilla.fenix.components.UseCases @@ -50,6 +49,8 @@ import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.navigateSafe import org.mozilla.fenix.ext.telemetryName import org.mozilla.fenix.nimbus.FxNimbus +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentCleared +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentRehydrated import org.mozilla.fenix.search.SearchFragmentAction.Init import org.mozilla.fenix.search.SearchFragmentAction.PrivateSuggestionsCardAccepted import org.mozilla.fenix.search.SearchFragmentAction.SearchEnginesSelectedActions @@ -59,6 +60,7 @@ import org.mozilla.fenix.search.SearchFragmentAction.SearchSuggestionsVisibility import org.mozilla.fenix.search.SearchFragmentAction.SuggestionClicked import org.mozilla.fenix.search.SearchFragmentAction.SuggestionSelected import org.mozilla.fenix.search.SearchFragmentAction.UpdateQuery +import org.mozilla.fenix.search.SearchFragmentStore.Environment import org.mozilla.fenix.search.awesomebar.DefaultSuggestionIconProvider import org.mozilla.fenix.search.awesomebar.DefaultSuggestionsStringsProvider import org.mozilla.fenix.search.awesomebar.SearchSuggestionsProvidersBuilder @@ -71,7 +73,6 @@ import mozilla.components.lib.state.Action as MVIAction /** * [SearchFragmentStore] [Middleware] that will handle the setup of the search UX and related user interactions. * - * @param uiContext [Context] used for various system interactions. * @param engine [Engine] used for speculative connections to search suggestions URLs. * @param useCases [UseCases] helping this integrate with other features of the applications. * @param nimbusComponents [NimbusComponents] used for accessing Nimbus events to use in telemetry. @@ -79,13 +80,9 @@ import mozilla.components.lib.state.Action as MVIAction * @param appStore [AppStore] to sync search related data with. * @param browserStore [BrowserStore] to sync search related data with. * @param toolbarStore [BrowserToolbarStore] used for querying and updating the toolbar state. - * @param navController [NavController] to use for navigating to other in-app destinations. - * @param browsingModeManager [BrowsingModeManager] used for querying and updating the browsing mode. - * @param scope [CoroutineScope] used for running long running operations in background. */ @Suppress("LongParameterList") class FenixSearchMiddleware( - private val uiContext: Context, private val engine: Engine, private val useCases: UseCases, private val nimbusComponents: NimbusComponents, @@ -93,10 +90,9 @@ class FenixSearchMiddleware( private val appStore: AppStore, private val browserStore: BrowserStore, private val toolbarStore: BrowserToolbarStore, - private val navController: NavController, - private val browsingModeManager: BrowsingModeManager, - private val scope: CoroutineScope, ) : Middleware<SearchFragmentState, SearchFragmentAction> { + @VisibleForTesting + internal var environment: Environment? = null private var observeSearchEnginesChangeJob: Job? = null @VisibleForTesting @@ -107,13 +103,12 @@ class FenixSearchMiddleware( next: (SearchFragmentAction) -> Unit, action: SearchFragmentAction, ) { + if (handleEnvironmentUpdates(context, next, action)) return + when (action) { is Init -> { next(action) - suggestionsProvidersBuilder = buildSearchSuggestionsProvider(context) - updateSearchProviders(context) - context.dispatch( SearchFragmentAction.UpdateSearchState( browserStore.state.search, @@ -181,6 +176,38 @@ class FenixSearchMiddleware( } } + private fun handleEnvironmentUpdates( + context: MiddlewareContext<SearchFragmentState, SearchFragmentAction>, + next: (SearchFragmentAction) -> Unit, + action: SearchFragmentAction, + ) = when (action) { + is EnvironmentRehydrated -> { + next(action) + + environment = action.environment + + suggestionsProvidersBuilder = buildSearchSuggestionsProvider(context) + updateSearchProviders(context) + + true + } + + is EnvironmentCleared -> { + next(action) + + environment = null + + // Search providers may keep hard references to lifecycle dependent objects + // so we need to reset them when the environment is cleared. + suggestionsProvidersBuilder = null + context.dispatch(SearchProvidersUpdated(emptyList())) + + true + } + + else -> false + } + /** * Observe when the user changes the search engine to use for the current in-progress search * and update the suggestions providers used and shown suggestions accordingly. @@ -241,7 +268,7 @@ class FenixSearchMiddleware( val showPrivatePrompt = with(context.state) { !settings.showSearchSuggestionsInPrivateOnboardingFinished && - browsingModeManager.mode.isPrivate && + environment?.browsingModeManager?.mode?.isPrivate == true && !isSearchSuggestionsFeatureEnabled() && !showSearchShortcuts && url != query } @@ -273,18 +300,20 @@ class FenixSearchMiddleware( internal fun buildSearchSuggestionsProvider( context: MiddlewareContext<SearchFragmentState, SearchFragmentAction>, ): SearchSuggestionsProvidersBuilder? { + val environment = environment ?: return null + return SearchSuggestionsProvidersBuilder( - components = uiContext.components, - browsingModeManager = browsingModeManager, + components = environment.context.components, + browsingModeManager = environment.browsingModeManager, includeSelectedTab = context.state.tabId == null, loadUrlUseCase = loadUrlUseCase(context), searchUseCase = searchUseCase(context), selectTabUseCase = selectTabUseCase(), suggestionsStringsProvider = DefaultSuggestionsStringsProvider( - uiContext, - DefaultSearchEngineProvider(uiContext.components.core.store), + environment.context, + DefaultSearchEngineProvider(environment.context.components.core.store), ), - suggestionIconProvider = DefaultSuggestionIconProvider(uiContext), + suggestionIconProvider = DefaultSuggestionIconProvider(environment.context), onSearchEngineShortcutSelected = ::handleSearchEngineSuggestionClicked, onSearchEngineSuggestionSelected = ::handleSearchEngineSuggestionClicked, onSearchEngineSettingsClicked = { handleClickSearchEngineSettings() }, @@ -308,7 +337,7 @@ class FenixSearchMiddleware( } else { context.state.tabId == null }, - usePrivateMode = browsingModeManager.mode.isPrivate, + usePrivateMode = environment?.browsingModeManager?.mode?.isPrivate == true, flags = flags, ) @@ -336,7 +365,7 @@ class FenixSearchMiddleware( } else { context.state.tabId == null }, - usePrivateMode = browsingModeManager.mode.isPrivate, + usePrivateMode = environment?.browsingModeManager?.mode?.isPrivate == true, forceSearch = true, searchEngine = searchEngine, ) @@ -364,7 +393,7 @@ class FenixSearchMiddleware( override fun invoke(tabId: String) { useCases.tabsUseCases.selectTab(tabId) - navController.navigate(R.id.browserFragment) + environment?.navController?.navigate(R.id.browserFragment) browserStore.dispatch(AwesomeBarAction.EngagementFinished(abandoned = false)) } @@ -378,7 +407,7 @@ class FenixSearchMiddleware( searchEngine: SearchEngine? = null, flags: LoadUrlFlags = LoadUrlFlags.none(), ) { - navController.navigate(R.id.browserFragment) + environment?.navController?.navigate(R.id.browserFragment) useCases.fenixBrowserUseCases.loadUrlOrSearch( searchTermOrURL = url, newTab = createNewTab, @@ -425,6 +454,8 @@ class FenixSearchMiddleware( context: MiddlewareContext<SearchFragmentState, SearchFragmentAction>, searchEngine: SearchEngine, ) { + val environment = environment ?: return + when { searchEngine.type == SearchEngine.Type.APPLICATION && searchEngine.id == HISTORY_SEARCH_ENGINE_ID -> { context.dispatch(SearchFragmentAction.SearchHistoryEngineSelected(searchEngine)) @@ -439,7 +470,7 @@ class FenixSearchMiddleware( context.dispatch( SearchFragmentAction.SearchDefaultEngineSelected( engine = searchEngine, - browsingMode = browsingModeManager.mode, + browsingMode = environment.browsingModeManager.mode, settings = settings, ), ) @@ -448,7 +479,7 @@ class FenixSearchMiddleware( context.dispatch( SearchFragmentAction.SearchShortcutEngineSelected( engine = searchEngine, - browsingMode = browsingModeManager.mode, + browsingMode = environment.browsingModeManager.mode, settings = settings, ), ) @@ -463,13 +494,19 @@ class FenixSearchMiddleware( @VisibleForTesting internal fun handleClickSearchEngineSettings() { val directions = SearchDialogFragmentDirections.actionGlobalSearchEngineFragment() - navController.navigateSafe(R.id.searchDialogFragment, directions) + environment?.navController?.navigateSafe(R.id.searchDialogFragment, directions) browserStore.dispatch(AwesomeBarAction.EngagementFinished(abandoned = true)) } private inline fun <S : State, A : MVIAction> Store<S, A>.observeWhileActive( crossinline observe: suspend (Flow<S>.() -> Unit), - ): Job = scope.launch { flow().observe() } + ): Job? = environment?.viewLifecycleOwner?.run { + lifecycleScope.launch { + repeatOnLifecycle(RESUMED) { + flow().observe() + } + } + } /** * Check whether search suggestions should be shown in the AwesomeBar. @@ -478,7 +515,9 @@ class FenixSearchMiddleware( */ @VisibleForTesting internal fun isSearchSuggestionsFeatureEnabled(): Boolean { - return when (browsingModeManager.mode) { + val environment = environment ?: return false + + return when (environment.browsingModeManager.mode) { BrowsingMode.Normal -> settings.shouldShowSearchSuggestions BrowsingMode.Private -> settings.shouldShowSearchSuggestions && settings.shouldShowSearchSuggestionsInPrivate diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/SearchFragmentStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/SearchFragmentStore.kt @@ -26,6 +26,7 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.metrics.MetricsUtils import org.mozilla.fenix.search.SearchFragmentAction.Init +import org.mozilla.fenix.search.SearchFragmentStore.Environment import org.mozilla.fenix.utils.Settings /** @@ -424,6 +425,16 @@ sealed class SearchFragmentAction : Action { ) : SearchFragmentAction() /** + * Signals a new valid [Environment] has been set. + */ + data class EnvironmentRehydrated(val environment: Environment) : SearchFragmentAction() + + /** + * Signals the current [Environment] is not valid anymore. + */ + data object EnvironmentCleared : SearchFragmentAction() + + /** * Action indicating the user allowed to show suggestions in private mode. */ data object PrivateSuggestionsCardAccepted : SearchFragmentAction() @@ -619,6 +630,8 @@ private fun searchStateReducer(state: SearchFragmentState, action: SearchFragmen state.copy(searchStartedForCurrentUrl = action.searchStartedForCurrentUrl) } + is SearchFragmentAction.EnvironmentRehydrated, + is SearchFragmentAction.EnvironmentCleared, is SearchFragmentAction.SuggestionClicked, is SearchFragmentAction.PrivateSuggestionsCardAccepted, is SearchFragmentAction.SuggestionSelected, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarComposable.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarComposable.kt @@ -25,7 +25,8 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.core.graphics.toColorInt import androidx.fragment.app.Fragment -import androidx.lifecycle.coroutineScope +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.navigation.NavController import mozilla.components.browser.state.action.AwesomeBarAction import mozilla.components.browser.state.store.BrowserStore @@ -37,12 +38,12 @@ import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.lib.state.ext.observeAsComposableState -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.support.ktx.android.view.hideKeyboard import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.Components +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.appstate.AppAction.SearchAction.SearchEnded import org.mozilla.fenix.components.metrics.MetricsUtils import org.mozilla.fenix.ext.settings @@ -63,13 +64,13 @@ private const val MATERIAL_DESIGN_SCRIM = "#52000000" * Wrapper over a [Composable] to show search suggestions, responsible for its setup. * * @param activity [HomeActivity] providing the ability to open URLs and querying the current browsing mode. - * @param fragment [Fragment] to the lifecycle of which long running operations and objects will be tied to. * @param modifier [Modifier] to be applied to the [Composable]. * @param components [Components] for accessing other functionalities of the application. * @param appStore [AppStore] for accessing the current application state. * @param browserStore [BrowserStore] for accessing the current browser state. * @param toolbarStore [BrowserToolbarStore] for accessing the current toolbar state. * @param navController [NavController] for navigating to other destinations in the application. + * @param lifecycleOwner [Fragment] for controlling the lifetime of long running operations. * @param tabId [String] Id of the current tab for which a new search was started. * @param showScrimWhenNoSuggestions Whether to show a scrim when no suggestions are available. * @param searchAccessPoint Where search was started from. @@ -77,18 +78,18 @@ private const val MATERIAL_DESIGN_SCRIM = "#52000000" @Suppress("LongParameterList") class AwesomeBarComposable( private val activity: HomeActivity, - private val fragment: Fragment, private val modifier: Modifier, private val components: Components, private val appStore: AppStore, private val browserStore: BrowserStore, private val toolbarStore: BrowserToolbarStore, private val navController: NavController, + private val lifecycleOwner: Fragment, private val tabId: String? = null, private val showScrimWhenNoSuggestions: Boolean = false, private val searchAccessPoint: MetricsUtils.Source = MetricsUtils.Source.NONE, ) { - private val searchStore by initializeSearchStore() + private val searchStore = initializeSearchStore() /** * [Composable] fully integrated with [BrowserStore] and [BrowserToolbarStore] @@ -253,32 +254,19 @@ class AwesomeBarComposable( } } - private fun initializeSearchStore() = fragment.fragmentStore( - createInitialSearchFragmentState( - activity = activity, - components = components, - tabId = tabId, - pastedText = null, - searchAccessPoint = searchAccessPoint, - ), - ) { - val lifecycleScope = fragment.viewLifecycleOwner.lifecycle.coroutineScope - + private fun initializeSearchStore() = StoreProvider.get(lifecycleOwner) { SearchFragmentStore( - initialState = it, + initialState = createInitialSearchFragmentState( + activity = activity, + components = components, + tabId = tabId, + pastedText = null, + searchAccessPoint = searchAccessPoint, + ), middleware = listOf( - BrowserToolbarToFenixSearchMapperMiddleware( - toolbarStore = toolbarStore, - browsingModeManager = activity.browsingModeManager, - scope = lifecycleScope, - browserStore = browserStore, - ), - BrowserStoreToFenixSearchMapperMiddleware( - browserStore = browserStore, - scope = lifecycleScope, - ), + BrowserToolbarToFenixSearchMapperMiddleware(toolbarStore, browserStore), + BrowserStoreToFenixSearchMapperMiddleware(browserStore), FenixSearchMiddleware( - uiContext = activity, engine = components.core.engine, useCases = components.useCases, nimbusComponents = components.nimbus, @@ -286,11 +274,27 @@ class AwesomeBarComposable( appStore = appStore, browserStore = browserStore, toolbarStore = toolbarStore, - navController = navController, + ), + ), + ) + }.also { + it.dispatch( + SearchFragmentAction.EnvironmentRehydrated( + SearchFragmentStore.Environment( + context = activity, + viewLifecycleOwner = lifecycleOwner.viewLifecycleOwner, browsingModeManager = activity.browsingModeManager, - scope = lifecycleScope, + navController = navController, ), ), ) + + lifecycleOwner.viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + it.dispatch(SearchFragmentAction.EnvironmentCleared) + } + }, + ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt @@ -30,7 +30,6 @@ import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.ConstellationState import mozilla.components.concept.sync.DeviceConstellationObserver import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.service.fxa.SyncEngine import mozilla.components.service.fxa.manager.FxaAccountManager import mozilla.components.service.fxa.manager.SyncEnginesStorage @@ -43,6 +42,7 @@ import mozilla.components.ui.widgets.withCenterAlignedButtons import mozilla.telemetry.glean.private.NoExtras import org.mozilla.fenix.GleanMetrics.SyncAccount import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint import org.mozilla.fenix.compose.snackbar.Snackbar import org.mozilla.fenix.compose.snackbar.SnackbarState @@ -144,18 +144,20 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.account_settings_preferences, rootKey) - accountSettingsStore = fragmentStore( - AccountSettingsFragmentState( - lastSyncedDate = if (getLastSynced(requireContext()) == 0L) { - LastSyncTime.Never - } else { - LastSyncTime.Success(getLastSynced(requireContext())) - }, - deviceName = requireComponents.backgroundServices.defaultDeviceName( - requireContext(), + accountSettingsStore = StoreProvider.get(this) { + AccountSettingsFragmentStore( + AccountSettingsFragmentState( + lastSyncedDate = if (getLastSynced(requireContext()) == 0L) { + LastSyncTime.Never + } else { + LastSyncTime.Success(getLastSynced(requireContext())) + }, + deviceName = requireComponents.backgroundServices.defaultDeviceName( + requireContext(), + ), ), - ), - ) { AccountSettingsFragmentStore(it) }.value + ) + } accountManager = requireComponents.backgroundServices.accountManager accountManager.register(accountStateObserver, this, true) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressEditorFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressEditorFragment.kt @@ -15,8 +15,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.autofill.AddressStructure -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import org.mozilla.fenix.SecureFragment +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.settings.address.store.AddressEnvironment @@ -24,6 +24,7 @@ import org.mozilla.fenix.settings.address.store.AddressMiddleware import org.mozilla.fenix.settings.address.store.AddressState import org.mozilla.fenix.settings.address.store.AddressStore import org.mozilla.fenix.settings.address.store.AddressStructureMiddleware +import org.mozilla.fenix.settings.address.store.EnvironmentRehydrated import org.mozilla.fenix.settings.address.ui.edit.EditAddressScreen import org.mozilla.fenix.theme.FirefoxTheme import kotlin.coroutines.resume @@ -41,12 +42,18 @@ class AddressEditorFragment : SecureFragment() { container: ViewGroup?, savedInstanceState: Bundle?, ) = content { - val store = fragmentStore( - AddressState.initial( - region = requireComponents.core.store.state.search.region, - address = args.address, - ), - ) { + val store = StoreProvider.get(this) { + AddressStore( + initialState = AddressState.initial( + region = requireComponents.core.store.state.search.region, + address = args.address, + ), + middleware = listOf( + AddressMiddleware(scope = viewLifecycleOwner.lifecycleScope), + AddressStructureMiddleware(scope = viewLifecycleOwner.lifecycleScope), + ), + ) + }.also { val storage = requireComponents.core.autofillStorage val engine = requireComponents.core.engine val crashReporter = requireComponents.analytics.crashReporter @@ -58,23 +65,10 @@ class AddressEditorFragment : SecureFragment() { getAddressStructure = engine::getAddressStructure, submitCaughtException = crashReporter::submitCaughtException, ) - - AddressStore( - initialState = it, - middleware = listOf( - AddressMiddleware( - environment = environment, - scope = viewLifecycleOwner.lifecycleScope, - ), - AddressStructureMiddleware( - environment = environment, - scope = viewLifecycleOwner.lifecycleScope, - ), - ), - ) + it.dispatch(EnvironmentRehydrated(environment)) } FirefoxTheme { - EditAddressScreen(store.value) + EditAddressScreen(store) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressManagementFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/AddressManagementFragment.kt @@ -16,10 +16,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.observeAsComposableState -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.telemetry.glean.private.NoExtras import org.mozilla.fenix.GleanMetrics.Addresses import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.settings.address.controller.DefaultAddressManagementController @@ -44,9 +44,9 @@ class AddressManagementFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle?, ): View { - store = fragmentStore(AutofillFragmentState()) { - AutofillFragmentStore(it) - }.value + store = StoreProvider.get(this) { + AutofillFragmentStore(AutofillFragmentState()) + } interactor = DefaultAddressManagementInteractor( controller = DefaultAddressManagementController( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressAction.kt @@ -83,6 +83,11 @@ sealed class DeleteDialogAction : AddressAction { } /** + * The Environment has been rehydrated from a configuration change. + */ +data class EnvironmentRehydrated(val environment: AddressEnvironment) : AddressAction + +/** * The Address View appeared. */ data object ViewAppeared : AddressAction diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressMiddleware.kt @@ -22,7 +22,7 @@ import org.mozilla.fenix.GleanMetrics.Addresses * @param ioDispatcher the dispatcher to run background code on. */ class AddressMiddleware( - private val environment: AddressEnvironment, + private var environment: AddressEnvironment? = null, private val scope: CoroutineScope = MainScope(), private val ioDispatcher: CoroutineDispatcher = IO, ) : Middleware<AddressState, AddressAction> { @@ -33,22 +33,23 @@ class AddressMiddleware( ) { next(action) when (action) { + is EnvironmentRehydrated -> environment = action.environment is SaveTapped -> runAndNavigateBack { context.state.guidToUpdate?.let { - environment.updateAddress(it, context.state.address) + environment?.updateAddress(it, context.state.address) Addresses.updated.add() } ?: run { - environment.createAddress(context.state.address) + environment?.createAddress(context.state.address) Addresses.saved.add() } } is DeleteDialogAction.DeleteTapped -> runAndNavigateBack { context.state.guidToUpdate?.also { - environment.deleteAddress(it) + environment?.deleteAddress(it) Addresses.deleted.add() } } - BackTapped, CancelTapped -> environment.navigateBack() + BackTapped, CancelTapped -> environment?.navigateBack() else -> {} // noop } } @@ -57,7 +58,7 @@ class AddressMiddleware( action() scope.launch(Dispatchers.Main) { - environment.navigateBack() + environment?.navigateBack() } } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressReducer.kt @@ -47,7 +47,7 @@ fun addressReducer(state: AddressState, action: AddressAction): AddressState { } } is DeleteDialogAction.DeleteTapped, ViewAppeared, - is BackTapped, CancelTapped, SaveTapped, + is EnvironmentRehydrated, BackTapped, CancelTapped, SaveTapped, -> state } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressStructureMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/address/store/AddressStructureMiddleware.kt @@ -44,7 +44,7 @@ data class UnknownLocalizationKey( * @param ioDispatcher the dispatcher to run background code on. */ class AddressStructureMiddleware( - private val environment: AddressEnvironment, + private var environment: AddressEnvironment? = null, private val scope: CoroutineScope = MainScope(), private val ioDispatcher: CoroutineDispatcher = IO, ) : Middleware<AddressState, AddressAction> { @@ -57,6 +57,7 @@ class AddressStructureMiddleware( next(action) when (action) { + is EnvironmentRehydrated -> environment = action.environment is ViewAppeared -> loadAddressStructure(context.store, true) is FormChange.Country -> if (preReductionCountry != context.store.state.address.country) { loadAddressStructure(context.store, false) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt @@ -17,10 +17,10 @@ import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.support.ktx.android.view.hideKeyboard import mozilla.components.support.locale.LocaleUseCases import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentLocaleSettingsBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.showToolbar @@ -49,9 +49,11 @@ class LocaleSettingsFragment : Fragment(), MenuProvider { val browserStore = requireContext().components.core.store val localeUseCase = LocaleUseCases(browserStore) - localeSettingsStore = fragmentStore(createInitialLocaleSettingsState(requireContext())) { - LocaleSettingsStore(it) - }.value + localeSettingsStore = StoreProvider.get(this) { + LocaleSettingsStore( + createInitialLocaleSettingsState(requireContext()), + ) + } interactor = LocaleSettingsInteractor( controller = DefaultLocaleSettingsController( activity = requireActivity(), diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/AutofillSettingFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/autofill/AutofillSettingFragment.kt @@ -25,12 +25,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.service.fxa.SyncEngine import mozilla.components.service.sync.autofill.AutofillCreditCardsAddressesStorage import mozilla.components.ui.widgets.withCenterAlignedButtons import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.accounts.FenixFxAEntryPoint import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.runIfFragmentIsAttached @@ -79,7 +79,9 @@ class AutofillSettingFragment : BiometricPromptPreferenceFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - store = fragmentStore(AutofillFragmentState()) { AutofillFragmentStore(it) }.value + store = StoreProvider.get(this) { + AutofillFragmentStore(AutofillFragmentState()) + } loadAutofillState() } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/SecureScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/SecureScreen.kt @@ -16,15 +16,15 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import mozilla.components.lib.state.ext.observeAsState -import mozilla.components.lib.state.helpers.StoreProvider.Companion.composableStore +import org.mozilla.fenix.components.lazyStore import org.mozilla.fenix.ext.settings import org.mozilla.fenix.settings.biometric.ui.state.BiometricAuthenticationState import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.AuthenticationFlowAction import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.LifecycleAction import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.UnlockScreenAction -import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenState import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenStore import org.mozilla.fenix.settings.logins.ui.BiometricAuthenticationDialog @@ -177,6 +177,8 @@ private fun Window.lock() = addFlags(WindowManager.LayoutParams.FLAG_SECURE) private fun Window.unlock() = clearFlags(WindowManager.LayoutParams.FLAG_SECURE) @Composable -private fun provideStore() = composableStore(SecureScreenState.Initial) { - SecureScreenStore(it) -}.value +private fun provideStore(): SecureScreenStore { + return LocalViewModelStoreOwner.current?.lazyStore { + SecureScreenStore() + }?.value ?: throw IllegalStateException("Expected LocalViewModelStoreOwner to be provided") +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/creditcards/CreditCardsManagementFragment.kt @@ -13,9 +13,9 @@ import androidx.navigation.fragment.findNavController import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import org.mozilla.fenix.R import org.mozilla.fenix.SecureFragment +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.ComponentCreditCardsBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.redirectToReAuth @@ -44,9 +44,9 @@ class CreditCardsManagementFragment : SecureFragment() { ): View? { val view = inflater.inflate(CreditCardsManagementView.LAYOUT_ID, container, false) - store = fragmentStore(AutofillFragmentState()) { - AutofillFragmentStore(it) - }.value + store = StoreProvider.get(this) { + AutofillFragmentStore(AutofillFragmentState()) + } interactor = DefaultCreditCardsManagementInteractor( controller = DefaultCreditCardsManagementController( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/doh/DohSettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/doh/DohSettingsFragment.kt @@ -12,9 +12,9 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.navigation.NavHostController import androidx.navigation.fragment.findNavController -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentDohSettingsBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings @@ -43,27 +43,40 @@ internal class DohSettingsFragment : Fragment() { binding.composeView.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - val buildStore = { composeNavController: NavHostController -> - val homeActivity = (requireActivity() as HomeActivity) - val navController = findNavController() - val settingsProvider = DefaultDohSettingsProvider( - engine = requireContext().components.core.engine, - settings = requireContext().settings(), - ) - - val store by fragmentStore(DohSettingsState()) { + val buildStore = { navController: NavHostController -> + val store = StoreProvider.get(this@DohSettingsFragment) { + val lifecycleHolder = LifecycleHolder( + context = requireContext(), + navController = this@DohSettingsFragment.findNavController(), + composeNavController = navController, + settingsProvider = DefaultDohSettingsProvider( + engine = requireContext().components.core.engine, + settings = requireContext().settings(), + ), + homeActivity = (requireActivity() as HomeActivity), + ) DohSettingsStore( middleware = listOf( DohSettingsMiddleware( - getSettingsProvider = { settingsProvider }, - getNavController = { composeNavController }, - getHomeActivity = { homeActivity }, - exitDohSettings = { navController.popBackStack() }, + getSettingsProvider = { lifecycleHolder.settingsProvider }, + getNavController = { lifecycleHolder.composeNavController }, + getHomeActivity = { lifecycleHolder.homeActivity }, + exitDohSettings = { lifecycleHolder.navController.popBackStack() }, ), ), + lifecycleHolder = lifecycleHolder, ) } - + store.lifecycleHolder?.apply { + this.context = requireContext() + this.navController = this@DohSettingsFragment.findNavController() + this.composeNavController = navController + this.settingsProvider = DefaultDohSettingsProvider( + engine = requireContext().components.core.engine, + settings = requireContext().settings(), + ) + this.homeActivity = (requireActivity() as HomeActivity) + } store } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/doh/DohSettingsStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/doh/DohSettingsStore.kt @@ -4,9 +4,30 @@ package org.mozilla.fenix.settings.doh +import androidx.navigation.NavController import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.Reducer import mozilla.components.lib.state.Store +import org.mozilla.fenix.HomeActivity + +/** + * A helper class to be able to change the reference to objects that get replaced when the activity + * gets recreated. + * + * @property context An Android [Context]. + * @property navController A [NavController] for interacting with the androidx navigation library. + * @property composeNavController A [NavController] for navigating within the local Composable nav graph. + * @property settingsProvider A [DefaultDohSettingsProvider] for connecting DoH modes/providers via GeckoView API. + * @property homeActivity The [HomeActivity] that provides a reference to the browsing mode and allows + * for opening a URL in the browser. + */ +internal class LifecycleHolder( + var context: android.content.Context, + var navController: NavController, + var composeNavController: NavController, + var settingsProvider: DefaultDohSettingsProvider, + var homeActivity: HomeActivity, +) /** * A Store for handling [DohSettingsState] and dispatching [DohSettingsAction]. @@ -14,11 +35,13 @@ import mozilla.components.lib.state.Store * @param initialState The initial state for the Store. * @param reducer Reducer to handle state updates based on dispatched actions. * @param middleware Middleware to handle side-effects in response to dispatched actions. + * @property lifecycleHolder a hack to box the references to objects that get recreated with the activity. */ internal class DohSettingsStore( initialState: DohSettingsState = DohSettingsState(), reducer: Reducer<DohSettingsState, DohSettingsAction> = ::dohSettingsReducer, middleware: List<Middleware<DohSettingsState, DohSettingsAction>> = listOf(), + var lifecycleHolder: LifecycleHolder? = null, ) : Store<DohSettingsState, DohSettingsAction>( initialState = initialState, reducer = reducer, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt @@ -24,7 +24,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.navBackStackStore import mozilla.components.support.ktx.android.view.hideKeyboard import mozilla.components.support.ktx.android.view.showKeyboard import mozilla.components.support.ktx.util.URLStringUtils @@ -32,6 +31,7 @@ import org.mozilla.fenix.GleanMetrics.Logins import org.mozilla.fenix.R import org.mozilla.fenix.biometricauthentication.AuthenticationStatus import org.mozilla.fenix.biometricauthentication.BiometricAuthenticationManager +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentAddLoginBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.registerForActivityResult @@ -83,10 +83,12 @@ class AddLoginFragment : Fragment(R.layout.fragment_add_login), MenuProvider { _binding = FragmentAddLoginBinding.bind(view) - loginsFragmentStore = findNavController().getBackStackEntry(R.id.savedLogins) - .navBackStackStore(createInitialLoginsListState(requireContext().settings())) { - LoginsFragmentStore(it) - }.value + loginsFragmentStore = + StoreProvider.get(findNavController().getBackStackEntry(R.id.savedLogins)) { + LoginsFragmentStore( + createInitialLoginsListState(requireContext().settings()), + ) + } interactor = AddLoginInteractor( SavedLoginsStorageController( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt @@ -25,13 +25,13 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.google.android.material.textfield.TextInputLayout import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.navBackStackStore import mozilla.components.support.ktx.android.view.hideKeyboard import mozilla.telemetry.glean.private.NoExtras import org.mozilla.fenix.GleanMetrics.Logins import org.mozilla.fenix.R import org.mozilla.fenix.biometricauthentication.AuthenticationStatus import org.mozilla.fenix.biometricauthentication.BiometricAuthenticationManager +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentEditLoginBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.registerForActivityResult @@ -53,6 +53,7 @@ import org.mozilla.fenix.settings.logins.togglePasswordReveal class EditLoginFragment : Fragment(R.layout.fragment_edit_login), MenuProvider { private val args by navArgs<EditLoginFragmentArgs>() + private lateinit var loginsFragmentStore: LoginsFragmentStore private lateinit var interactor: EditLoginInteractor private lateinit var oldLogin: SavedLogin @@ -88,9 +89,11 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login), MenuProvider { oldLogin = args.savedLoginItem - val loginsFragmentStore by findNavController().getBackStackEntry(R.id.savedLogins) - .navBackStackStore(createInitialLoginsListState(requireContext().settings())) { - LoginsFragmentStore(it) + loginsFragmentStore = + StoreProvider.get(findNavController().getBackStackEntry(R.id.savedLogins)) { + LoginsFragmentStore( + createInitialLoginsListState(requireContext().settings()), + ) } interactor = EditLoginInteractor( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt @@ -29,7 +29,6 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.google.android.material.dialog.MaterialAlertDialogBuilder import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.navBackStackStore import mozilla.components.ui.widgets.withCenterAlignedButtons import mozilla.telemetry.glean.private.NoExtras import org.mozilla.fenix.BrowserDirection @@ -39,6 +38,7 @@ import org.mozilla.fenix.R import org.mozilla.fenix.SecureFragment import org.mozilla.fenix.biometricauthentication.AuthenticationStatus import org.mozilla.fenix.biometricauthentication.BiometricAuthenticationManager +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.compose.snackbar.Snackbar import org.mozilla.fenix.compose.snackbar.SnackbarState import org.mozilla.fenix.databinding.FragmentLoginDetailBinding @@ -65,6 +65,7 @@ class LoginDetailFragment : SecureFragment(R.layout.fragment_login_detail), Menu private val args by navArgs<LoginDetailFragmentArgs>() private var login: SavedLogin? = null + private lateinit var savedLoginsStore: LoginsFragmentStore private lateinit var loginDetailsBindingDelegate: LoginDetailsBindingDelegate private lateinit var interactor: LoginDetailInteractor private var menu: Menu? = null @@ -90,6 +91,12 @@ class LoginDetailFragment : SecureFragment(R.layout.fragment_login_detail), Menu setSecureContentVisibility(true) } + savedLoginsStore = + StoreProvider.get(findNavController().getBackStackEntry(R.id.savedLogins)) { + LoginsFragmentStore( + createInitialLoginsListState(requireContext().settings()), + ) + } loginDetailsBindingDelegate = LoginDetailsBindingDelegate(binding) return view @@ -99,11 +106,6 @@ class LoginDetailFragment : SecureFragment(R.layout.fragment_login_detail), Menu super.onViewCreated(view, savedInstanceState) requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - val savedLoginsStore by findNavController().getBackStackEntry(R.id.savedLogins) - .navBackStackStore(createInitialLoginsListState(requireContext().settings())) { - LoginsFragmentStore(it) - } - interactor = LoginDetailInteractor( SavedLoginsStorageController( passwordsStorage = requireContext().components.core.passwordsStorage, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt @@ -36,8 +36,6 @@ import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.menu.MenuController import mozilla.components.concept.menu.Orientation import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore -import mozilla.components.lib.state.helpers.StoreProvider.Companion.navBackStackStore import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.Config import org.mozilla.fenix.HomeActivity @@ -46,12 +44,14 @@ import org.mozilla.fenix.SecureFragment import org.mozilla.fenix.biometricauthentication.AuthenticationStatus import org.mozilla.fenix.biometricauthentication.BiometricAuthenticationManager import org.mozilla.fenix.components.LogMiddleware +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentSavedLoginsBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.registerForActivityResult import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.lifecycle.LifecycleHolder import org.mozilla.fenix.settings.biometric.DefaultBiometricUtils import org.mozilla.fenix.settings.logins.LoginsAction import org.mozilla.fenix.settings.logins.LoginsFragmentStore @@ -154,20 +154,22 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { if (requireContext().settings().enableComposeLogins) { return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - val buildStore = { composeNavController: NavHostController -> - val homeActivity = (requireActivity() as HomeActivity) - val navController = findNavController() - - val store by fragmentStore( - LoginsState.default.copy( - sortOrder = LoginsSortOrder.fromString( - value = requireContext().settings().loginsListSortOrder, - default = LoginsSortOrder.Alphabetical, - ), - ), - ) { + val buildStore = { navController: NavHostController -> + val store = StoreProvider.get(this@SavedLoginsFragment) { + val lifecycleHolder = LifecycleHolder( + context = requireContext(), + navController = this@SavedLoginsFragment.findNavController(), + composeNavController = navController, + homeActivity = (requireActivity() as HomeActivity), + ) + LoginsStore( - initialState = it, + initialState = LoginsState.default.copy( + sortOrder = LoginsSortOrder.fromString( + value = requireContext().settings().loginsListSortOrder, + default = LoginsSortOrder.Alphabetical, + ), + ), middleware = listOf( LogMiddleware( tag = "LoginsStore", @@ -175,15 +177,15 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { ), LoginsMiddleware( loginsStorage = requireContext().components.core.passwordsStorage, - getNavController = { composeNavController }, - exitLogins = { navController.popBackStack() }, + getNavController = { lifecycleHolder.composeNavController }, + exitLogins = { lifecycleHolder.navController.popBackStack() }, persistLoginsSortOrder = { DefaultSavedLoginsStorage( - context.settings(), + lifecycleHolder.context.settings(), ).savedLoginsSortOrder = it }, openTab = { url, openInNewTab -> - homeActivity.openToBrowserAndLoad( + lifecycleHolder.homeActivity.openToBrowserAndLoad( searchTermOrURL = url, newTab = openInNewTab, from = BrowserDirection.FromSavedLoginsFragment, @@ -192,12 +194,20 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { ), ) }, - clipboardManager = homeActivity.getSystemService(), + clipboardManager = lifecycleHolder.homeActivity.getSystemService(), ), ), + lifecycleHolder = lifecycleHolder, ) } + store.lifecycleHolder?.apply { + this.navController = this@SavedLoginsFragment.findNavController() + this.composeNavController = navController + this.homeActivity = (requireActivity() as HomeActivity) + this.context = requireContext() + } + store } setContent { @@ -216,10 +226,12 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { _binding = FragmentSavedLoginsBinding.bind(view) - savedLoginsStore = findNavController().getBackStackEntry(R.id.savedLogins) - .navBackStackStore(createInitialLoginsListState(requireContext().settings())) { - LoginsFragmentStore(it) - }.value + savedLoginsStore = + StoreProvider.get(findNavController().getBackStackEntry(R.id.savedLogins)) { + LoginsFragmentStore( + createInitialLoginsListState(requireContext().settings()), + ) + } loginsListController = LoginsListController( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsStore.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix.settings.logins.ui import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.Reducer import mozilla.components.lib.state.Store +import org.mozilla.fenix.lifecycle.LifecycleHolder /** * A Store for handling [LoginsState] and dispatching [LoginsAction]. @@ -14,11 +15,13 @@ import mozilla.components.lib.state.Store * @param initialState The initial state for the Store. * @param reducer Reducer to handle state updates based on dispatched actions. * @param middleware Middleware to handle side-effects in response to dispatched actions. + * @property lifecycleHolder a hack to box the references to objects that get recreated with the activity. */ internal class LoginsStore( initialState: LoginsState = LoginsState.default, reducer: Reducer<LoginsState, LoginsAction> = ::loginsReducer, middleware: List<Middleware<LoginsState, LoginsAction>> = listOf(), + val lifecycleHolder: LifecycleHolder? = null, ) : Store<LoginsState, LoginsAction>( initialState = initialState, reducer = reducer, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/CookieBannerPanelDialogFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/quicksettings/protections/cookiebanners/CookieBannerPanelDialogFragment.kt @@ -17,9 +17,9 @@ import kotlinx.coroutines.plus import mozilla.components.browser.state.selector.findTabOrCustomTab import mozilla.components.browser.state.state.SessionState import mozilla.components.lib.state.ext.consumeFrom -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import org.mozilla.fenix.R import org.mozilla.fenix.android.FenixDialogFragment +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentCookieBannerHandlingDetailsDialogBinding import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.trackingprotection.ProtectionsState @@ -52,17 +52,19 @@ class CookieBannerPanelDialogFragment : FenixDialogFragment() { val rootView = inflateRootView(container) val tab = store.state.findTabOrCustomTab(provideCurrentTabId()) - protectionsStore = fragmentStore( - ProtectionsState( - tab = tab, - url = args.url, - isTrackingProtectionEnabled = args.trackingProtectionEnabled, - cookieBannerUIMode = args.cookieBannerUIMode, - listTrackers = listOf(), - mode = ProtectionsState.Mode.Normal, - lastAccessedCategory = "", - ), - ) { ProtectionsStore(it) }.value + protectionsStore = StoreProvider.get(this) { + ProtectionsStore( + ProtectionsState( + tab = tab, + url = args.url, + isTrackingProtectionEnabled = args.trackingProtectionEnabled, + cookieBannerUIMode = args.cookieBannerUIMode, + listTrackers = listOf(), + mode = ProtectionsState.Mode.Normal, + lastAccessedCategory = "", + ), + ) + } val controller = DefaultCookieBannerDetailsController( context = requireContext(), diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchAction.kt @@ -17,6 +17,13 @@ sealed interface SettingsSearchAction : Action { data object Init : SettingsSearchAction /** + * Signals a new valid [SettingsSearchEnvironment] has been set. + * + * @property environment New [SettingsSearchEnvironment]. + */ + data class EnvironmentRehydrated(val environment: SettingsSearchEnvironment) : SettingsSearchAction + + /** * Signals that the current [SettingsSearchEnvironment] has been cleared. */ data object EnvironmentCleared : SettingsSearchAction diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchEnvironment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchEnvironment.kt @@ -0,0 +1,24 @@ +/* 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 org.mozilla.fenix.settings.settingssearch + +import android.content.Context +import androidx.fragment.app.Fragment +import androidx.navigation.NavController + +/** + * Environment for the [SettingsSearchStore]. + * + * @property navController [NavController] used for navigation. + * @property fragment [Fragment] used for lifecycle owner and context for UI operations. + * @property context [Context] used for various system interactions + * @property recentSettingsSearchesRepository [RecentSettingsSearchesRepository] used for storing recent searches. + */ +data class SettingsSearchEnvironment( + val navController: NavController, + val fragment: Fragment, + val context: Context, + val recentSettingsSearchesRepository: RecentSettingsSearchesRepository, +) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchFragment.kt @@ -11,9 +11,9 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.compose.content -import androidx.lifecycle.coroutineScope +import androidx.lifecycle.DefaultLifecycleObserver import androidx.navigation.fragment.findNavController -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.ext.components import org.mozilla.fenix.theme.FirefoxTheme @@ -49,18 +49,34 @@ class SettingsSearchFragment : Fragment() { private fun buildSettingsSearchStore(): SettingsSearchStore { val recentSettingsSearchesRepository = FenixRecentSettingsSearchesRepository(requireContext()) - return fragmentStore(SettingsSearchState.Default(emptyList()) as SettingsSearchState) { + return StoreProvider.get(this) { SettingsSearchStore( initialState = SettingsSearchState.Default(emptyList()), middleware = listOf( SettingsSearchMiddleware( fenixSettingsIndexer = requireContext().components.settingsIndexer, + ), + ), + ) + }.also { + it.dispatch( + SettingsSearchAction.EnvironmentRehydrated( + environment = SettingsSearchEnvironment( + fragment = this, navController = findNavController(), + context = requireContext(), recentSettingsSearchesRepository = recentSettingsSearchesRepository, - scope = viewLifecycleOwner.lifecycle.coroutineScope, ), ), ) - }.value + + viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: androidx.lifecycle.LifecycleOwner) { + it.dispatch(SettingsSearchAction.EnvironmentCleared) + } + }, + ) + } } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddleware.kt @@ -5,7 +5,9 @@ package org.mozilla.fenix.settings.settingssearch import androidx.core.os.bundleOf -import androidx.navigation.NavController +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -16,19 +18,15 @@ import mozilla.components.lib.state.MiddlewareContext /** * [Middleware] for the settings search screen. * - * @param fenixSettingsIndexer [SettingsIndexer] to use for indexing and querying settings. - * @param navController [NavController] used for navigation. - * @param recentSettingsSearchesRepository [RecentSettingsSearchesRepository] used for storing recent searches. - * @param scope [CoroutineScope] used for running long running operations in background. - * @param dispatcher [CoroutineDispatcher] to use for performing background tasks. + * @property fenixSettingsIndexer [SettingsIndexer] to use for indexing and querying settings. + * @property dispatcher [CoroutineDispatcher] to use for performing background tasks. */ class SettingsSearchMiddleware( - private val fenixSettingsIndexer: SettingsIndexer, - private val navController: NavController, - private val recentSettingsSearchesRepository: RecentSettingsSearchesRepository, - private val scope: CoroutineScope, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + val fenixSettingsIndexer: SettingsIndexer, + val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : Middleware<SettingsSearchState, SettingsSearchAction> { + internal var environment: SettingsSearchEnvironment? = null + override fun invoke( context: MiddlewareContext<SettingsSearchState, SettingsSearchAction>, next: (SettingsSearchAction) -> Unit, @@ -39,7 +37,17 @@ class SettingsSearchMiddleware( is SettingsSearchAction.Init -> { next(action) fenixSettingsIndexer.indexAllSettings() - scope.launch { observeRecentSearches(store) } + } + is SettingsSearchAction.EnvironmentRehydrated -> { + next(action) + environment = action.environment + environment?.fragment?.viewLifecycleOwner?.lifecycleScope?.launch { + observeRecentSearches(store) + } + } + is SettingsSearchAction.EnvironmentCleared -> { + next(action) + environment = null } is SettingsSearchAction.SearchQueryUpdated -> { next(action) @@ -65,17 +73,17 @@ class SettingsSearchMiddleware( ) val fragmentId = searchItem.preferenceFileInformation.fragmentId CoroutineScope(dispatcher).launch { - recentSettingsSearchesRepository.addRecentSearchItem(searchItem) + environment?.recentSettingsSearchesRepository?.addRecentSearchItem(searchItem) } CoroutineScope(Dispatchers.Main).launch { - navController.navigate(fragmentId, bundle) + environment?.navController?.navigate(fragmentId, bundle) } next(action) } is SettingsSearchAction.ClearRecentSearchesClicked -> { next(action) CoroutineScope(Dispatchers.IO).launch { - recentSettingsSearchesRepository.clearRecentSearches() + environment?.recentSettingsSearchesRepository?.clearRecentSearches() } } else -> { @@ -91,9 +99,13 @@ class SettingsSearchMiddleware( * @param store The [SettingsSearchStore] to dispatch the updates to. */ private fun observeRecentSearches(store: SettingsSearchStore) { - scope.launch { - recentSettingsSearchesRepository.recentSearches.collect { recents -> - store.dispatch(SettingsSearchAction.RecentSearchesUpdated(recents)) + environment?.fragment?.viewLifecycleOwner?.run { + lifecycleScope.launch { + repeatOnLifecycle(RESUMED) { + environment?.recentSettingsSearchesRepository?.recentSearches?.collect { recents -> + store.dispatch(SettingsSearchAction.RecentSearchesUpdated(recents)) + } + } } } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchStore.kt @@ -71,6 +71,7 @@ private fun reduce(state: SettingsSearchState, action: SettingsSearchAction): Se is SettingsSearchAction.Init, is SettingsSearchAction.ResultItemClicked, is SettingsSearchAction.EnvironmentCleared, + is SettingsSearchAction.EnvironmentRehydrated, -> state } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt @@ -40,7 +40,6 @@ import mozilla.components.feature.accounts.push.CloseTabsUseCases import mozilla.components.feature.downloads.ui.DownloadCancelDialogFragment import mozilla.components.feature.tabs.tabstray.TabsFeature import mozilla.components.lib.state.ext.observeAsState -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.util.AndroidDisplayUnitConverter import mozilla.telemetry.glean.private.NoExtras @@ -50,6 +49,7 @@ import org.mozilla.fenix.GleanMetrics.TabsTray import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.compose.core.Action import org.mozilla.fenix.compose.snackbar.Snackbar import org.mozilla.fenix.compose.snackbar.SnackbarState @@ -159,24 +159,22 @@ class TabsTrayFragment : AppCompatDialogFragment() { }, ) - tabsTrayStore = fragmentStore( - TabsTrayState( - selectedPage = initialPage, - mode = initialMode, - inactiveTabs = inactiveTabs, - inactiveTabsExpanded = initialInactiveExpanded, - normalTabs = normalTabs, - privateTabs = requireComponents.core.store.state.privateTabs, - selectedTabId = requireComponents.core.store.state.selectedTabId, - ), - ) { + tabsTrayStore = StoreProvider.get(this) { TabsTrayStore( - initialState = it, + initialState = TabsTrayState( + selectedPage = initialPage, + mode = initialMode, + inactiveTabs = inactiveTabs, + inactiveTabsExpanded = initialInactiveExpanded, + normalTabs = normalTabs, + privateTabs = requireComponents.core.store.state.privateTabs, + selectedTabId = requireComponents.core.store.state.selectedTabId, + ), middlewares = listOf( TabsTrayTelemetryMiddleware(requireComponents.nimbus.events), ), ) - }.value + } navigationInteractor = DefaultNavigationInteractor( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagementFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagementFragment.kt @@ -43,7 +43,6 @@ import mozilla.components.feature.accounts.push.CloseTabsUseCases import mozilla.components.feature.downloads.ui.DownloadCancelDialogFragment import mozilla.components.feature.tabs.tabstray.TabsFeature import mozilla.components.lib.state.ext.observeAsState -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.support.ktx.android.view.setSystemBarsBackground import mozilla.telemetry.glean.private.NoExtras @@ -52,6 +51,7 @@ import org.mozilla.fenix.GleanMetrics.PrivateBrowsingLocked import org.mozilla.fenix.GleanMetrics.TabsTray import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.ext.actualInactiveTabs import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getBottomToolbarHeight @@ -146,24 +146,22 @@ class TabManagementFragment : DialogFragment() { }, ) - tabsTrayStore = fragmentStore( - TabsTrayState( - selectedPage = initialPage, - mode = initialMode, - inactiveTabs = inactiveTabs, - inactiveTabsExpanded = initialInactiveExpanded, - normalTabs = normalTabs, - privateTabs = requireComponents.core.store.state.privateTabs, - selectedTabId = requireComponents.core.store.state.selectedTabId, - ), - ) { + tabsTrayStore = StoreProvider.get(this) { TabsTrayStore( - initialState = it, + initialState = TabsTrayState( + selectedPage = initialPage, + mode = initialMode, + inactiveTabs = inactiveTabs, + inactiveTabsExpanded = initialInactiveExpanded, + normalTabs = normalTabs, + privateTabs = requireComponents.core.store.state.privateTabs, + selectedTabId = requireComponents.core.store.state.selectedTabId, + ), middlewares = listOf( TabsTrayTelemetryMiddleware(requireComponents.nimbus.events), ), ) - }.value + } tabManagerController = DefaultTabManagerController( accountManager = requireComponents.backgroundServices.accountManager, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/store/TermsOfUsePromptStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/store/TermsOfUsePromptStore.kt @@ -79,10 +79,9 @@ sealed interface TermsOfUsePromptAction : Action { * A [Store] that holds the [TermsOfUsePromptState]. */ class TermsOfUsePromptStore( - initialState: TermsOfUsePromptState = TermsOfUsePromptState, middleware: List<Middleware<TermsOfUsePromptState, TermsOfUsePromptAction>>, ) : Store<TermsOfUsePromptState, TermsOfUsePromptAction>( - initialState = initialState, + initialState = TermsOfUsePromptState, reducer = { _, _ -> TermsOfUsePromptState }, middleware = middleware, ) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/ui/TermsOfUseBottomSheetFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/ui/TermsOfUseBottomSheetFragment.kt @@ -13,13 +13,12 @@ import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.navigation.fragment.navArgs import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore +import org.mozilla.fenix.components.lazyStore import org.mozilla.fenix.ext.settings import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.termsofuse.store.DefaultTermsOfUsePromptRepository import org.mozilla.fenix.termsofuse.store.TermsOfUsePromptAction import org.mozilla.fenix.termsofuse.store.TermsOfUsePromptPreferencesMiddleware -import org.mozilla.fenix.termsofuse.store.TermsOfUsePromptState import org.mozilla.fenix.termsofuse.store.TermsOfUsePromptStore import org.mozilla.fenix.termsofuse.store.TermsOfUsePromptTelemetryMiddleware import org.mozilla.fenix.theme.FirefoxTheme @@ -32,9 +31,8 @@ class TermsOfUseBottomSheetFragment : BottomSheetDialogFragment() { private val args by navArgs<TermsOfUseBottomSheetFragmentArgs>() - private val termsOfUsePromptStore by fragmentStore(TermsOfUsePromptState) { + private val termsOfUsePromptStore by lazyStore { TermsOfUsePromptStore( - initialState = it, middleware = listOf( TermsOfUsePromptPreferencesMiddleware( repository = DefaultTermsOfUsePromptRepository( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/trackingprotection/TrackingProtectionPanelDialogFragment.kt @@ -36,7 +36,6 @@ import mozilla.components.browser.state.store.BrowserStore import mozilla.components.feature.session.TrackingProtectionUseCases import mozilla.components.lib.state.ext.consumeFlow import mozilla.components.lib.state.ext.observe -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged @@ -45,6 +44,7 @@ import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.GleanMetrics.TrackingProtection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentTrackingProtectionBinding import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.requireComponents @@ -89,17 +89,19 @@ class TrackingProtectionPanelDialogFragment : AppCompatDialogFragment(), UserInt val view = inflateRootView(container) val tab = store.state.findTabOrCustomTab(provideCurrentTabId()) - protectionsStore = fragmentStore( - ProtectionsState( - tab = tab, - url = args.url, - isTrackingProtectionEnabled = args.trackingProtectionEnabled, - cookieBannerUIMode = args.cookieBannerUIMode, - listTrackers = listOf(), - mode = ProtectionsState.Mode.Normal, - lastAccessedCategory = "", - ), - ) { ProtectionsStore(it) }.value + protectionsStore = StoreProvider.get(this) { + ProtectionsStore( + ProtectionsState( + tab = tab, + url = args.url, + isTrackingProtectionEnabled = args.trackingProtectionEnabled, + cookieBannerUIMode = args.cookieBannerUIMode, + listTrackers = listOf(), + mode = ProtectionsState.Mode.Normal, + lastAccessedCategory = "", + ), + ) + } trackingProtectionInteractor = TrackingProtectionPanelInteractor( context = requireContext(), fragment = this, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/webcompat/ui/WebCompatReporterFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/webcompat/ui/WebCompatReporterFragment.kt @@ -13,15 +13,13 @@ import androidx.fragment.compose.content import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.lifecycle.viewModelScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import kotlinx.coroutines.launch -import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore -import mozilla.components.lib.state.helpers.StoreProvider.Companion.storeProvider import mozilla.components.support.ktx.android.view.hideKeyboard import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.components.lazyStore import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.theme.FirefoxTheme import org.mozilla.fenix.webcompat.WEB_COMPAT_REPORTER_SUMO_URL @@ -38,27 +36,19 @@ class WebCompatReporterFragment : Fragment() { private val args by navArgs<WebCompatReporterFragmentArgs>() - private lateinit var webCompatReporterStore: WebCompatReporterStore - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - webCompatReporterStore = fragmentStore( - WebCompatReporterState( + private val webCompatReporterStore by lazyStore { viewModelScope -> + WebCompatReporterStore( + initialState = WebCompatReporterState( tabUrl = args.tabUrl, enteredUrl = args.tabUrl, ), - ) { - WebCompatReporterStore( - initialState = it, - middleware = WebCompatReporterMiddlewareProvider.provideMiddleware( - browserStore = requireComponents.core.store, - appStore = requireComponents.appStore, - scope = storeProvider.viewModelScope, - nimbusApi = requireComponents.nimbus.sdk, - ), - ) - }.value + middleware = WebCompatReporterMiddlewareProvider.provideMiddleware( + browserStore = requireComponents.core.store, + appStore = requireComponents.appStore, + scope = viewModelScope, + nimbusApi = requireComponents.nimbus.sdk, + ), + ) } override fun onCreateView( diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/store/BrowserScreenMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/store/BrowserScreenMiddlewareTest.kt @@ -4,7 +4,10 @@ package org.mozilla.fenix.browser.store +import android.content.Context import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.every import io.mockk.mockk @@ -15,19 +18,27 @@ import mozilla.components.lib.crash.CrashReporter import mozilla.components.lib.state.Middleware import mozilla.components.support.test.middleware.CaptureActionsMiddleware import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith import org.mozilla.fenix.browser.store.BrowserScreenAction.CancelPrivateDownloadsOnPrivateTabsClosedAccepted +import org.mozilla.fenix.browser.store.BrowserScreenAction.EnvironmentCleared +import org.mozilla.fenix.browser.store.BrowserScreenAction.EnvironmentRehydrated import org.mozilla.fenix.browser.store.BrowserScreenMiddleware.Companion.CANCEL_PRIVATE_DOWNLOADS_DIALOG_FRAGMENT_TAG +import org.mozilla.fenix.browser.store.BrowserScreenStore.Environment +import org.mozilla.fenix.helpers.lifecycle.TestLifecycleOwner @RunWith(AndroidJUnit4::class) class BrowserScreenMiddlewareTest { + + private val lifecycleOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) private val fragmentManager: FragmentManager = mockk(relaxed = true) private val crashReporter: CrashReporter = mockk(relaxed = true) @Test fun `WHEN the last private tab is closing THEN record a breadcrumb and show a warning dialog`() { - val middleware = spyk(BrowserScreenMiddleware(testContext, crashReporter, fragmentManager)) + val middleware = spyk(BrowserScreenMiddleware(crashReporter)) val store = buildStore(listOf(middleware)) val warningDialog: DownloadCancelDialogFragment = mockk(relaxed = true) @@ -49,7 +60,7 @@ class BrowserScreenMiddlewareTest { @Test fun `GIVEN a warning dialog for closing private tabs is shown WHEN the warning is accepted THEN inform about this`() { - val middleware = spyk(BrowserScreenMiddleware(testContext, crashReporter, fragmentManager)) + val middleware = spyk(BrowserScreenMiddleware(crashReporter)) val captureActionsMiddleware = CaptureActionsMiddleware<BrowserScreenState, BrowserScreenAction>() val store = buildStore(listOf(middleware, captureActionsMiddleware)) val warningDialog: DownloadCancelDialogFragment = mockk(relaxed = true) @@ -63,9 +74,28 @@ class BrowserScreenMiddlewareTest { captureActionsMiddleware.assertLastAction(CancelPrivateDownloadsOnPrivateTabsClosedAccepted::class) {} } + @Test + fun `GIVEN an environment was already set WHEN it is cleared THEN reset it to null`() { + val middleware = BrowserScreenMiddleware(crashReporter) + val store = buildStore(listOf(middleware)) + + assertNotNull(middleware.environment) + + store.dispatch(EnvironmentCleared) + + assertNull(middleware.environment) + } + private fun buildStore( middlewares: List<Middleware<BrowserScreenState, BrowserScreenAction>> = emptyList(), + context: Context = testContext, + viewLifecycleOwner: LifecycleOwner = lifecycleOwner, + fragmentManager: FragmentManager = this.fragmentManager, ) = BrowserScreenStore( middleware = middlewares, - ) + ).also { + it.dispatch( + EnvironmentRehydrated(Environment(context, viewLifecycleOwner, fragmentManager)), + ) + } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/store/BrowserScreenStoreKtTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/store/BrowserScreenStoreKtTest.kt @@ -4,10 +4,14 @@ package org.mozilla.fenix.browser.store +import android.content.Context import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import io.mockk.mockk import mozilla.components.concept.engine.translate.Language import mozilla.components.lib.state.Middleware +import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.rule.MainCoroutineRule import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -19,14 +23,18 @@ import org.mozilla.fenix.browser.PageTranslationStatus import org.mozilla.fenix.browser.ReaderModeStatus import org.mozilla.fenix.browser.store.BrowserScreenAction.CancelPrivateDownloadsOnPrivateTabsClosedAccepted import org.mozilla.fenix.browser.store.BrowserScreenAction.ClosingLastPrivateTab +import org.mozilla.fenix.browser.store.BrowserScreenAction.EnvironmentRehydrated import org.mozilla.fenix.browser.store.BrowserScreenAction.PageTranslationStatusUpdated import org.mozilla.fenix.browser.store.BrowserScreenAction.ReaderModeStatusUpdated +import org.mozilla.fenix.browser.store.BrowserScreenStore.Environment +import org.mozilla.fenix.helpers.lifecycle.TestLifecycleOwner import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class BrowserScreenStoreKtTest { @get:Rule val mainCoroutineRule = MainCoroutineRule() + private val lifecycleOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) private val fragmentManager: FragmentManager = mockk() @Test @@ -78,8 +86,15 @@ class BrowserScreenStoreKtTest { private fun buildStore( initialState: BrowserScreenState = BrowserScreenState(), middlewares: List<Middleware<BrowserScreenState, BrowserScreenAction>> = emptyList(), + context: Context = testContext, + viewLifecycleOwner: LifecycleOwner = lifecycleOwner, + fragmentManager: FragmentManager = this.fragmentManager, ) = BrowserScreenStore( initialState = initialState, middleware = middlewares, - ) + ).also { + it.dispatch( + EnvironmentRehydrated(Environment(context, viewLifecycleOwner, fragmentManager)), + ) + } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/store/BrowserScreenStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/store/BrowserScreenStoreTest.kt @@ -6,6 +6,8 @@ package org.mozilla.fenix.browser.store import androidx.lifecycle.Lifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.mockk +import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.rule.MainCoroutineRule import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -14,6 +16,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.mozilla.fenix.browser.store.BrowserScreenAction.CancelPrivateDownloadsOnPrivateTabsClosedAccepted import org.mozilla.fenix.browser.store.BrowserScreenAction.ClosingLastPrivateTab +import org.mozilla.fenix.browser.store.BrowserScreenAction.EnvironmentRehydrated +import org.mozilla.fenix.browser.store.BrowserScreenStore.Environment import org.mozilla.fenix.helpers.lifecycle.TestLifecycleOwner @RunWith(AndroidJUnit4::class) @@ -46,5 +50,7 @@ class BrowserScreenStoreTest { initialState = BrowserScreenState( cancelPrivateDownloadsAccepted = cancelPrivateDownloadsAccepted, ), - ) + ).also { + it.dispatch(EnvironmentRehydrated(Environment(testContext, lifecycleOwner, mockk()))) + } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/StoreProviderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/StoreProviderTest.kt @@ -0,0 +1,124 @@ +/* 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 org.mozilla.fenix.components + +import androidx.fragment.app.Fragment +import kotlinx.coroutines.CoroutineScope +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import mozilla.components.support.test.robolectric.createAddedTestFragment +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StoreProviderTest { + + private class BasicState : State + + private val basicStore = Store(BasicState(), { state, _: Action -> state }) + + @Test + fun `factory returns store provider`() { + var createCalled = false + val factory = StoreProviderFactory { + createCalled = true + basicStore + } + + assertFalse(createCalled) + + assertEquals(basicStore, factory.create(StoreProvider::class.java).store) + + assertTrue(createCalled) + } + + @Test + fun `get returns store`() { + val fragment = createAddedTestFragment { Fragment() } + + val store = StoreProvider.get(fragment) { basicStore } + assertEquals(basicStore, store) + } + + @Test + fun `get only calls createStore if needed`() { + val fragment = createAddedTestFragment { Fragment() } + + var createCalled = false + val createStore: (CoroutineScope) -> Store<BasicState, Action> = { + createCalled = true + basicStore + } + + StoreProvider.get(fragment, createStore) + assertTrue(createCalled) + + createCalled = false + StoreProvider.get(fragment, createStore) + assertFalse(createCalled) + } + + @Test + fun `WHEN store is created lazily THEN createStore is only invoked on access`() { + val fragment = createAddedTestFragment { Fragment() } + + var createCalled = false + val createStore: (CoroutineScope) -> Store<BasicState, Action> = { + createCalled = true + basicStore + } + + val store by fragment.lazyStore(createStore) + // The store is not created yet. + assertFalse(createCalled) + + assertEquals(basicStore, store) + // The store is only created when it's used. + assertTrue(createCalled) + + // The store is not created again. + createCalled = false + fragment.lazyStore(createStore).value + assertFalse(createCalled) + } + + @Test + fun `GIVEN different stores are persisted WHEN requesting them THEN get their unique instances`() { + val fragment = createAddedTestFragment { Fragment() } + var createACalled = false + val storeAFactory: (CoroutineScope) -> Store<BasicState, Action> = { + createACalled = true + basicStore + } + var createBCalled = false + val storeBFactory: (CoroutineScope) -> StoreB = { + createBCalled = true + StoreB(BasicState()) + } + + val storeA: Store<BasicState, Action> = StoreProvider.get(fragment, storeAFactory) + val storeB: StoreB = StoreProvider.get(fragment, storeBFactory) + assertTrue(createACalled) + assertTrue(createBCalled) + + createACalled = false + createBCalled = false + assertSame(storeA, StoreProvider.get(fragment, storeAFactory)) + assertSame(storeB, StoreProvider.get(fragment, storeBFactory)) + assertFalse(createACalled) + assertFalse(createBCalled) + } + + private class StoreB(initialState: BasicState) : Store<BasicState, Action>( + initialState, + { state, _: Action -> state }, + ) +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddlewareTest.kt @@ -4,6 +4,10 @@ package org.mozilla.fenix.components.toolbar +import android.content.Context +import android.content.res.Configuration +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry @@ -19,9 +23,7 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.spyk import io.mockk.verify -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope import kotlinx.coroutines.test.runTest import mozilla.components.browser.state.action.BrowserAction import mozilla.components.browser.state.action.ContentAction @@ -70,6 +72,8 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.B import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.BrowserToolbarMenuButton.Text.StringResText import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.BrowserToolbarMenuDivider import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.ProgressBarConfig import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.engine.Engine @@ -92,7 +96,6 @@ import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.rule.MainLooperTestRule import mozilla.components.support.utils.ClipboardHandler import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -122,6 +125,7 @@ import org.mozilla.fenix.browser.store.BrowserScreenAction.PageTranslationStatus import org.mozilla.fenix.browser.store.BrowserScreenAction.ReaderModeStatusUpdated import org.mozilla.fenix.browser.store.BrowserScreenState import org.mozilla.fenix.browser.store.BrowserScreenStore +import org.mozilla.fenix.browser.store.BrowserScreenStore.Environment import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.NimbusComponents import org.mozilla.fenix.components.UseCases @@ -160,6 +164,7 @@ import org.mozilla.fenix.components.toolbar.TabCounterInteractions.TabCounterCli import org.mozilla.fenix.components.toolbar.TabCounterInteractions.TabCounterLongClicked import org.mozilla.fenix.components.usecases.FenixBrowserUseCases import org.mozilla.fenix.ext.directionsEq +import org.mozilla.fenix.ext.settings import org.mozilla.fenix.helpers.FenixGleanTestRule import org.mozilla.fenix.settings.ShortcutType import org.mozilla.fenix.tabstray.Page @@ -185,6 +190,7 @@ class BrowserToolbarMiddlewareTest { } private val browserStore = BrowserStore() private val clipboard: ClipboardHandler = mockk(relaxed = true) + private val lifecycleOwner = FakeLifecycleOwner(Lifecycle.State.RESUMED) private val navController: NavController = mockk(relaxed = true) private val browsingModeManager = SimpleBrowsingModeManager(Normal) private val browserAnimator: BrowserAnimator = mockk(relaxed = true) @@ -212,11 +218,26 @@ class BrowserToolbarMiddlewareTest { private val publicSuffixList = PublicSuffixList(testContext) private val bookmarksStorage: BookmarksStorage = mockk() private lateinit var appStore: AppStore + private lateinit var configuration: Configuration + private lateinit var fragment: Fragment + private lateinit var mockContext: Context @Before fun setup() { appStore = spyk(AppStore()) coEvery { bookmarksStorage.getBookmarksWithUrl(any()) } returns Result.success(listOf(mockk())) + mockContext = mockk(relaxed = true) { + every { settings() } returns settings + } + fragment = spyk(Fragment()).apply { + every { context } returns mockContext + } + every { fragment.viewLifecycleOwner } returns lifecycleOwner + configuration = Configuration().apply { + screenHeightDp = 700 + screenWidthDp = 400 + } + every { mockContext.resources.configuration } returns configuration } @Test @@ -249,9 +270,12 @@ class BrowserToolbarMiddlewareTest { tabs = listOf(createTab("test.com", private = false)), ), ) - val middleware = buildMiddleware(browserStore = browserStore, browsingModeManager = browsingModeManager) + val middleware = buildMiddleware(browserStore = browserStore) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + browsingModeManager = browsingModeManager, + ) val toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd val tabCounterButton = toolbarBrowserActions[1] as TabCounterAction @@ -269,9 +293,12 @@ class BrowserToolbarMiddlewareTest { ), ), ) - val middleware = buildMiddleware(browserStore = browserStore, browsingModeManager = browsingModeManager) + val middleware = buildMiddleware(browserStore = browserStore) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + browsingModeManager = browsingModeManager, + ) val toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd val tabCounterButton = toolbarBrowserActions[1] as TabCounterAction @@ -303,6 +330,18 @@ class BrowserToolbarMiddlewareTest { } @Test + fun `GIVEN an environment was already set WHEN it is cleared THEN reset it to null`() { + val middleware = buildMiddleware() + val store = buildStore(middleware) + + assertNotNull(middleware.environment) + + store.dispatch(EnvironmentCleared) + + assertNull(middleware.environment) + } + + @Test fun `GIVEN ABOUT_HOME URL WHEN the page origin is modified THEN update the page origin`() = runTest { val tab = createTab("https://mozilla.com/") val browserStore = BrowserStore( @@ -341,19 +380,16 @@ class BrowserToolbarMiddlewareTest { orientation = Portrait, ), ) - var isWideScreen = false - var isTallScreen = false - val middleware = buildMiddleware( - appStore = appStore, - isWideScreen = { isWideScreen }, - isTallScreen = { isTallScreen }, - ) + val middleware = buildMiddleware(appStore = appStore) val toolbarStore = buildStore(middleware) var toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(3, toolbarBrowserActions.size) - isWideScreen = true + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } appStore.dispatch(AppAction.OrientationChange(Landscape)) mainLooperRule.idle() @@ -375,13 +411,7 @@ class BrowserToolbarMiddlewareTest { orientation = Landscape, ), ) - var isWideScreen = false - var isTallScreen = false - val middleware = buildMiddleware( - appStore = appStore, - isWideScreen = { isWideScreen }, - isTallScreen = { isTallScreen }, - ) + val middleware = buildMiddleware(appStore = appStore) val toolbarStore = buildStore(middleware) @@ -394,7 +424,10 @@ class BrowserToolbarMiddlewareTest { assertEqualsTabCounterButton(expectedTabCounterButton(), tabCounterButton) assertEquals(expectedMenuButton(), menuButton) - isWideScreen = true + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } appStore.dispatch(AppAction.OrientationChange(Portrait)) mainLooperRule.idle() @@ -407,9 +440,12 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN in normal browsing WHEN the number of normal opened tabs is modified THEN update the tab counter`() = runTest { val browsingModeManager = SimpleBrowsingModeManager(Normal) val browserStore = BrowserStore() - val middleware = buildMiddleware(browserStore = browserStore, browsingModeManager = browsingModeManager) + val middleware = buildMiddleware(browserStore = browserStore) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + browsingModeManager = browsingModeManager, + ) var toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(3, toolbarBrowserActions.size) @@ -438,9 +474,11 @@ class BrowserToolbarMiddlewareTest { tabs = listOf(initialNormalTab, initialPrivateTab), ), ) - val middleware = buildMiddleware(browserStore = browserStore, browsingModeManager = browsingModeManager) - - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(browserStore = browserStore) + val toolbarStore = buildStore( + middleware = middleware, + browsingModeManager = browsingModeManager, + ) var toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(3, toolbarBrowserActions.size) @@ -466,7 +504,10 @@ class BrowserToolbarMiddlewareTest { ) } answers { browserAnimatorActionCaptor.captured.invoke(true) } val middleware = buildMiddleware() - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + ) val newTabButton = toolbarStore.state.displayState.browserActionsEnd[0] as ActionButtonRes toolbarStore.dispatch(newTabButton.onClick as BrowserToolbarEvent) @@ -484,7 +525,10 @@ class BrowserToolbarMiddlewareTest { } answers { browserAnimatorActionCaptor.captured.invoke(true) } every { settings.enableHomepageAsNewTab } returns true val middleware = buildMiddleware() - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + ) val newTabButton = toolbarStore.state.displayState.browserActionsEnd[0] as ActionButtonRes toolbarStore.dispatch(newTabButton.onClick as BrowserToolbarEvent) @@ -502,7 +546,10 @@ class BrowserToolbarMiddlewareTest { } answers { browserAnimatorActionCaptor.captured.invoke(true) } every { settings.enableHomepageSearchBar } returns true val middleware = buildMiddleware() - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + ) val newTabButton = toolbarStore.state.displayState.browserActionsEnd[0] as ActionButtonRes toolbarStore.dispatch(newTabButton.onClick as BrowserToolbarEvent) @@ -514,7 +561,10 @@ class BrowserToolbarMiddlewareTest { every { navController.currentDestination?.id } returns R.id.browserFragment val middleware = buildMiddleware() - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + ) val menuButton = toolbarStore.state.displayState.browserActionsEnd[2] as ActionButtonRes toolbarStore.dispatch(menuButton.onClick as BrowserToolbarEvent) @@ -532,9 +582,16 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN browsing in normal mode WHEN clicking the tab counter button THEN open the tabs tray in normal mode`() { every { navController.currentDestination?.id } returns R.id.browserFragment + val browsingModeManager = SimpleBrowsingModeManager(Normal) - val middleware = buildMiddleware(browserStore = browserStore, browsingModeManager = browsingModeManager) - val toolbarStore = buildStore(middleware) + val thumbnailsFeature: BrowserThumbnails = mockk(relaxed = true) + val middleware = buildMiddleware(browserStore = browserStore) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, + thumbnailsFeature = thumbnailsFeature, + ) val tabCounterButton = toolbarStore.state.displayState.browserActionsEnd[1] as TabCounterAction toolbarStore.dispatch(tabCounterButton.onClick) @@ -557,12 +614,13 @@ class BrowserToolbarMiddlewareTest { } val browsingModeManager = SimpleBrowsingModeManager(Private) val thumbnailsFeature: BrowserThumbnails = mockk(relaxed = true) - val middleware = buildMiddleware( + val middleware = buildMiddleware(browserStore = browserStore) + val toolbarStore = buildStore( + middleware = middleware, navController = navController, browsingModeManager = browsingModeManager, - thumbnailsFeature = { thumbnailsFeature }, + thumbnailsFeature = thumbnailsFeature, ) - val toolbarStore = buildStore(middleware) val tabCounterButton = toolbarStore.state.displayState.browserActionsEnd[1] as TabCounterAction toolbarStore.dispatch(tabCounterButton.onClick) @@ -586,8 +644,12 @@ class BrowserToolbarMiddlewareTest { fun `WHEN clicking on the first option in the toolbar long click menu THEN open a new normal tab`() { val navController: NavController = mockk(relaxed = true) val browsingModeManager = SimpleBrowsingModeManager(Normal) - val middleware = buildMiddleware(navController = navController, browsingModeManager = browsingModeManager) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(browserStore = browserStore) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, + ) val tabCounterButton = toolbarStore.state.displayState.browserActionsEnd[1] as TabCounterAction assertEqualsTabCounterButton(expectedTabCounterButton(0, false), tabCounterButton) val tabCounterMenuItems = (tabCounterButton.onLongClick as CombinedEventAndMenu).menu.items() @@ -604,6 +666,8 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN no search terms for the current tab WHEN the page origin is clicked THEN start search in the home screen`() { + val navController: NavController = mockk(relaxed = true) + val browsingModeManager = SimpleBrowsingModeManager(Normal) val currentTab = createTab("test.com") val browserStore = BrowserStore( BrowserState( @@ -612,7 +676,11 @@ class BrowserToolbarMiddlewareTest { ), ) val middleware = buildMiddleware(browserStore = browserStore) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, + ) toolbarStore.dispatch(toolbarStore.state.displayState.pageOrigin.onClick as BrowserToolbarAction) @@ -628,6 +696,8 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN the current tab has search terms WHEN the page origin is clicked THEN start search in the browser screen`() { + val navController: NavController = mockk(relaxed = true) + val browsingModeManager = SimpleBrowsingModeManager(Normal) val currentTab = createTab("test.com", searchTerms = "test") val browserStore = BrowserStore( BrowserState( @@ -636,7 +706,11 @@ class BrowserToolbarMiddlewareTest { ), ) val middleware = buildMiddleware(browserStore = browserStore) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, + ) toolbarStore.dispatch(toolbarStore.state.displayState.pageOrigin.onClick as BrowserToolbarAction) @@ -648,7 +722,7 @@ class BrowserToolbarMiddlewareTest { @Test fun `WHEN clicking on the URL THEN record telemetry`() { val middleware = buildMiddleware() - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore(middleware, navController = navController) toolbarStore.dispatch(toolbarStore.state.displayState.pageOrigin.onClick as BrowserToolbarAction) @@ -658,6 +732,8 @@ class BrowserToolbarMiddlewareTest { @Test @Config(sdk = [30]) fun `GIVEN on Android 11 WHEN choosing to copy the current URL to clipboard THEN copy to clipboard and show a snackbar`() { + val navController: NavController = mockk(relaxed = true) + val browsingModeManager = SimpleBrowsingModeManager(Normal) val clipboard = ClipboardHandler(testContext) val currentTab = createTab("test.com") val browserStore = BrowserStore( @@ -667,10 +743,15 @@ class BrowserToolbarMiddlewareTest { ), ) val middleware = buildMiddleware( + appStore = appStore, browserStore = browserStore, clipboard = clipboard, ) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, + ) toolbarStore.dispatch(CopyToClipboardClicked) @@ -691,10 +772,15 @@ class BrowserToolbarMiddlewareTest { ), ) val middleware = buildMiddleware( + appStore = appStore, browserStore = browserStore, clipboard = clipboard, ) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, + ) toolbarStore.dispatch(CopyToClipboardClicked) @@ -715,10 +801,15 @@ class BrowserToolbarMiddlewareTest { ), ) val middleware = buildMiddleware( + appStore = appStore, browserStore = browserStore, clipboard = clipboard, ) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, + ) toolbarStore.dispatch(CopyToClipboardClicked) @@ -729,6 +820,8 @@ class BrowserToolbarMiddlewareTest { @Test fun `WHEN choosing to paste from clipboard THEN start a new search with the current clipboard text`() { + val navController: NavController = mockk(relaxed = true) + val browsingModeManager = SimpleBrowsingModeManager(Normal) val queryText = "test" val clipboard = ClipboardHandler(testContext).also { it.text = queryText @@ -744,7 +837,11 @@ class BrowserToolbarMiddlewareTest { browserStore = browserStore, clipboard = clipboard, ) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, + ) toolbarStore.dispatch(PasteFromClipboardClicked) @@ -756,6 +853,8 @@ class BrowserToolbarMiddlewareTest { @Test fun `WHEN choosing to load URL from clipboard THEN start load the URL from clipboard in a new tab`() { + val navController: NavController = mockk(relaxed = true) + val browsingModeManager = SimpleBrowsingModeManager(Normal) val clipboardUrl = "https://www.mozilla.com" val clipboard = ClipboardHandler(testContext).also { it.text = clipboardUrl @@ -776,7 +875,11 @@ class BrowserToolbarMiddlewareTest { useCases = useCases, clipboard = clipboard, ) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, + ) every { appStore.state.searchState.selectedSearchEngine?.searchEngine } returns searchEngine @@ -797,9 +900,14 @@ class BrowserToolbarMiddlewareTest { @Test fun `WHEN clicking on the second option in the toolbar long click menu THEN open a new private tab`() { + val navController: NavController = mockk(relaxed = true) val browsingModeManager = SimpleBrowsingModeManager(Normal) - val middleware = buildMiddleware(browsingModeManager = browsingModeManager) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware() + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, + ) val tabCounterButton = toolbarStore.state.displayState.browserActionsEnd[1] as TabCounterAction assertEqualsTabCounterButton(expectedTabCounterButton(0, false), tabCounterButton) val tabCounterMenuItems = (tabCounterButton.onLongClick as CombinedEventAndMenu).menu.items() @@ -816,6 +924,7 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN multiple tabs opened WHEN clicking on the close tab item in the tab counter long click menu THEN close the current tab`() { + val navController: NavController = mockk(relaxed = true) val browsingModeManager = SimpleBrowsingModeManager(Private) val currentTab = createTab("test.com", private = true) val browserStore = BrowserStore( @@ -827,10 +936,15 @@ class BrowserToolbarMiddlewareTest { val tabsUseCases: TabsUseCases = mockk(relaxed = true) every { useCases.tabsUseCases } returns tabsUseCases val middleware = buildMiddleware( + appStore = appStore, browserStore = browserStore, + useCases = useCases, + ) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, browsingModeManager = browsingModeManager, ) - val toolbarStore = buildStore(middleware) mainLooperRule.idle() val tabCounterButton = toolbarStore.state.displayState.browserActionsEnd[1] as TabCounterAction @@ -859,11 +973,16 @@ class BrowserToolbarMiddlewareTest { ), ) val tabsUseCases: TabsUseCases = mockk(relaxed = true) - every { useCases.tabsUseCases } returns tabsUseCases val middleware = buildMiddleware( + appStore = appStore, browserStore = browserStore, + useCases = useCases, + ) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + browsingModeManager = browsingModeManager, ) - val toolbarStore = buildStore(middleware) mainLooperRule.idle() val tabCounterButton = toolbarStore.state.displayState.browserActionsEnd[1] as TabCounterAction @@ -898,12 +1017,16 @@ class BrowserToolbarMiddlewareTest { ), ) val tabsUseCases: TabsUseCases = mockk(relaxed = true) - every { useCases.tabsUseCases } returns tabsUseCases val middleware = buildMiddleware( + appStore = appStore, browserStore = browserStore, + useCases = useCases, + ) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, browsingModeManager = browsingModeManager, ) - val toolbarStore = buildStore(middleware) mainLooperRule.idle() val tabCounterButton = toolbarStore.state.displayState.browserActionsEnd[1] as TabCounterAction @@ -942,14 +1065,16 @@ class BrowserToolbarMiddlewareTest { ), ) val tabsUseCases: TabsUseCases = mockk(relaxed = true) - every { useCases.tabsUseCases } returns tabsUseCases val middleware = buildMiddleware( appStore = appStore, browserStore = browserStore, useCases = useCases, + ) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, browsingModeManager = browsingModeManager, ) - val toolbarStore = buildStore(middleware) mainLooperRule.idle() val tabCounterButton = toolbarStore.state.displayState.browserActionsEnd[1] as TabCounterAction @@ -994,14 +1119,16 @@ class BrowserToolbarMiddlewareTest { ), ) val tabsUseCases: TabsUseCases = mockk(relaxed = true) - every { useCases.tabsUseCases } returns tabsUseCases val middleware = buildMiddleware( appStore = appStore, browserStore = browserStore, useCases = useCases, + ) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, browsingModeManager = browsingModeManager, ) - val toolbarStore = buildStore(middleware) mainLooperRule.idle() val tabCounterButton = toolbarStore.state.displayState.browserActionsEnd[1] as TabCounterAction @@ -1038,6 +1165,7 @@ class BrowserToolbarMiddlewareTest { ) val middleware = buildMiddleware( browserStore = browserStore, + useCases = useCases, ) val toolbarStore = buildStore(middleware).also { it.dispatch(BrowserToolbarAction.Init()) @@ -1067,6 +1195,7 @@ class BrowserToolbarMiddlewareTest { ) val middleware = buildMiddleware( browserStore = browserStore, + useCases = useCases, ) val toolbarStore = buildStore(middleware).also { it.dispatch(BrowserToolbarAction.Init()) @@ -1094,11 +1223,15 @@ class BrowserToolbarMiddlewareTest { ), ) val browserScreenStore = buildBrowserScreenStore() + val readerModeController: ReaderModeController = mockk(relaxed = true) val middleware = buildMiddleware( browserScreenStore = browserScreenStore, browserStore = browserStore, ) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + readerModeController = readerModeController, + ) browserScreenStore.dispatch( ReaderModeStatusUpdated( @@ -1108,7 +1241,6 @@ class BrowserToolbarMiddlewareTest { ), ), ) - mainLooperRule.idle() val readerModeButton = toolbarStore.state.displayState.pageActionsEnd[0] as ActionButtonRes assertEquals(expectedReaderModeButton(false), readerModeButton) @@ -1128,11 +1260,15 @@ class BrowserToolbarMiddlewareTest { ), ) val browserScreenStore = buildBrowserScreenStore() + val readerModeController: ReaderModeController = mockk(relaxed = true) val middleware = buildMiddleware( browserScreenStore = browserScreenStore, browserStore = browserStore, ) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + readerModeController = readerModeController, + ) browserScreenStore.dispatch( ReaderModeStatusUpdated( @@ -1142,7 +1278,6 @@ class BrowserToolbarMiddlewareTest { ), ), ) - mainLooperRule.idle() val readerModeButton = toolbarStore.state.displayState.pageActionsEnd[0] as ActionButtonRes assertEquals(expectedReaderModeButton(true), readerModeButton) @@ -1154,14 +1289,15 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN on a wide window WHEN translation is possible THEN show a translate button`() { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.shouldUseExpandedToolbar } returns false val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - isWideScreen = { true }, - isTallScreen = { false }, - ) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore(middleware, browsingModeManager = browsingModeManager, navController = navController) browserScreenStore.dispatch( PageTranslationStatusUpdated( @@ -1172,7 +1308,6 @@ class BrowserToolbarMiddlewareTest { ), ), ) - mainLooperRule.idle() val translateButton = toolbarStore.state.displayState.pageActionsEnd[0] assertEquals(expectedTranslateButton(), translateButton) @@ -1180,14 +1315,19 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN the current page is translated AND a wide window WHEN knowing of this state THEN update the translate button to show this`() { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.shouldUseExpandedToolbar } returns true val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - isWideScreen = { true }, - isTallScreen = { false }, + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore( + middleware, + browsingModeManager = browsingModeManager, + navController = navController, ) - val toolbarStore = buildStore(middleware) browserScreenStore.dispatch( PageTranslationStatusUpdated( @@ -1198,7 +1338,6 @@ class BrowserToolbarMiddlewareTest { ), ), ) - mainLooperRule.idle() var translateButton = toolbarStore.state.displayState.pageActionsEnd[0] assertEquals(expectedTranslateButton(), translateButton) @@ -1211,7 +1350,6 @@ class BrowserToolbarMiddlewareTest { ), ), ) - mainLooperRule.idle() translateButton = toolbarStore.state.displayState.pageActionsEnd[0] assertEquals( expectedTranslateButton(isActive = true), @@ -1221,6 +1359,11 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN translation is possible WHEN tapping on the translate button THEN allow user to choose how to translate`() { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.shouldUseExpandedToolbar } returns true val currentNavDestination: NavDestination = mockk { every { id } returns R.id.browserFragment @@ -1230,13 +1373,12 @@ class BrowserToolbarMiddlewareTest { } val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore( + middleware, + browsingModeManager = browsingModeManager, navController = navController, - isWideScreen = { true }, - isTallScreen = { false }, ) - val toolbarStore = buildStore(middleware) browserScreenStore.dispatch( PageTranslationStatusUpdated( PageTranslationStatus( @@ -1246,7 +1388,6 @@ class BrowserToolbarMiddlewareTest { ), ), ) - mainLooperRule.idle() val translateButton = toolbarStore.state.displayState.pageActionsEnd[0] as ActionButtonRes @@ -1266,21 +1407,27 @@ class BrowserToolbarMiddlewareTest { every { settings.shouldUseExpandedToolbar } returns false val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware(appStore, browserScreenStore) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore( + middleware, + browsingModeManager = browsingModeManager, + navController = navController, + ) assertTrue(toolbarStore.state.displayState.pageActionsEnd.isEmpty()) } @Test fun `GIVEN on a wide screen with tabstrip is disabled THEN show a share button as page end action`() { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.isTabStripEnabled } returns false val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - isWideScreen = { true }, - ) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore(middleware, browsingModeManager = browsingModeManager, navController = navController) val shareButton = toolbarStore.state.displayState.pageActionsEnd[0] assertEquals(expectedShareButton(), shareButton) @@ -1290,14 +1437,23 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN on a large screen with tabstrip is enabled THEN don't show a share button as page end action`() { every { settings.isTabStripEnabled } returns true val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware(appStore, browserScreenStore) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore( + middleware, + browsingModeManager = browsingModeManager, + navController = navController, + ) assertTrue(toolbarStore.state.displayState.pageActionsEnd.isEmpty()) } @Test fun `GIVEN the current tab shows a content page WHEN the share button is clicked THEN record telemetry and start sharing the local resource`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.isTabStripEnabled } returns true every { settings.shouldUseExpandedToolbar } returns false val browserScreenStore = buildBrowserScreenStore() @@ -1310,12 +1466,12 @@ class BrowserToolbarMiddlewareTest { ), middleware = listOf(captureMiddleware), ) - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - browserStore = browserStore, - isWideScreen = { true }, + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore( + middleware, + browsingModeManager = browsingModeManager, + navController = navController, ) - val toolbarStore = buildStore(middleware) mainLooperRule.idle() val shareButton = toolbarStore.state.displayState.browserActionsEnd[0] as ActionButtonRes @@ -1331,6 +1487,11 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN the current tab shows a normal webpage WHEN the share button is clicked THEN record telemetry and open the share dialog`() { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.isTabStripEnabled } returns true every { settings.shouldUseExpandedToolbar } returns false every { navController.currentDestination?.id } returns R.id.browserFragment @@ -1343,12 +1504,12 @@ class BrowserToolbarMiddlewareTest { selectedTabId = currentTab.id, ), ) - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - browserStore = browserStore, - isWideScreen = { true }, + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore( + middleware, + browsingModeManager = browsingModeManager, + navController = navController, ) - val toolbarStore = buildStore(middleware) mainLooperRule.idle() val shareButton = toolbarStore.state.displayState.browserActionsEnd[0] as ActionButtonRes @@ -1378,13 +1539,18 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN on a small width with tabstrip is enabled and not using the extended layout THEN don't show a share button as browser end action`() { every { settings.shouldUseExpandedToolbar } returns false every { settings.isTabStripEnabled } returns true + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 500 + } + every { mockContext.resources.configuration } returns configuration val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - isWideScreen = { false }, - isTallScreen = { false }, + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore( + middleware, + browsingModeManager = browsingModeManager, + navController = navController, ) - val toolbarStore = buildStore(middleware) assertEquals(1, toolbarStore.state.displayState.browserActionsEnd.size) val toolbarButton = toolbarStore.state.displayState.browserActionsEnd[0] @@ -1395,22 +1561,29 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN expanded toolbar with tabstrip and tall window WHEN changing to short window THEN show menu`() = runTest { every { settings.isTabStripEnabled } returns true every { settings.shouldUseExpandedToolbar } returns true + configuration = Configuration().apply { + screenHeightDp = 500 + screenWidthDp = 300 + } + every { mockContext.resources.configuration } returns configuration val browserScreenStore = buildBrowserScreenStore() - var isWideScreen = false - var isTallScreen = true - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - isWideScreen = { isWideScreen }, - isTallScreen = { isTallScreen }, + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore( + middleware, + browsingModeManager = browsingModeManager, + navController = navController, ) - val toolbarStore = buildStore(middleware) var navigationActions = toolbarStore.state.displayState.navigationActions assertEquals(5, navigationActions.size) var toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(0, toolbarBrowserActions.size) - isTallScreen = false + configuration = Configuration().apply { + screenHeightDp = 300 + screenWidthDp = 500 + } + every { mockContext.resources.configuration } returns configuration appStore.dispatch(AppAction.OrientationChange(Portrait)) mainLooperRule.idle() @@ -1424,15 +1597,21 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN on a wide screen with tabstrip is enabled and not using the extended layout THEN show a share button as browser end action`() { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration + every { settings.isTabStripEnabled } returns true every { settings.shouldUseExpandedToolbar } returns false val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - isWideScreen = { true }, - isTallScreen = { false }, + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore( + middleware, + browsingModeManager = browsingModeManager, + navController = navController, ) - val toolbarStore = buildStore(middleware) val shareButton = toolbarStore.state.displayState.browserActionsEnd[0] assertEquals(expectedShareButton(), shareButton) @@ -1440,14 +1619,16 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN short window with tabstrip is enabled and not using the extended layout THEN show a share button as browser end action`() { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.isTabStripEnabled } returns true every { settings.shouldUseExpandedToolbar } returns false val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - isWideScreen = { true }, - ) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore(middleware, browsingModeManager = browsingModeManager, navController = navController) val shareButton = toolbarStore.state.displayState.browserActionsEnd[0] assertEquals(expectedShareButton(), shareButton) @@ -1459,8 +1640,12 @@ class BrowserToolbarMiddlewareTest { every { settings.shouldUseExpandedToolbar } returns true val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware(appStore, browserScreenStore) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore( + middleware, + browsingModeManager = browsingModeManager, + navController = navController, + ) assertTrue(toolbarStore.state.displayState.pageActionsEnd.isEmpty()) } @@ -1479,7 +1664,7 @@ class BrowserToolbarMiddlewareTest { } every { browserScreenState.pageTranslationStatus } returns pageTranslationStatus - var middleware = buildMiddleware(appStore) + var middleware = buildMiddleware(appStore, browserScreenStore, browserStore) var toolbarStore = buildStore(middleware) assertEquals( @@ -1487,11 +1672,13 @@ class BrowserToolbarMiddlewareTest { toolbarStore.state.displayState.pageActionsEnd, ) - middleware = buildMiddleware( - appStore = appStore, - isWideScreen = { true }, - isTallScreen = { false }, - ) + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration + + middleware = buildMiddleware(appStore, browserScreenStore, browserStore) toolbarStore = buildStore(middleware) assertEquals( @@ -1503,11 +1690,12 @@ class BrowserToolbarMiddlewareTest { toolbarStore.state.displayState.pageActionsEnd, ) - middleware = buildMiddleware( - appStore = appStore, - isWideScreen = { false }, - isTallScreen = { true }, - ) + configuration = Configuration().apply { + screenHeightDp = 700 + screenWidthDp = 400 + } + every { mockContext.resources.configuration } returns configuration + middleware = buildMiddleware(appStore, browserScreenStore, browserStore) toolbarStore = buildStore(middleware) assertEquals( @@ -1518,10 +1706,13 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN device has wide window WHEN a website is loaded THEN show navigation buttons`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.shouldUseBottomToolbar } returns false - val middleware = buildMiddleware( - isWideScreen = { true }, - ) + val middleware = buildMiddleware() val toolbarStore = buildStore(middleware) val displayGoBackButton = toolbarStore.state.displayState.browserActionsStart[0] @@ -1533,6 +1724,12 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN the back button is shown WHEN interacted with THEN go back or show history`() = runTest { every { navController.currentDestination?.id } returns R.id.browserFragment + + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.shouldUseBottomToolbar } returns false val currentTab = createTab("test.com", private = false) val captureMiddleware = CaptureActionsMiddleware<BrowserState, BrowserAction>() @@ -1546,10 +1743,7 @@ class BrowserToolbarMiddlewareTest { ), middleware = listOf(captureMiddleware) + EngineMiddleware.create(engine), ) - val middleware = buildMiddleware( - browserStore = browserStore, - isWideScreen = { true }, - ) + val middleware = buildMiddleware(appStore, browserStore = browserStore) val toolbarStore = buildStore(middleware) val backButton = toolbarStore.state.displayState.browserActionsStart[0] as ActionButtonRes @@ -1570,6 +1764,11 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN the forward button is shown WHEN interacted with THEN go forward or show history`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.shouldUseBottomToolbar } returns false val currentTab = createTab("test.com", private = false) val captureMiddleware = CaptureActionsMiddleware<BrowserState, BrowserAction>() @@ -1580,10 +1779,7 @@ class BrowserToolbarMiddlewareTest { ), middleware = listOf(captureMiddleware) + EngineMiddleware.create(mockk()), ) - val middleware = buildMiddleware( - browserStore = browserStore, - isWideScreen = { true }, - ) + val middleware = buildMiddleware(appStore, browserStore = browserStore) val toolbarStore = buildStore(middleware) val forwardButton = toolbarStore.state.displayState.browserActionsStart[1] as ActionButtonRes @@ -1598,6 +1794,11 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN device has wide window WHEN a website is loaded THEN show refresh button`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration val browsingModeManager = SimpleBrowsingModeManager(Private) val currentNavDestination: NavDestination = mockk { every { id } returns R.id.browserFragment @@ -1626,15 +1827,11 @@ class BrowserToolbarMiddlewareTest { } val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - browserStore = browserStore, - useCases = useCases, - sessionUseCases = sessionUseCases, - navController = navController, - browsingModeManager = browsingModeManager, - isWideScreen = { true }, + appStore, browserScreenStore, browserStore, useCases, sessionUseCases = sessionUseCases, + ) + val toolbarStore = buildStore( + middleware, browsingModeManager = browsingModeManager, navController = navController, ) - val toolbarStore = buildStore(middleware) val loadUrlFlagsUsed = mutableListOf<LoadUrlFlags>() @@ -1652,6 +1849,11 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN device have a wide window WHEN a website is loaded THEN show refresh button`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration val browsingModeManager = SimpleBrowsingModeManager(Private) val currentNavDestination: NavDestination = mockk { every { id } returns R.id.browserFragment @@ -1679,15 +1881,17 @@ class BrowserToolbarMiddlewareTest { every { fenixBrowserUseCases } returns browserUseCases } val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - browserStore = browserStore, - useCases = useCases, + appStore, + browserScreenStore, + browserStore, + useCases, sessionUseCases = sessionUseCases, - navController = navController, + ) + val toolbarStore = buildStore( + middleware, browsingModeManager = browsingModeManager, - isWideScreen = { true }, + navController = navController, ) - val toolbarStore = buildStore(middleware) val loadUrlFlagsUsed = mutableListOf<LoadUrlFlags>() @@ -1706,6 +1910,11 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN a loaded tab WHEN the refresh button is pressed THEN show stop refresh button`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration val browsingModeManager = SimpleBrowsingModeManager(Private) val currentNavDestination: NavDestination = mockk { every { id } returns R.id.browserFragment @@ -1733,15 +1942,11 @@ class BrowserToolbarMiddlewareTest { every { fenixBrowserUseCases } returns browserUseCases } val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - browserStore = browserStore, - useCases = useCases, - sessionUseCases = sessionUseCases, - navController = navController, - browsingModeManager = browsingModeManager, - isWideScreen = { true }, + appStore, browserScreenStore, browserStore, useCases, sessionUseCases = sessionUseCases, + ) + val toolbarStore = buildStore( + middleware, browsingModeManager = browsingModeManager, navController = navController, ) - val toolbarStore = buildStore(middleware) val loadUrlFlagsUsed = mutableListOf<LoadUrlFlags>() @@ -2351,7 +2556,10 @@ class BrowserToolbarMiddlewareTest { testContext.getString(tabcounterR.string.mozac_tab_counter_open_tab_tray, 3), action.contentDescription, ) - assertFalse(action.showPrivacyMask) + assertEquals( + middleware.environment?.browsingModeManager?.mode == Private, + action.showPrivacyMask, + ) assertEquals(TabCounterClicked(Source.AddressBar), action.onClick) assertNotNull(action.onLongClick) } @@ -2423,11 +2631,14 @@ class BrowserToolbarMiddlewareTest { @Test fun `WHEN initializing the navigation bar AND should not use simple toolbar AND in short window THEN add no navigation bar actions`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.shouldUseExpandedToolbar } returns true - val middleware = buildMiddleware( - isWideScreen = { true }, - ) + val middleware = buildMiddleware(appStore = appStore) val toolbarStore = buildStore(middleware) val navigationActions = toolbarStore.state.displayState.navigationActions @@ -2442,13 +2653,7 @@ class BrowserToolbarMiddlewareTest { ), ) every { settings.shouldUseExpandedToolbar } returns true - var isWideScreen = false - var isTallScreen = true - val middleware = buildMiddleware( - appStore = appStore, - isWideScreen = { isWideScreen }, - isTallScreen = { isTallScreen }, - ) + val middleware = buildMiddleware(appStore = appStore) val toolbarStore = buildStore(middleware) var navigationActions = toolbarStore.state.displayState.navigationActions @@ -2457,8 +2662,11 @@ class BrowserToolbarMiddlewareTest { var toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(0, toolbarBrowserActions.size) - isWideScreen = true - isTallScreen = false + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration appStore.dispatch(AppAction.OrientationChange(Landscape)) mainLooperRule.idle() @@ -2678,14 +2886,16 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN share shortcut is selected THEN update end page actions without share action`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.isTabStripEnabled } returns false every { settings.toolbarSimpleShortcutKey } returns ShortcutType.SHARE val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - isWideScreen = { true }, - ) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore(middleware, browsingModeManager = browsingModeManager, navController = navController) val endPageActions = toolbarStore.state.displayState.pageActionsEnd assertEquals(emptyList<Action>(), endPageActions) @@ -2693,14 +2903,16 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN translate shortcut is selected THEN update end page actions without translate action`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.isTabStripEnabled } returns false every { settings.toolbarSimpleShortcutKey } returns ShortcutType.TRANSLATE val browserScreenStore = buildBrowserScreenStore() - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - isWideScreen = { true }, - ) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(appStore, browserScreenStore, browserStore) + val toolbarStore = buildStore(middleware, browsingModeManager = browsingModeManager, navController = navController) browserScreenStore.dispatch( PageTranslationStatusUpdated( @@ -2730,7 +2942,10 @@ class BrowserToolbarMiddlewareTest { every { settings.toolbarSimpleShortcutKey } returns ShortcutType.HOMEPAGE val middleware = buildMiddleware() - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + ) val homepageButton = toolbarStore.state.displayState.browserActionsEnd[0] as ActionButtonRes toolbarStore.dispatch(homepageButton.onClick as BrowserToolbarEvent) @@ -2744,7 +2959,10 @@ class BrowserToolbarMiddlewareTest { every { settings.toolbarSimpleShortcutKey } returns ShortcutType.HOMEPAGE val middleware = buildMiddleware() - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + ) val newTabButton = toolbarStore.state.displayState.browserActionsEnd[0] as ActionButtonRes toolbarStore.dispatch(newTabButton.onClick as BrowserToolbarEvent) @@ -2753,16 +2971,16 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN expanded toolbar is used and navbar is hidden WHEN building end browser actions THEN use simple toolbar shortcuts`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { settings.shouldShowToolbarCustomization } returns true every { settings.shouldUseExpandedToolbar } returns true every { settings.toolbarSimpleShortcutKey } returns ShortcutType.HOMEPAGE - val middleware = buildMiddleware( - browserScreenStore = browserScreenStore, - isTallScreen = { false }, - isWideScreen = { true }, - ) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore() val homepageButton = toolbarStore.state.displayState.browserActionsEnd[0] as ActionButtonRes assertEquals(expectedHomepageButton(), homepageButton) @@ -3243,65 +3461,73 @@ class BrowserToolbarMiddlewareTest { appStore: AppStore = this.appStore, browserScreenStore: BrowserScreenStore = this.browserScreenStore, browserStore: BrowserStore = this.browserStore, - permissionsStorage: SitePermissionsStorage = this.permissionsStorage, - cookieBannersStorage: CookieBannersStorage = this.cookieBannersStorage, - trackingProtectionUseCases: TrackingProtectionUseCases = this.trackingProtectionUseCases, - bookmarksStorage: BookmarksStorage = this.bookmarksStorage, useCases: UseCases = this.useCases, - sessionUseCases: SessionUseCases = SessionUseCases(browserStore), nimbusComponents: NimbusComponents = this.nimbusComponents, clipboard: ClipboardHandler = this.clipboard, publicSuffixList: PublicSuffixList = this.publicSuffixList, settings: Settings = this.settings, - navController: NavController = this.navController, - browsingModeManager: BrowsingModeManager = this.browsingModeManager, - readerModeController: ReaderModeController = this.readerModeController, - browserAnimator: BrowserAnimator = this.browserAnimator, - thumbnailsFeature: () -> BrowserThumbnails = { this.thumbnailsFeature }, - isWideScreen: () -> Boolean = { false }, - isTallScreen: () -> Boolean = { true }, - scope: CoroutineScope = MainScope(), + permissionsStorage: SitePermissionsStorage = this.permissionsStorage, + cookieBannersStorage: CookieBannersStorage = this.cookieBannersStorage, + trackingProtectionUseCases: TrackingProtectionUseCases = this.trackingProtectionUseCases, + sessionUseCases: SessionUseCases = SessionUseCases(browserStore), + bookmarksStorage: BookmarksStorage = this.bookmarksStorage, ) = BrowserToolbarMiddleware( - uiContext = testContext, appStore = appStore, browserScreenStore = browserScreenStore, browserStore = browserStore, - permissionsStorage = permissionsStorage, - cookieBannersStorage = cookieBannersStorage, - bookmarksStorage = bookmarksStorage, - trackingProtectionUseCases = trackingProtectionUseCases, useCases = useCases, nimbusComponents = nimbusComponents, clipboard = clipboard, publicSuffixList = publicSuffixList, settings = settings, - navController = navController, - browsingModeManager = browsingModeManager, - readerModeController = readerModeController, - browserAnimator = browserAnimator, - thumbnailsFeature = thumbnailsFeature, - isWideScreen = isWideScreen, - isTallScreen = isTallScreen, + permissionsStorage = permissionsStorage, + cookieBannersStorage = cookieBannersStorage, + trackingProtectionUseCases = trackingProtectionUseCases, sessionUseCases = sessionUseCases, - scope = scope, + bookmarksStorage = bookmarksStorage, ioDispatcher = Dispatchers.Main, ) private fun buildStore( middleware: BrowserToolbarMiddleware = buildMiddleware(), + context: Context = testContext, + navController: NavController = this@BrowserToolbarMiddlewareTest.navController, + browsingModeManager: BrowsingModeManager = this@BrowserToolbarMiddlewareTest.browsingModeManager, + browserAnimator: BrowserAnimator = this@BrowserToolbarMiddlewareTest.browserAnimator, + thumbnailsFeature: BrowserThumbnails? = this@BrowserToolbarMiddlewareTest.thumbnailsFeature, + readerModeController: ReaderModeController = this@BrowserToolbarMiddlewareTest.readerModeController, ) = BrowserToolbarStore( middleware = listOf(middleware), ).also { - mainLooperRule.idle() // to complete the initial setup happening in coroutines + it.dispatch( + EnvironmentRehydrated( + BrowserToolbarEnvironment( + context = context, + fragment = fragment, + navController = navController, + browsingModeManager = browsingModeManager, + browserAnimator = browserAnimator, + thumbnailsFeature = { thumbnailsFeature }, + readerModeController = readerModeController, + ), + ), + ) } private fun buildBrowserScreenStore( initialState: BrowserScreenState = BrowserScreenState(), middlewares: List<Middleware<BrowserScreenState, BrowserScreenAction>> = emptyList(), + context: Context = testContext, + viewLifecycleOwner: LifecycleOwner = lifecycleOwner, + fragmentManager: FragmentManager = mockk(), ) = BrowserScreenStore( initialState = initialState, middleware = middlewares, - ) + ).also { + it.dispatch( + BrowserScreenAction.EnvironmentRehydrated(Environment(context, viewLifecycleOwner, fragmentManager)), + ) + } private class FakeLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner { override val lifecycle: Lifecycle = LifecycleRegistry(this).apply { diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/CustomTabBrowserToolbarMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/CustomTabBrowserToolbarMiddlewareTest.kt @@ -18,11 +18,6 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import mozilla.components.browser.state.action.ContentAction.UpdateProgressAction import mozilla.components.browser.state.action.ContentAction.UpdateSecurityInfoAction @@ -42,6 +37,8 @@ import mozilla.components.compose.browser.toolbar.concept.PageOrigin import mozilla.components.compose.browser.toolbar.concept.PageOrigin.Companion.ContextualMenuOption import mozilla.components.compose.browser.toolbar.concept.PageOrigin.Companion.PageOriginContextualMenuInteractions.CopyToClipboardClicked import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.ProgressBarConfig import mozilla.components.concept.engine.cookiehandling.CookieBannersStorage import mozilla.components.concept.engine.permission.SitePermissionsStorage @@ -298,6 +295,18 @@ class CustomTabBrowserToolbarMiddlewareTest { } @Test + fun `GIVEN an environment was already set WHEN it is cleared THEN reset it to null`() { + val middleware = buildMiddleware() + val store = buildStore(middleware) + + assertNotNull(middleware.environment) + + store.dispatch(EnvironmentCleared) + + assertNull(middleware.environment) + } + + @Test fun `GIVEN the website is insecure WHEN the conection becomes secure THEN update appropriate security indicator`() = runTest { val customTab = createCustomTab( url = "URL", @@ -450,8 +459,11 @@ class CustomTabBrowserToolbarMiddlewareTest { val navController: NavController = mockk(relaxed = true) every { customTab.content.url } returns "https://mozilla.test" val clipboard = ClipboardHandler(testContext) - val middleware = buildMiddleware(appStore = appStore, clipboard = clipboard, navController = navController) - val toolbarStore = buildStore(middleware) + val middleware = buildMiddleware(appStore = appStore, clipboard = clipboard) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + ) toolbarStore.dispatch(CopyToClipboardClicked) @@ -464,10 +476,14 @@ class CustomTabBrowserToolbarMiddlewareTest { @Config(sdk = [33]) fun `GIVEN on Android 13 WHEN choosing to copy the current URL to clipboard THEN copy to clipboard and don't show a snackbar`() { val appStore: AppStore = mockk(relaxed = true) + val navController: NavController = mockk(relaxed = true) every { customTab.content.url } returns "https://mozilla.test" val clipboard = ClipboardHandler(testContext) val middleware = buildMiddleware(appStore = appStore, clipboard = clipboard) - val toolbarStore = buildStore(middleware) + val toolbarStore = buildStore( + middleware = middleware, + navController = navController, + ) toolbarStore.dispatch(CopyToClipboardClicked) @@ -727,12 +743,8 @@ class CustomTabBrowserToolbarMiddlewareTest { trackingProtectionUseCases: TrackingProtectionUseCases = this.trackingProtectionUseCases, publicSuffixList: PublicSuffixList = this.publicSuffixList, clipboard: ClipboardHandler = this.clipboard, - navController: NavController = this.navController, - closeTabDelegate: () -> Unit = this.closeTabDelegate, settings: Settings = this.settings, - scope: CoroutineScope = MainScope(), ) = CustomTabBrowserToolbarMiddleware( - uiContext = testContext, customTabId = this.customTabId, browserStore = browserStore, appStore = appStore, @@ -742,17 +754,29 @@ class CustomTabBrowserToolbarMiddlewareTest { trackingProtectionUseCases = trackingProtectionUseCases, publicSuffixList = publicSuffixList, clipboard = clipboard, - navController = navController, - closeTabDelegate = closeTabDelegate, settings = settings, - scope = scope, ) private fun buildStore( middleware: CustomTabBrowserToolbarMiddleware = buildMiddleware(), + context: Context = testContext, + lifecycleOwner: LifecycleOwner = this@CustomTabBrowserToolbarMiddlewareTest.lifecycleOwner, + navController: NavController = this@CustomTabBrowserToolbarMiddlewareTest.navController, + closeTabDelegate: () -> Unit = this@CustomTabBrowserToolbarMiddlewareTest.closeTabDelegate, ) = BrowserToolbarStore( middleware = listOf(middleware), - ) + ).also { + it.dispatch( + EnvironmentRehydrated( + CustomTabToolbarEnvironment( + context = context, + viewLifecycleOwner = lifecycleOwner, + navController = navController, + closeTabDelegate = closeTabDelegate, + ), + ), + ) + } private fun assertPageOriginEquals(expected: PageOrigin, actual: PageOrigin) { assertEquals(expected.hint, actual.hint) diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddlewareTest.kt @@ -5,15 +5,18 @@ package org.mozilla.fenix.home.toolbar import android.content.Context +import android.content.res.Configuration import android.os.Looper +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry import androidx.navigation.NavController import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.every import io.mockk.mockk import io.mockk.spyk import io.mockk.verify -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope import kotlinx.coroutines.test.runTest import mozilla.components.browser.state.action.SearchAction.ApplicationSearchEnginesLoaded import mozilla.components.browser.state.action.TabListAction.AddTabAction @@ -43,12 +46,13 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.B import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.BrowserToolbarMenuButton.Icon.DrawableResIcon import mozilla.components.compose.browser.toolbar.store.BrowserToolbarMenuItem.BrowserToolbarMenuButton.Text.StringResText import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.support.test.mock import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.rule.MainLooperTestRule import mozilla.components.support.utils.ClipboardHandler import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -76,6 +80,7 @@ import org.mozilla.fenix.components.appstate.SupportedMenuNotifications import org.mozilla.fenix.components.appstate.search.SearchState import org.mozilla.fenix.components.appstate.search.SelectedSearchEngine import org.mozilla.fenix.components.menu.MenuAccessPoint +import org.mozilla.fenix.components.toolbar.BrowserToolbarEnvironment import org.mozilla.fenix.components.usecases.FenixBrowserUseCases import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.settings @@ -93,7 +98,6 @@ import org.mozilla.fenix.search.fixtures.assertSearchSelectorEquals import org.mozilla.fenix.search.fixtures.buildExpectedSearchSelector import org.mozilla.fenix.settings.ShortcutType import org.mozilla.fenix.tabstray.Page -import org.mozilla.fenix.utils.Settings import org.robolectric.Shadows.shadowOf import mozilla.components.ui.icons.R as iconsR import mozilla.components.ui.tabcounter.R as tabcounterR @@ -107,8 +111,12 @@ class BrowserToolbarMiddlewareTest { val gleanRule = FenixGleanTestRule(testContext) private val browserStore = BrowserStore() + private val lifecycleOwner = FakeLifecycleOwner(Lifecycle.State.RESUMED) private val browsingModeManager = SimpleBrowsingModeManager(Normal) + private val mockContext: Context = mockk(relaxed = true) private lateinit var appStore: AppStore + private lateinit var fragment: Fragment + private lateinit var configuration: Configuration @Before fun setup() = runTest { @@ -118,11 +126,28 @@ class BrowserToolbarMiddlewareTest { every { testContext.settings().tabManagerEnhancementsEnabled } returns false every { testContext.settings().shouldShowToolbarCustomization } returns false every { testContext.settings().toolbarExpandedShortcutKey } returns ShortcutType.BOOKMARK + + fragment = spyk(Fragment()).apply { + every { context } returns mockContext + } + every { fragment.viewLifecycleOwner } returns lifecycleOwner + configuration = Configuration().apply { + screenHeightDp = 700 + screenWidthDp = 400 + } + every { mockContext.resources.configuration } returns configuration } @Test fun `WHEN initializing the toolbar THEN add browser end actions`() = runTest { - val (_, toolbarStore) = buildMiddlewareAndAddToStore() + val middleware = BrowserToolbarMiddleware( + appStore, + browserStore, + mockk(), + mockk(), + ) + + val toolbarStore = buildStore(middleware) val toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(2, toolbarBrowserActions.size) @@ -135,8 +160,9 @@ class BrowserToolbarMiddlewareTest { @Test fun `WHEN initializing the toolbar AND should use expanded toolbar THEN don't add browser end actions`() = runTest { every { testContext.settings().shouldUseExpandedToolbar } returns true + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) - val (_, toolbarStore) = buildMiddlewareAndAddToStore() + val toolbarStore = buildStore(middleware) val toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(0, toolbarBrowserActions.size) @@ -145,8 +171,9 @@ class BrowserToolbarMiddlewareTest { @Test fun `WHEN initializing the navigation bar AND should use expanded toolbar THEN add navigation bar actions`() = runTest { every { testContext.settings().shouldUseExpandedToolbar } returns true + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) - val (_, toolbarStore) = buildMiddlewareAndAddToStore() + val toolbarStore = buildStore(middleware) val navigationActions = toolbarStore.state.displayState.navigationActions assertEquals(5, navigationActions.size) @@ -167,11 +194,15 @@ class BrowserToolbarMiddlewareTest { @Test fun `WHEN initializing the navigation bar AND should use expanded toolbar AND window is short THEN add no navigation bar actions`() = runTest { + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration every { testContext.settings().shouldUseExpandedToolbar } returns true + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - isWideScreen = { true }, - ) + val toolbarStore = buildStore(middleware) val navigationActions = toolbarStore.state.displayState.navigationActions assertEquals(0, navigationActions.size) @@ -188,14 +219,9 @@ class BrowserToolbarMiddlewareTest { ), ) every { testContext.settings().shouldUseExpandedToolbar } returns true + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) - var isWideScreen = false - var isTallScreen = true - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - appStore = appStore, - isWideScreen = { isWideScreen }, - isTallScreen = { isTallScreen }, - ) + val toolbarStore = buildStore(middleware) var navigationActions = toolbarStore.state.displayState.navigationActions assertEquals(5, navigationActions.size) @@ -203,8 +229,11 @@ class BrowserToolbarMiddlewareTest { var toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(0, toolbarBrowserActions.size) - isWideScreen = true - isTallScreen = false + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration appStore.dispatch(AppAction.OrientationChange(Landscape)) mainLooperRule.idle() @@ -223,9 +252,10 @@ class BrowserToolbarMiddlewareTest { tabs = listOf(createTab("test.com", private = false)), ), ) + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - browserStore = browserStore, + val toolbarStore = buildStore( + middleware = middleware, browsingModeManager = browsingModeManager, ) @@ -245,8 +275,10 @@ class BrowserToolbarMiddlewareTest { ), ), ) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - browserStore = browserStore, + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + + val toolbarStore = buildStore( + middleware = middleware, browsingModeManager = browsingModeManager, ) @@ -264,7 +296,9 @@ class BrowserToolbarMiddlewareTest { contextualMenuOptions = listOf(PasteFromClipboard, LoadFromClipboard), onClick = OriginClicked, ) - val (_, toolbarStore) = buildMiddlewareAndAddToStore() + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + + val toolbarStore = buildStore(middleware) val originConfiguration = toolbarStore.state.displayState.pageOrigin assertEquals(expectedConfiguration, originConfiguration) @@ -272,30 +306,41 @@ class BrowserToolbarMiddlewareTest { @Test fun `WHEN clicking on the URL THEN record telemetry`() { - val (_, toolbarStore) = buildMiddlewareAndAddToStore() + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore(middleware) toolbarStore.dispatch(toolbarStore.state.displayState.pageOrigin.onClick as BrowserToolbarAction) assertEquals("HOME", Events.searchBarTapped.testGetValue()?.last()?.extra?.get("source")) } + @Test + fun `GIVEN an environment was already set WHEN it is cleared THEN reset it to null`() { + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val store = buildStore(middleware) + + assertNotNull(middleware.environment) + + store.dispatch(EnvironmentCleared) + + assertNull(middleware.environment) + } + // Testing updated configuration @Test fun `GIVEN tall window WHEN changing to short window THEN show browser end actions`() = runTest { - var isWideScreen = false - var isTallScreen = true - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - appStore = appStore, - isWideScreen = { isWideScreen }, - isTallScreen = { isTallScreen }, - ) + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore(middleware) mainLooperRule.idle() var toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(2, toolbarBrowserActions.size) - isWideScreen = true - isTallScreen = false + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration appStore.dispatch(AppAction.OrientationChange(Landscape)) mainLooperRule.idle() @@ -309,19 +354,22 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN short window WHEN changing to tall window THEN show all browser end actions`() = runTest { - var isWideScreen = true - var isTallScreen = false - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - appStore = appStore, - isWideScreen = { isWideScreen }, - isTallScreen = { isTallScreen }, - ) + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore(middleware) mainLooperRule.idle() var toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(2, toolbarBrowserActions.size) - isWideScreen = false - isTallScreen = true + configuration = Configuration().apply { + screenHeightDp = 700 + screenWidthDp = 400 + } + every { mockContext.resources.configuration } returns configuration appStore.dispatch(AppAction.OrientationChange(Portrait)) mainLooperRule.idle() @@ -337,21 +385,24 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN expanded toolbar with tabstrip and tall window WHEN changing to short window THEN show menu`() = runTest { every { testContext.settings().shouldUseExpandedToolbar } returns true every { testContext.settings().isTabStripEnabled } returns true - var isWideScreen = false - var isTallScreen = true - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - appStore = appStore, - isWideScreen = { isWideScreen }, - isTallScreen = { isTallScreen }, - ) + configuration = Configuration().apply { + screenHeightDp = 700 + screenWidthDp = 400 + } + every { mockContext.resources.configuration } returns configuration + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore(middleware) mainLooperRule.idle() var navigationActions = toolbarStore.state.displayState.navigationActions assertEquals(5, navigationActions.size) var toolbarBrowserActions = toolbarStore.state.displayState.browserActionsEnd assertEquals(0, toolbarBrowserActions.size) - isWideScreen = true - isTallScreen = false + configuration = Configuration().apply { + screenHeightDp = 400 + screenWidthDp = 700 + } + every { mockContext.resources.configuration } returns configuration appStore.dispatch(AppAction.OrientationChange(Portrait)) mainLooperRule.idle() @@ -367,8 +418,9 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN in normal browsing WHEN the number of normal opened tabs is modified THEN update the tab counter`() = runTest { val browsingModeManager = SimpleBrowsingModeManager(Normal) val browserStore = BrowserStore() - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - browserStore = browserStore, + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore( + middleware = middleware, browsingModeManager = browsingModeManager, ) mainLooperRule.idle() @@ -399,8 +451,9 @@ class BrowserToolbarMiddlewareTest { tabs = listOf(initialNormalTab, initialPrivateTab), ), ) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - browserStore = browserStore, + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore( + middleware = middleware, browsingModeManager = browsingModeManager, ) mainLooperRule.idle() @@ -423,7 +476,9 @@ class BrowserToolbarMiddlewareTest { @Test fun `WHEN clicking the menu button THEN open the menu`() { val navController: NavController = mockk(relaxed = true) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore( + middleware = middleware, navController = navController, ) val menuButton = toolbarStore.state.displayState.browserActionsEnd[1] as ActionButtonRes @@ -444,7 +499,9 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN browsing in normal mode WHEN clicking the tab counter button THEN open the tabs tray in normal mode`() { val browsingModeManager = SimpleBrowsingModeManager(Normal) val navController: NavController = mockk(relaxed = true) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore( + middleware = middleware, navController = navController, browsingModeManager = browsingModeManager, ) @@ -464,7 +521,9 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN browsing in private mode WHEN clicking the tab counter button THEN open the tabs tray in private mode`() { val browsingModeManager = SimpleBrowsingModeManager(Private) val navController: NavController = mockk(relaxed = true) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore( + middleware = middleware, navController = navController, browsingModeManager = browsingModeManager, ) @@ -484,7 +543,9 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN browsing in normal mode WHEN clicking on the long click menu option THEN open a new private tab`() { val browsingModeManager = SimpleBrowsingModeManager(Normal) val navController: NavController = mockk(relaxed = true) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore( + middleware = middleware, navController = navController, browsingModeManager = browsingModeManager, ) @@ -503,7 +564,9 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN browsing in private mode WHEN clicking on the long click menu option THEN open a new normal tab`() { val browsingModeManager = SimpleBrowsingModeManager(Private) val navController: NavController = mockk(relaxed = true) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore( + middleware = middleware, navController = navController, browsingModeManager = browsingModeManager, ) @@ -522,7 +585,9 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN in normal browsing mode WHEN the page origin is clicked THEN start the search UX for normal browsing`() { val browsingModeManager = SimpleBrowsingModeManager(Normal) val navController: NavController = mockk(relaxed = true) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore( + middleware = middleware, navController = navController, browsingModeManager = browsingModeManager, ) @@ -536,7 +601,9 @@ class BrowserToolbarMiddlewareTest { fun `GIVEN in private browsing mode WHEN the page origin is clicked THEN start the search UX for private browsing`() { val browsingModeManager = SimpleBrowsingModeManager(Private) val navController: NavController = mockk(relaxed = true) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore( + middleware = middleware, navController = navController, browsingModeManager = browsingModeManager, ) @@ -552,8 +619,10 @@ class BrowserToolbarMiddlewareTest { val clipboard = ClipboardHandler(testContext).also { it.text = "test" } - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - clipboard = clipboard, + val middleware = BrowserToolbarMiddleware(appStore, browserStore, clipboard, mockk()) + val toolbarStore = buildStore( + middleware = middleware, + navController = mockk(), browsingModeManager = browsingModeManager, ) @@ -576,9 +645,9 @@ class BrowserToolbarMiddlewareTest { every { fenixBrowserUseCases } returns browserUseCases } val selectedSearchEngine = appStore.state.searchState.selectedSearchEngine?.searchEngine - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - clipboard = clipboard, - useCases = useCases, + val middleware = BrowserToolbarMiddleware(appStore, browserStore, clipboard, useCases) + val toolbarStore = buildStore( + middleware = middleware, navController = navController, browsingModeManager = browsingModeManager, ) @@ -600,9 +669,8 @@ class BrowserToolbarMiddlewareTest { fun `WHEN the selected search engine changes THEN update the search selector`() { val appStore = AppStore() - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - appStore = appStore, - ) + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore(middleware) val newSearchEngine = SearchEngine("test", "Test", mock(), type = APPLICATION) appStore.dispatch(SearchEngineSelected(newSearchEngine, true)) @@ -625,12 +693,10 @@ class BrowserToolbarMiddlewareTest { ), ), ) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - appStore = appStore, - ) + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore(middleware) browserStore.dispatch(ApplicationSearchEnginesLoaded(listOf(otherSearchEngine))) - mainLooperRule.idle() assertNotEquals( appStore.state.searchState.selectedSearchEngine?.searchEngine, @@ -654,9 +720,8 @@ class BrowserToolbarMiddlewareTest { ), ) - val (middleware, _) = buildMiddlewareAndAddToStore( - browserStore = browserStore, - ) + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + buildStore(middleware) val action = middleware.buildHomeAction( action = HomeToolbarAction.TabCounter, @@ -667,14 +732,18 @@ class BrowserToolbarMiddlewareTest { testContext.getString(tabcounterR.string.mozac_tab_counter_open_tab_tray, 3), action.contentDescription, ) - assertFalse(action.showPrivacyMask) + assertEquals( + middleware.environment?.browsingModeManager?.mode == Private, + action.showPrivacyMask, + ) assertEquals(TabCounterClicked(Source.AddressBar), action.onClick) assertNotNull(action.onLongClick) } @Test fun `WHEN building Menu action THEN returns Menu ActionButton`() { - val (middleware, _) = buildMiddlewareAndAddToStore() + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + buildStore(middleware) val action = middleware.buildHomeAction( action = HomeToolbarAction.Menu, @@ -690,9 +759,8 @@ class BrowserToolbarMiddlewareTest { @Test fun `GIVEN the menu button is not highlighted WHEN a menu item is highlighted THEN highlight menu button`() = runTest { val appStore = AppStore() - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - appStore = appStore, - ) + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore(middleware) mainLooperRule.idle() val initialMenuButton = toolbarStore.state.displayState.browserActionsEnd[1] as ActionButtonRes @@ -715,9 +783,8 @@ class BrowserToolbarMiddlewareTest { supportedMenuNotifications = setOf(SupportedMenuNotifications.Downloads), ), ) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - appStore = appStore, - ) + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore(middleware) mainLooperRule.idle() val initialMenuButton = toolbarStore.state.displayState.browserActionsEnd[1] as ActionButtonRes @@ -740,9 +807,8 @@ class BrowserToolbarMiddlewareTest { supportedMenuNotifications = setOf(SupportedMenuNotifications.OpenInApp), ), ) - val (_, toolbarStore) = buildMiddlewareAndAddToStore( - appStore = appStore, - ) + val middleware = BrowserToolbarMiddleware(appStore, browserStore, mockk(), mockk()) + val toolbarStore = buildStore(middleware) mainLooperRule.idle() val menuButton = toolbarStore.state.displayState.browserActionsEnd[1] as ActionButtonRes @@ -755,7 +821,13 @@ class BrowserToolbarMiddlewareTest { every { testContext.settings().shouldUseExpandedToolbar } returns true every { testContext.settings().toolbarExpandedShortcutKey } returns ShortcutType.TRANSLATE - val (_, toolbarStore) = buildMiddlewareAndAddToStore() + val middleware = BrowserToolbarMiddleware( + appStore, + browserStore, + mockk(), + mockk(), + ) + val toolbarStore = buildStore(middleware) val translateButton = toolbarStore.state.displayState.navigationActions.first() as ActionButtonRes assertEquals(expectedTranslateButton, translateButton) @@ -767,7 +839,13 @@ class BrowserToolbarMiddlewareTest { every { testContext.settings().shouldUseExpandedToolbar } returns true every { testContext.settings().toolbarExpandedShortcutKey } returns ShortcutType.HOMEPAGE - val (_, toolbarStore) = buildMiddlewareAndAddToStore() + val middleware = BrowserToolbarMiddleware( + appStore, + browserStore, + mockk(), + mockk(), + ) + val toolbarStore = buildStore(middleware) val homepageButton = toolbarStore.state.displayState.navigationActions.first() as ActionButtonRes assertEquals(expectedHomepageButton, homepageButton) @@ -779,7 +857,13 @@ class BrowserToolbarMiddlewareTest { every { testContext.settings().shouldUseExpandedToolbar } returns true every { testContext.settings().toolbarExpandedShortcutKey } returns ShortcutType.BACK - val (_, toolbarStore) = buildMiddlewareAndAddToStore() + val middleware = BrowserToolbarMiddleware( + appStore, + browserStore, + mockk(), + mockk(), + ) + val toolbarStore = buildStore(middleware) val backButton = toolbarStore.state.displayState.navigationActions.first() as ActionButtonRes assertEquals(expectedBackButton, backButton) @@ -809,71 +893,24 @@ class BrowserToolbarMiddlewareTest { ) } - private fun buildMiddlewareAndAddToStore( - uiContext: Context = testContext, - appStore: AppStore = this.appStore, - browserStore: BrowserStore = this.browserStore, - clipboard: ClipboardHandler = mockk(), - useCases: UseCases = mockk(), - navController: NavController = mockk(), - browsingModeManager: BrowsingModeManager = this.browsingModeManager, - settings: Settings = testContext.settings(), - isWideScreen: () -> Boolean = { false }, - isTallScreen: () -> Boolean = { true }, - scope: CoroutineScope = MainScope(), - ): Pair<BrowserToolbarMiddleware, BrowserToolbarStore> { - val middleware = buildMiddleware( - uiContext = uiContext, - appStore = appStore, - browserStore = browserStore, - clipboard = clipboard, - useCases = useCases, - navController = navController, - browsingModeManager = browsingModeManager, - settings = settings, - isWideScreen = isWideScreen, - isTallScreen = isTallScreen, - scope = scope, - ) - val store = buildStore( - middleware = middleware, - ) - - return middleware to store - } - - private fun buildMiddleware( - uiContext: Context = testContext, - appStore: AppStore = this.appStore, - browserStore: BrowserStore = this.browserStore, - clipboard: ClipboardHandler = mockk(), - useCases: UseCases = mockk(), - navController: NavController = mockk(), - browsingModeManager: BrowsingModeManager = this.browsingModeManager, - settings: Settings = testContext.settings(), - isWideScreen: () -> Boolean = { false }, - isTallScreen: () -> Boolean = { true }, - scope: CoroutineScope = MainScope(), - ) = BrowserToolbarMiddleware( - uiContext = uiContext, - appStore = appStore, - browserStore = browserStore, - clipboard = clipboard, - useCases = useCases, - navController = navController, - browsingModeManager = browsingModeManager, - settings = settings, - isWideScreen = isWideScreen, - isTallScreen = isTallScreen, - scope = scope, - ) - private fun buildStore( middleware: BrowserToolbarMiddleware, + context: Context = testContext, + navController: NavController = mockk(), + browsingModeManager: BrowsingModeManager = this.browsingModeManager, ) = BrowserToolbarStore( middleware = listOf(middleware), ).also { - mainLooperRule.idle() // to complete the initial setup happening in coroutines + it.dispatch( + EnvironmentRehydrated( + BrowserToolbarEnvironment( + context = context, + fragment = fragment, + navController = navController, + browsingModeManager = browsingModeManager, + ), + ), + ) } private fun expectedSearchSelector( @@ -1002,4 +1039,10 @@ class BrowserToolbarMiddlewareTest { state = ActionButton.State.DISABLED, onClick = FakeClicked, ) + + private class FakeLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner { + override val lifecycle: Lifecycle = LifecycleRegistry(this).apply { + currentState = initialState + } + } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserStoreToFenixSearchMapperMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserStoreToFenixSearchMapperMiddlewareTest.kt @@ -4,26 +4,29 @@ package org.mozilla.fenix.search +import androidx.lifecycle.Lifecycle.State.RESUMED import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest import mozilla.components.browser.state.action.SearchAction.ApplicationSearchEnginesLoaded import mozilla.components.browser.state.search.SearchEngine import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.SearchState import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.support.test.robolectric.testContext import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith +import org.mozilla.fenix.helpers.lifecycle.TestLifecycleOwner +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentCleared +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentRehydrated import org.mozilla.fenix.search.fixtures.EMPTY_SEARCH_FRAGMENT_STATE import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class BrowserStoreToFenixSearchMapperMiddlewareTest { - @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `WHEN the browser search state changes THEN update the application search state`() = runTest(UnconfinedTestDispatcher()) { + fun `WHEN the browser search state changes THEN update the application search state`() { val defaultSearchEngine: SearchEngine = mockk() val newSearchEngines: List<SearchEngine> = listOf(defaultSearchEngine, mockk()) val browserStore = BrowserStore( @@ -33,7 +36,7 @@ class BrowserStoreToFenixSearchMapperMiddlewareTest { ), ), ) - val middleware = BrowserStoreToFenixSearchMapperMiddleware(browserStore, backgroundScope) + val middleware = BrowserStoreToFenixSearchMapperMiddleware(browserStore) val searchStore = buildStore(middleware) browserStore.dispatch(ApplicationSearchEnginesLoaded(newSearchEngines)) @@ -41,8 +44,31 @@ class BrowserStoreToFenixSearchMapperMiddlewareTest { assertEquals(defaultSearchEngine, searchStore.state.defaultEngine) } + @Test + fun `GIVEN an environment was already set WHEN it is cleared THEN reset it to null`() { + val middleware = BrowserStoreToFenixSearchMapperMiddleware(mockk(relaxed = true)) + val store = buildStore(middleware) + + assertNotNull(middleware.environment) + + store.dispatch(EnvironmentCleared) + + assertNull(middleware.environment) + } + private fun buildStore(middleware: BrowserStoreToFenixSearchMapperMiddleware) = SearchFragmentStore( initialState = EMPTY_SEARCH_FRAGMENT_STATE, middleware = listOf(middleware), - ) + ).also { + it.dispatch( + EnvironmentRehydrated( + SearchFragmentStore.Environment( + context = testContext, + viewLifecycleOwner = TestLifecycleOwner(RESUMED), + browsingModeManager = mockk(), + navController = mockk(), + ), + ), + ) + } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddlewareTest.kt @@ -4,8 +4,10 @@ package org.mozilla.fenix.search -import android.content.Context import android.os.Looper +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.LifecycleOwner import androidx.navigation.NavController import androidx.navigation.NavDirections import io.mockk.Runs @@ -18,9 +20,7 @@ import io.mockk.slot import io.mockk.spyk import io.mockk.verify import io.mockk.verifyOrder -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope import mozilla.components.browser.domains.autocomplete.BaseDomainAutocompleteProvider import mozilla.components.browser.state.action.AwesomeBarAction.EngagementFinished import mozilla.components.browser.state.action.SearchAction.ApplicationSearchEnginesLoaded @@ -40,6 +40,8 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.Ent import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.ExitEditMode import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.EngineSession @@ -47,16 +49,17 @@ import mozilla.components.concept.toolbar.AutocompleteProvider import mozilla.components.concept.toolbar.AutocompleteResult import mozilla.components.feature.awesomebar.provider.SessionAutocompleteProvider import mozilla.components.feature.syncedtabs.SyncedTabsAutocompleteProvider +import mozilla.components.support.test.ext.joinBlocking import mozilla.components.support.test.middleware.CaptureActionsMiddleware import mozilla.components.support.test.mock import mozilla.components.support.test.robolectric.testContext -import mozilla.components.support.test.rule.MainLooperTestRule import mozilla.telemetry.glean.testing.GleanTestRule import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -82,7 +85,9 @@ import org.mozilla.fenix.components.appstate.search.SelectedSearchEngine import org.mozilla.fenix.components.search.BOOKMARKS_SEARCH_ENGINE_ID import org.mozilla.fenix.components.search.HISTORY_SEARCH_ENGINE_ID import org.mozilla.fenix.components.search.TABS_SEARCH_ENGINE_ID +import org.mozilla.fenix.components.toolbar.BrowserToolbarEnvironment import org.mozilla.fenix.components.usecases.FenixBrowserUseCases +import org.mozilla.fenix.helpers.lifecycle.TestLifecycleOwner import org.mozilla.fenix.search.EditPageEndActionsInteractions.ClearSearchClicked import org.mozilla.fenix.search.EditPageEndActionsInteractions.QrScannerClicked import org.mozilla.fenix.search.EditPageEndActionsInteractions.VoiceSearchButtonClicked @@ -111,20 +116,38 @@ class BrowserToolbarSearchMiddlewareTest { @get:Rule val gleanTestRule = GleanTestRule(testContext) - @get:Rule - val mainLooperRule = MainLooperTestRule() - val appStore = AppStore() val browserStore: BrowserStore = mockk(relaxed = true) { every { state.search } returns fakeSearchState() } val components: Components = mockk() val settings: Settings = mockk(relaxed = true) + val lifecycleOwner: LifecycleOwner = TestLifecycleOwner(RESUMED) val navController: NavController = mockk { every { navigate(any<NavDirections>()) } just Runs every { navigate(any<Int>()) } just Runs } val browsingModeManager: BrowsingModeManager = mockk() + private lateinit var fragment: Fragment + + @Before + fun setup() { + fragment = spyk(Fragment()).apply { + every { context } returns testContext + } + every { fragment.getViewLifecycleOwner() } returns lifecycleOwner + } + + @Test + fun `GIVEN an environment was already set WHEN it is cleared THEN reset it to null`() { + val (middleware, store) = buildMiddlewareAndAddToStore() + + assertNotNull(middleware.environment) + + store.dispatch(EnvironmentCleared) + + assertNull(middleware.environment) + } @Test fun `WHEN the toolbar enters in edit mode THEN a new search selector button is added`() { @@ -309,7 +332,7 @@ class BrowserToolbarSearchMiddlewareTest { } every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() - val middleware = spyk(buildMiddleware()) + val middleware = spyk(buildMiddleware(appStore, browserStore, components, settings)) every { middleware.isSpeechRecognitionAvailable() } returns true val store = buildStore(middleware) val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() @@ -360,7 +383,7 @@ class BrowserToolbarSearchMiddlewareTest { } every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() - val middleware = spyk(buildMiddleware()) + val middleware = spyk(buildMiddleware(appStore, browserStore, components, settings)) val store = buildStore(middleware) val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() @@ -403,7 +426,7 @@ class BrowserToolbarSearchMiddlewareTest { } every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() - val middleware = spyk(buildMiddleware()) + val middleware = spyk(buildMiddleware(appStore, browserStore, components, settings)) val store = buildStore(middleware) val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() @@ -447,7 +470,7 @@ class BrowserToolbarSearchMiddlewareTest { } every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() - val middleware = spyk(buildMiddleware()) + val middleware = spyk(buildMiddleware(appStore, browserStore, components, settings)) val store = buildStore(middleware) val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() @@ -692,12 +715,12 @@ class BrowserToolbarSearchMiddlewareTest { ), ) val browserStore = BrowserStore() - val (_, store) = buildMiddlewareAndAddToStore(testContext, appStore, browserStore) + val (_, store) = buildMiddlewareAndAddToStore(appStore, browserStore) store.dispatch(EnterEditMode) val newSearchEngines = fakeSearchState().applicationSearchEngines browserStore.dispatch(ApplicationSearchEnginesLoaded(newSearchEngines)) - mainLooperRule.idle() + shadowOf(Looper.getMainLooper()).idle() // wait for observing and processing the search engines update assertSearchSelectorEquals( expectedSearchSelector(selectedSearchEngine, newSearchEngines), @@ -962,7 +985,7 @@ class BrowserToolbarSearchMiddlewareTest { store.dispatch(qrScannerButton.onClick as BrowserToolbarEvent) appStore.dispatch(QrScannerInputAvailable("mozilla.test")) - mainLooperRule.idle() + shadowOf(Looper.getMainLooper()).idle() // wait for observing and processing qr scan result assertEquals("mozilla.test", store.state.editState.query.current) appStoreActionsCaptor.assertLastAction(QrScannerInputConsumed::class) @@ -996,7 +1019,7 @@ class BrowserToolbarSearchMiddlewareTest { store.dispatch(qrScannerButton.onClick as BrowserToolbarEvent) appStore.dispatch(QrScannerInputAvailable("test.mozilla")) - mainLooperRule.idle() + shadowOf(Looper.getMainLooper()).idle() // wait for observing and processing qr scan result assertEquals("test.mozilla", store.state.editState.query.current) appStoreActionsCaptor.assertLastAction(QrScannerInputConsumed::class) @@ -1035,7 +1058,7 @@ class BrowserToolbarSearchMiddlewareTest { store.dispatch(qrScannerButton.onClick as BrowserToolbarEvent) appStore.dispatch(QrScannerInputAvailable("test.com")) - mainLooperRule.idle() + shadowOf(Looper.getMainLooper()).idle() // wait for observing and processing qr scan result assertEquals("test.com", store.state.editState.query.current) appStoreActionsCaptor.assertLastAction(QrScannerInputConsumed::class) @@ -1097,56 +1120,41 @@ class BrowserToolbarSearchMiddlewareTest { ) private fun buildMiddlewareAndAddToStore( - uiContext: Context = testContext, appStore: AppStore = this.appStore, browserStore: BrowserStore = this.browserStore, components: Components = this.components, + settings: Settings = this.settings, navController: NavController = this.navController, browsingModeManager: BrowsingModeManager = this.browsingModeManager, - settings: Settings = this.settings, - scope: CoroutineScope = MainScope(), ): Pair<BrowserToolbarSearchMiddleware, BrowserToolbarStore> { - val middleware = buildMiddleware( - uiContext = uiContext, - appStore = appStore, - browserStore = browserStore, - components = components, - navController = navController, - browsingModeManager = browsingModeManager, - settings = settings, - scope = scope, - ) - val store = buildStore(middleware) + val middleware = buildMiddleware(appStore, browserStore, components, settings) + val store = buildStore(middleware, navController, browsingModeManager) return middleware to store } private fun buildStore( middleware: BrowserToolbarSearchMiddleware = buildMiddleware(), + navController: NavController = this.navController, + browsingModeManager: BrowsingModeManager = this.browsingModeManager, ) = BrowserToolbarStore( - middleware = listOf(middleware), - ) + middleware = listOf(middleware), + ).also { + it.dispatch( + EnvironmentRehydrated( + BrowserToolbarEnvironment( + testContext, fragment, navController, browsingModeManager, + ), + ), + ) + } private fun buildMiddleware( - uiContext: Context = testContext, appStore: AppStore = this.appStore, browserStore: BrowserStore = this.browserStore, components: Components = this.components, - navController: NavController = this.navController, - browsingModeManager: BrowsingModeManager = this.browsingModeManager, settings: Settings = this.settings, - scope: CoroutineScope = MainScope(), - ) = BrowserToolbarSearchMiddleware( - uiContext = uiContext, - appStore = appStore, - browserStore = browserStore, - components = components, - navController = navController, - browsingModeManager = browsingModeManager, - settings = settings, - scope = scope, - autocompleteDispatcher = Dispatchers.Main, - ) + ) = BrowserToolbarSearchMiddleware(appStore, browserStore, components, settings, Dispatchers.Main) private fun configureAutocompleteProvidersInComponents() { val autocompleteSuggestion = AutocompleteResult( diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarSearchStatusSyncMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarSearchStatusSyncMiddlewareTest.kt @@ -4,17 +4,25 @@ package org.mozilla.fenix.search +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope +import io.mockk.spyk import kotlinx.coroutines.test.runTest import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.EnterEditMode import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.ExitEditMode import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared +import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated +import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.rule.MainLooperTestRule import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -23,6 +31,8 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.appstate.AppAction.SearchAction.SearchEnded import org.mozilla.fenix.components.appstate.AppAction.SearchAction.SearchStarted +import org.mozilla.fenix.components.toolbar.BrowserToolbarEnvironment +import org.mozilla.fenix.helpers.lifecycle.TestLifecycleOwner import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) @@ -32,7 +42,26 @@ class BrowserToolbarSearchStatusSyncMiddlewareTest { val mainLooperRule = MainLooperTestRule() private val appStore = AppStore() + private val lifecycleOwner: LifecycleOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) private val browsingModeManager: BrowsingModeManager = mockk(relaxed = true) + private lateinit var fragment: Fragment + + @Before + fun setup() { + fragment = spyk(Fragment()) + every { fragment.getViewLifecycleOwner() } returns lifecycleOwner + } + + @Test + fun `GIVEN an environment was already set WHEN it is cleared THEN reset it to null`() { + val (middleware, toolbarStore) = buildMiddlewareAndAddToSearchStore() + + assertNotNull(middleware.environment) + + toolbarStore.dispatch(EnvironmentCleared) + + assertNull(middleware.environment) + } @Test fun `WHEN the toolbar exits search mode THEN synchronize search being ended for the application`() = runTest { @@ -105,19 +134,26 @@ class BrowserToolbarSearchStatusSyncMiddlewareTest { private fun buildMiddlewareAndAddToSearchStore( appStore: AppStore = this.appStore, - browsingModeManager: BrowsingModeManager = this.browsingModeManager, - scope: CoroutineScope = MainScope(), ): Pair<BrowserToolbarSearchStatusSyncMiddleware, BrowserToolbarStore> { - val middleware = buildMiddleware(appStore, browsingModeManager, scope) + val middleware = buildMiddleware(appStore) val toolbarStore = BrowserToolbarStore( middleware = listOf(middleware), - ) + ).also { + it.dispatch( + EnvironmentRehydrated( + BrowserToolbarEnvironment( + context = testContext, + navController = mockk(), + fragment = fragment, + browsingModeManager = browsingModeManager, + ), + ), + ) + } return middleware to toolbarStore } private fun buildMiddleware( appStore: AppStore = this.appStore, - browsingModeManager: BrowsingModeManager = this.browsingModeManager, - scope: CoroutineScope = MainScope(), - ) = BrowserToolbarSearchStatusSyncMiddleware(appStore, browsingModeManager, scope) + ) = BrowserToolbarSearchStatusSyncMiddleware(appStore) } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddlewareTest.kt @@ -4,41 +4,33 @@ package org.mozilla.fenix.search +import androidx.lifecycle.Lifecycle import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import mozilla.components.browser.state.selector.selectedTab -import mozilla.components.browser.state.state.BrowserState -import mozilla.components.browser.state.state.createTab -import mozilla.components.browser.state.store.BrowserStore import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchQueryUpdated import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.EnterEditMode import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.lib.state.Middleware import mozilla.components.support.test.middleware.CaptureActionsMiddleware -import mozilla.components.support.test.rule.MainLooperTestRule +import mozilla.components.support.test.robolectric.testContext import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager +import org.mozilla.fenix.helpers.lifecycle.TestLifecycleOwner +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentCleared +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentRehydrated import org.mozilla.fenix.search.SearchFragmentAction.SearchStarted import org.mozilla.fenix.search.fixtures.EMPTY_SEARCH_FRAGMENT_STATE import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class BrowserToolbarToFenixSearchMapperMiddlewareTest { - @get:Rule - val mainLooperRule = MainLooperTestRule() - val toolbarStore = BrowserToolbarStore() private val browsingModeManager: BrowsingModeManager = mockk { every { mode } returns BrowsingMode.Private @@ -51,7 +43,6 @@ class BrowserToolbarToFenixSearchMapperMiddlewareTest { val searchStore = buildSearchStore(listOf(searchStatusMapperMiddleware, captorMiddleware)) toolbarStore.dispatch(EnterEditMode) - mainLooperRule.idle() captorMiddleware.assertLastAction(SearchStarted::class) { assertNull(it.selectedSearchEngine) @@ -60,55 +51,52 @@ class BrowserToolbarToFenixSearchMapperMiddlewareTest { } @Test + fun `GIVEN an environment was already set WHEN it is cleared THEN reset it to null`() { + val searchStatusMapperMiddleware = buildMiddleware() + val searchStore = buildSearchStore(listOf(searchStatusMapperMiddleware)) + + assertNotNull(searchStatusMapperMiddleware.environment) + + searchStore.dispatch(EnvironmentCleared) + + assertNull(searchStatusMapperMiddleware.environment) + } + + @Test fun `GIVEN search was started WHEN there's a new query in the toolbar THEN update the search state`() { val searchStore = buildSearchStore(listOf(buildMiddleware())) toolbarStore.dispatch(EnterEditMode) searchStore.dispatch(SearchStarted(mockk(), false, false, searchStartedForCurrentUrl = false)) - mainLooperRule.idle() toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("t"))) - mainLooperRule.idle() assertEquals("t", searchStore.state.query) toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("te"))) - mainLooperRule.idle() assertEquals("te", searchStore.state.query) toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("tes"))) - mainLooperRule.idle() assertEquals("tes", searchStore.state.query) toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) - mainLooperRule.idle() assertEquals("test", searchStore.state.query) } @Test fun `GIVEN search was started for the current URL WHEN there's a new query in the toolbar THEN don't update the search state`() { - val currentTab = createTab("https://mozilla.org") - val browserStore = BrowserStore( - BrowserState( - tabs = listOf(currentTab), - selectedTabId = currentTab.id, - ), - ) - val searchStore = buildSearchStore(listOf(buildMiddleware(browserStore = browserStore))) + val searchStore = buildSearchStore(listOf(buildMiddleware())) toolbarStore.dispatch(EnterEditMode) + searchStore.dispatch(SearchStarted(mockk(), false, false, searchStartedForCurrentUrl = true)) toolbarStore.dispatch( SearchQueryUpdated(BrowserToolbarQuery("https://mozilla.org"), isQueryPrefilled = true), ) - searchStore.dispatch(SearchStarted(mockk(), false, false, searchStartedForCurrentUrl = true)) - mainLooperRule.idle() assertEquals("", searchStore.state.query) toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("t"))) - mainLooperRule.idle() assertEquals("t", searchStore.state.query) toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("https://mozilla.org"))) - mainLooperRule.idle() assertEquals("https://mozilla.org", searchStore.state.query) } @@ -117,14 +105,22 @@ class BrowserToolbarToFenixSearchMapperMiddlewareTest { ) = SearchFragmentStore( initialState = emptySearchState, middleware = middlewares, - ) + ).also { + it.dispatch( + EnvironmentRehydrated( + SearchFragmentStore.Environment( + context = testContext, + viewLifecycleOwner = TestLifecycleOwner(Lifecycle.State.RESUMED), + browsingModeManager = browsingModeManager, + navController = mockk(), + ), + ), + ) + } private fun buildMiddleware( toolbarStore: BrowserToolbarStore = this.toolbarStore, - browsingModeManager: BrowsingModeManager = this.browsingModeManager, - scope: CoroutineScope = MainScope(), - browserStore: BrowserStore? = null, - ) = BrowserToolbarToFenixSearchMapperMiddleware(toolbarStore, browsingModeManager, scope, browserStore) + ) = BrowserToolbarToFenixSearchMapperMiddleware(toolbarStore) private val emptySearchState = EMPTY_SEARCH_FRAGMENT_STATE.copy( searchEngineSource = mockk(), diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/FenixSearchMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/FenixSearchMiddlewareTest.kt @@ -4,7 +4,8 @@ package org.mozilla.fenix.search -import android.content.Context +import android.os.Looper +import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.navigation.NavController import androidx.navigation.NavDirections import io.mockk.Runs @@ -13,9 +14,6 @@ import io.mockk.just import io.mockk.mockk import io.mockk.spyk import io.mockk.verify -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.test.runTest import mozilla.components.browser.state.action.AwesomeBarAction import mozilla.components.browser.state.action.AwesomeBarAction.EngagementFinished import mozilla.components.browser.state.action.BrowserAction @@ -37,7 +35,6 @@ import mozilla.components.lib.state.MiddlewareContext import mozilla.components.lib.state.Store import mozilla.components.support.test.middleware.CaptureActionsMiddleware import mozilla.components.support.test.robolectric.testContext -import mozilla.components.support.test.rule.MainLooperTestRule import mozilla.telemetry.glean.testing.GleanTestRule import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -65,14 +62,18 @@ import org.mozilla.fenix.components.appstate.search.SelectedSearchEngine import org.mozilla.fenix.components.search.BOOKMARKS_SEARCH_ENGINE_ID import org.mozilla.fenix.components.usecases.FenixBrowserUseCases import org.mozilla.fenix.ext.telemetryName +import org.mozilla.fenix.helpers.lifecycle.TestLifecycleOwner import org.mozilla.fenix.search.SearchEngineSource.Bookmarks import org.mozilla.fenix.search.SearchEngineSource.Shortcut +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentCleared +import org.mozilla.fenix.search.SearchFragmentAction.EnvironmentRehydrated import org.mozilla.fenix.search.SearchFragmentAction.SearchProvidersUpdated import org.mozilla.fenix.search.SearchFragmentAction.SearchShortcutEngineSelected import org.mozilla.fenix.search.SearchFragmentAction.SearchStarted import org.mozilla.fenix.search.SearchFragmentAction.SearchSuggestionsVisibilityUpdated import org.mozilla.fenix.search.SearchFragmentAction.SuggestionClicked import org.mozilla.fenix.search.SearchFragmentAction.SuggestionSelected +import org.mozilla.fenix.search.SearchFragmentStore.Environment import org.mozilla.fenix.search.awesomebar.SearchSuggestionsProvidersBuilder import org.mozilla.fenix.search.fixtures.EMPTY_SEARCH_FRAGMENT_STATE import org.mozilla.fenix.telemetry.ACTION_SEARCH_ENGINE_SELECTED @@ -87,9 +88,6 @@ class FenixSearchMiddlewareTest { @get:Rule val gleanTestRule = GleanTestRule(testContext) - @get:Rule - val mainLooperRule = MainLooperTestRule() - private val engine: Engine = mockk { every { speculativeCreateSession(any(), any()) } just Runs } @@ -257,7 +255,6 @@ class FenixSearchMiddlewareTest { val defaultSearchEngine = fakeSearchEnginesState().selectedOrDefaultSearchEngine store.dispatch(SearchStarted(defaultSearchEngine, false, false, true)) - mainLooperRule.idle() searchActionsCaptor.assertLastAction(SearchSuggestionsVisibilityUpdated::class) { assertTrue(it.visible) @@ -312,7 +309,6 @@ class FenixSearchMiddlewareTest { every { middleware.buildSearchSuggestionsProvider(any()) } returns expectedSearchSuggestionsProvider store.dispatch(SearchStarted(null, false, false, false)) // this triggers observing the search engine updates - mainLooperRule.idle() searchActionsCaptor.assertLastAction(SearchShortcutEngineSelected::class) { assertEquals(newSearchEngineSelection, it.engine) @@ -323,7 +319,7 @@ class FenixSearchMiddlewareTest { } @Test - fun `WHEN needing to load an URL THEN open it in browser, record search ended and record telemetry`() { + fun `When needing to load an URL THEN open it in browser, record search ended and record telemetry`() { val url = "https://mozilla.com" val flags = LoadUrlFlags.all() every { settings.enableHomepageAsNewTab } returns true @@ -351,14 +347,14 @@ class FenixSearchMiddlewareTest { } @Test - fun `WHEN needing to search for specific terms THEN open them in browser, record search ended and record telemetry`() = runTest { + fun `WHEN needing to search for specific terms THEN open them in browser, record search ended and record telemetry`() { val searchTerm = "test" every { settings.enableHomepageAsNewTab } returns true val nimbusEventsStore: NimbusEventStore = mockk { every { recordEvent(any()) } just Runs } every { nimbusComponents.events } returns nimbusEventsStore - val middleware = buildMiddleware() + val middleware = buildMiddleware(nimbusComponents = nimbusComponents) val store = buildStore(middleware) val context = buildContext(store) @@ -416,7 +412,7 @@ class FenixSearchMiddlewareTest { appStore.dispatch(AppAction.SearchAction.SearchStarted()) store.dispatch(SearchStarted(defaultSearchEngine, false, false, false)) appStore.dispatch(SearchEngineSelected(searchEngineClicked, true)) - mainLooperRule.idle() + shadowOf(Looper.getMainLooper()).idle() assertEquals(Bookmarks(searchEngineClicked), store.state.searchEngineSource) assertNotNull(store.state.defaultEngine) @@ -491,6 +487,18 @@ class FenixSearchMiddlewareTest { verify { toolbarStore.dispatch(BrowserEditToolbarAction.SearchQueryUpdated(BrowserToolbarQuery("test"))) } } + @Test + fun `GIVEN an environment was already set WHEN it is cleared THEN reset it to null and clear search suggestions providers`() { + val (middleware, store) = buildMiddlewareAndAddToSearchStore() + + assertNotNull(middleware.environment) + + store.dispatch(EnvironmentCleared) + + assertNull(middleware.environment) + assertEquals(emptyList<SuggestionProvider>(), store.state.searchSuggestionsProviders) + } + private fun buildMiddlewareAndAddToSearchStore( engine: Engine = this.engine, useCases: UseCases = this.useCases, @@ -500,13 +508,7 @@ class FenixSearchMiddlewareTest { toolbarStore: BrowserToolbarStore = this.toolbarStore, ): Pair<FenixSearchMiddleware, SearchFragmentStore> { val middleware = buildMiddleware( - engine = engine, - useCases = useCases, - nimbusComponents = nimbusComponents, - settings = settings, - appStore = appStore, - browserStore = browserStore, - toolbarStore = toolbarStore, + engine, useCases, nimbusComponents, settings, appStore, browserStore, toolbarStore, ) every { middleware.buildSearchSuggestionsProvider(any()) } returns mockk(relaxed = true) @@ -516,7 +518,6 @@ class FenixSearchMiddlewareTest { } private fun buildMiddleware( - uiContext: Context = testContext, engine: Engine = this.engine, useCases: UseCases = this.useCases, nimbusComponents: NimbusComponents = this.nimbusComponents, @@ -524,13 +525,9 @@ class FenixSearchMiddlewareTest { appStore: AppStore = this.appStore, browserStore: BrowserStore = this.browserStore, toolbarStore: BrowserToolbarStore = this.toolbarStore, - navController: NavController = this.navController, - browsingModeManager: BrowsingModeManager = this.browsingModeManager, - scope: CoroutineScope = MainScope(), ): FenixSearchMiddleware { val middleware = spyk( FenixSearchMiddleware( - uiContext = uiContext, engine = engine, useCases = useCases, nimbusComponents = nimbusComponents, @@ -538,9 +535,6 @@ class FenixSearchMiddlewareTest { appStore = appStore, browserStore = browserStore, toolbarStore = toolbarStore, - navController = navController, - browsingModeManager = browsingModeManager, - scope = scope, ), ) every { middleware.buildSearchSuggestionsProvider(any()) } returns mockk(relaxed = true) @@ -553,7 +547,18 @@ class FenixSearchMiddlewareTest { ) = SearchFragmentStore( initialState = buildEmptySearchState(), middleware = listOf(middleware, searchActionsCaptor), - ) + ).also { + it.dispatch( + EnvironmentRehydrated( + Environment( + context = testContext, + viewLifecycleOwner = TestLifecycleOwner(RESUMED), + browsingModeManager = browsingModeManager, + navController = navController, + ), + ), + ) + } private fun buildContext( store: SearchFragmentStore, diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddlewareTest.kt @@ -15,8 +15,6 @@ import io.mockk.every import io.mockk.mockk import io.mockk.spyk import io.mockk.verify -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import mozilla.components.support.test.robolectric.testContext @@ -49,25 +47,31 @@ class SettingsSearchMiddlewareTest { every { fragment.viewLifecycleOwner } returns lifecycleOwner } - private fun buildMiddleware( - fenixSettingsIndexer: SettingsIndexer = TestSettingsIndexer(), - navController: NavController = this.navController, - recentSettingsSearchesRepository: RecentSettingsSearchesRepository = this.recentSearchesRepository, - scope: CoroutineScope = coroutineRule.scope, - dispatcher: CoroutineDispatcher = coroutineRule.testDispatcher, - ) = SettingsSearchMiddleware( - fenixSettingsIndexer = fenixSettingsIndexer, - navController = navController, - recentSettingsSearchesRepository = recentSettingsSearchesRepository, - scope = scope, - dispatcher = dispatcher, - ) + private fun buildMiddleware(): SettingsSearchMiddleware { + return SettingsSearchMiddleware( + fenixSettingsIndexer = TestSettingsIndexer(), + dispatcher = coroutineRule.testDispatcher, + ) + } @Test fun `WHEN the settings search query is updated and results are not found THEN the state is updated`() { - val middleware = buildMiddleware(EmptyTestSettingsIndexer()) + val middleware = SettingsSearchMiddleware( + fenixSettingsIndexer = EmptyTestSettingsIndexer(), + dispatcher = coroutineRule.testDispatcher, + ) val query = "longSample" val store = SettingsSearchStore(middleware = listOf(middleware)) + store.dispatch( + SettingsSearchAction.EnvironmentRehydrated( + environment = SettingsSearchEnvironment( + fragment = fragment, + navController = navController, + context = testContext, + recentSettingsSearchesRepository = recentSearchesRepository, + ), + ), + ) store.dispatch(SettingsSearchAction.SearchQueryUpdated(query)) assert(store.state is SettingsSearchState.NoSearchResults) assert(store.state.searchQuery == query) @@ -80,6 +84,16 @@ class SettingsSearchMiddlewareTest { val query = "a" val store = SettingsSearchStore(middleware = listOf(middleware)) store.dispatch(SettingsSearchAction.Init) + store.dispatch( + SettingsSearchAction.EnvironmentRehydrated( + environment = SettingsSearchEnvironment( + fragment = fragment, + navController = navController, + context = testContext, + recentSettingsSearchesRepository = recentSearchesRepository, + ), + ), + ) store.dispatch(SettingsSearchAction.SearchQueryUpdated(query)) assert(store.state is SettingsSearchState.SearchInProgress) assert(store.state.searchQuery == query) @@ -92,6 +106,16 @@ class SettingsSearchMiddlewareTest { val testItem = testList.first() store.dispatch(SettingsSearchAction.Init) + store.dispatch( + SettingsSearchAction.EnvironmentRehydrated( + environment = SettingsSearchEnvironment( + fragment = fragment, + navController = navController, + context = testContext, + recentSettingsSearchesRepository = recentSearchesRepository, + ), + ), + ) store.dispatch(SettingsSearchAction.ResultItemClicked(testItem)) @@ -104,6 +128,16 @@ class SettingsSearchMiddlewareTest { val middleware = buildMiddleware() val store = SettingsSearchStore(middleware = listOf(middleware)) val updatedRecents = listOf(testList.first()) + store.dispatch( + SettingsSearchAction.EnvironmentRehydrated( + environment = SettingsSearchEnvironment( + fragment = fragment, + navController = navController, + context = testContext, + recentSettingsSearchesRepository = recentSearchesRepository, + ), + ), + ) store.dispatch(SettingsSearchAction.RecentSearchesUpdated(updatedRecents)) @@ -114,6 +148,16 @@ class SettingsSearchMiddlewareTest { fun `WHEN ClearRecentSearchesClicked is dispatched THEN store state is updated correctly`() { val middleware = buildMiddleware() val store = SettingsSearchStore(middleware = listOf(middleware)) + store.dispatch( + SettingsSearchAction.EnvironmentRehydrated( + environment = SettingsSearchEnvironment( + fragment = fragment, + navController = navController, + context = testContext, + recentSettingsSearchesRepository = recentSearchesRepository, + ), + ), + ) store.dispatch(SettingsSearchAction.ClearRecentSearchesClicked) diff --git a/mobile/android/fenix/docs/architectureexample/HistoryFragmentExample.kt b/mobile/android/fenix/docs/architectureexample/HistoryFragmentExample.kt @@ -6,15 +6,17 @@ // /docs/architecture-overview.md class HistoryFragment : Fragment() { - private val store by storeFragment(HistoryState.initial) { restoredState -> - HistoryStore( - initialState = restoredState, - middleware = listOf( - HistoryNavigationMiddleware(findNavController()) - HistoryStorageMiddleware(HistoryStorage()), - HistoryTelemetryMiddleware(), + private val store by lazy { + StoreProvider.get(this) { + HistoryStore( + initialState = HistoryState.initial, + middleware = listOf( + HistoryNavigationMiddleware(findNavController()) + HistoryStorageMiddleware(HistoryStorage()), + HistoryTelemetryMiddleware(), + ) ) - ) + } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {