commit 3b5c7dc57eb533cbc766727df31998be67ae9808 parent d92d2a6aaa1ed6d67663473fe255060992621c88 Author: Harrison Oglesby <oglesby.harrison@gmail.com> Date: Tue, 4 Nov 2025 23:44:42 +0000 Bug 1994279 - Part 1: Recent Searches in Settings Search r=android-reviewers,petru Differential Revision: https://phabricator.services.mozilla.com/D270228 Diffstat:
12 files changed, 644 insertions(+), 75 deletions(-)
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/FenixRecentSettingsSearchesRepository.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/FenixRecentSettingsSearchesRepository.kt @@ -0,0 +1,84 @@ +/* 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.datastore.core.DataStore +import androidx.datastore.dataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.mozilla.fenix.settings.datastore.RecentSettingsSearchItem +import org.mozilla.fenix.settings.datastore.RecentSettingsSearches +import org.mozilla.fenix.settings.settingssearch.DefaultFenixSettingsIndexer.Companion.preferenceFileInformationList + +private val Context.recentSearchesDataStore: DataStore<RecentSettingsSearches> by dataStore( + fileName = "recent_searches.pb", + serializer = RecentSettingsSearchesSerializer, +) + +/** + * Repository for recent searches. + * + * @param context The application context. + */ +class FenixRecentSettingsSearchesRepository( + private val context: Context, +) : RecentSettingsSearchesRepository { + + override val recentSearches: Flow<List<SettingsSearchItem>> = + context.recentSearchesDataStore.data.map { protoResult -> + protoResult.itemsList.mapNotNull { protoItem -> + val prefInfo = preferenceFileInformationList.find { + it.xmlResourceId == protoItem.xmlResourceId + } ?: return@mapNotNull null + + SettingsSearchItem( + preferenceKey = protoItem.preferenceKey, + title = protoItem.title, + summary = protoItem.summary, + breadcrumbs = protoItem.breadcrumbsList, + preferenceFileInformation = prefInfo, + ) + } + } + + /** + * Adds a new recent search item to the repository. + * + * @param item The [SettingsSearchItem] to add. + */ + override suspend fun addRecentSearchItem(item: SettingsSearchItem) { + context.recentSearchesDataStore.updateData { currentRecents -> + val currentItems = currentRecents.itemsList.toMutableList() + + currentItems.removeIf { it.preferenceKey == item.preferenceKey } + + val newProtoItem = RecentSettingsSearchItem.newBuilder() + .setPreferenceKey(item.preferenceKey) + .setTitle(item.title) + .setSummary(item.summary) + .addAllBreadcrumbs(item.breadcrumbs) + .setXmlResourceId(item.preferenceFileInformation.xmlResourceId) + .build() + currentItems.add(0, newProtoItem) + + val updatedItems = currentItems.take(MAX_RECENTS) + currentRecents.toBuilder().clearItems().addAllItems(updatedItems).build() + } + } + + /** + * Clears all recent search items from the repository. + */ + override suspend fun clearRecentSearches() { + context.recentSearchesDataStore.updateData { + it.toBuilder().clearItems().build() + } + } + + companion object { + private const val MAX_RECENTS = 5 + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/RecentSettingsSearchesRepository.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/RecentSettingsSearchesRepository.kt @@ -0,0 +1,31 @@ +/* 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 kotlinx.coroutines.flow.Flow + +/** + * A repository for recent search items. + */ +interface RecentSettingsSearchesRepository { + + /** + * A flow that emits the list of recent search items whenever it changes. + */ + val recentSearches: Flow<List<SettingsSearchItem>> + + /** + * Adds a [SettingsSearchItem] to the list of recent search items. + * If the item already exits, it is moved to the top. + * + * @param item The item to add. + */ + suspend fun addRecentSearchItem(item: SettingsSearchItem) + + /** + * Clears the list of recent search items. + */ + suspend fun clearRecentSearches() +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/RecentSettingsSearchesSerializer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/RecentSettingsSearchesSerializer.kt @@ -0,0 +1,29 @@ +/* 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 androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import org.mozilla.fenix.settings.datastore.RecentSettingsSearches +import java.io.InputStream +import java.io.OutputStream + +/** + * DataStore serializer for Recent Settings Searches. + */ +object RecentSettingsSearchesSerializer : Serializer<RecentSettingsSearches> { + override val defaultValue: RecentSettingsSearches = RecentSettingsSearches.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): RecentSettingsSearches { + try { + return RecentSettingsSearches.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo(t: RecentSettingsSearches, output: OutputStream) = t.writeTo(output) +} 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 @@ -10,6 +10,24 @@ import mozilla.components.lib.state.Action * Actions for the settings search screen. */ sealed interface SettingsSearchAction : Action { + + /** + * User has started a search. + */ + 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 + /** * User has updated the search query in the search bar. * @@ -41,4 +59,11 @@ sealed interface SettingsSearchAction : Action { * @property item [SettingsSearchItem] that was clicked. */ data class ResultItemClicked(val item: SettingsSearchItem) : SettingsSearchAction + + /** + * Recent Searches have been updated. + * + * @property recentSearches List of [SettingsSearchItem]s that represent the recent searches. + */ + data class RecentSearchesUpdated(val recentSearches: List<SettingsSearchItem>) : 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 @@ -14,6 +14,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.ExperimentalMaterial3Api import androidx.fragment.app.Fragment import androidx.fragment.compose.content +import androidx.lifecycle.DefaultLifecycleObserver import androidx.navigation.fragment.findNavController import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.ext.components @@ -49,18 +50,36 @@ class SettingsSearchFragment : Fragment() { } private fun buildSettingsSearchStore(): SettingsSearchStore { + val recentSettingsSearchesRepository = FenixRecentSettingsSearchesRepository(requireContext()) + return StoreProvider.get(this) { SettingsSearchStore( - initialState = SettingsSearchState.Default, + initialState = SettingsSearchState.Default(emptyList()), middleware = listOf( SettingsSearchMiddleware( - SettingsSearchMiddleware.Companion.Dependencies( - navController = findNavController(), - ), fenixSettingsIndexer = requireContext().components.settingsIndexer, ), ), ) + }.also { + it.dispatch( + SettingsSearchAction.EnvironmentRehydrated( + environment = SettingsSearchEnvironment( + fragment = this, + navController = findNavController(), + context = requireContext(), + recentSettingsSearchesRepository = recentSettingsSearchesRepository, + ), + ), + ) + + 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,10 @@ 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 import kotlinx.coroutines.launch @@ -15,18 +18,14 @@ import mozilla.components.lib.state.MiddlewareContext /** * [Middleware] for the settings search screen. * - * @param initialDependencies [Dependencies] to use for navigation. * @property fenixSettingsIndexer [SettingsIndexer] to use for indexing and querying settings. + * @property dispatcher [CoroutineDispatcher] to use for performing background tasks. */ class SettingsSearchMiddleware( - initialDependencies: Dependencies, val fenixSettingsIndexer: SettingsIndexer, + val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : Middleware<SettingsSearchState, SettingsSearchAction> { - var dependencies = initialDependencies - - init { - fenixSettingsIndexer.indexAllSettings() - } + internal var environment: SettingsSearchEnvironment? = null override fun invoke( context: MiddlewareContext<SettingsSearchState, SettingsSearchAction>, @@ -35,9 +34,24 @@ class SettingsSearchMiddleware( ) { val store = context.store as SettingsSearchStore when (action) { + is SettingsSearchAction.Init -> { + next(action) + fenixSettingsIndexer.indexAllSettings() + } + 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) - CoroutineScope(Dispatchers.Main).launch { + CoroutineScope(dispatcher).launch { val results = fenixSettingsIndexer.getSettingsWithQuery(action.query) if (results.isEmpty()) { store.dispatch(SettingsSearchAction.NoResultsFound(action.query)) @@ -58,10 +72,15 @@ class SettingsSearchMiddleware( "search_in_progress" to true, ) val fragmentId = searchItem.preferenceFileInformation.fragmentId + CoroutineScope(dispatcher).launch { + environment?.recentSettingsSearchesRepository?.addRecentSearchItem(searchItem) + } CoroutineScope(Dispatchers.Main).launch { - dependencies.navController.navigate(fragmentId, bundle) + environment?.navController?.navigate(fragmentId, bundle) } + next(action) } + else -> { next(action) // no op in middleware layer @@ -69,9 +88,20 @@ class SettingsSearchMiddleware( } } - companion object { - data class Dependencies( - val navController: NavController, - ) + /** + * Observes the recent searches repository and updates the store when the list of recent searches changes. + * + * @param store The [SettingsSearchStore] to dispatch the updates to. + */ + private fun observeRecentSearches(store: SettingsSearchStore) { + 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/SettingsSearchScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchScreen.kt @@ -53,11 +53,21 @@ fun SettingsSearchScreen( when (state) { is SettingsSearchState.Default -> { - SettingsSearchMessageContent( - modifier = Modifier - .padding(top = topPadding) - .fillMaxSize(), - ) + if (state.recentSearches.isNotEmpty()) { + SearchResults( + store = store, + searchItems = state.recentSearches, + modifier = Modifier + .padding(top = topPadding) + .fillMaxSize(), + ) + } else { + SettingsSearchMessageContent( + modifier = Modifier + .padding(top = topPadding) + .fillMaxSize(), + ) + } } is SettingsSearchState.NoSearchResults -> { SettingsSearchMessageContent( @@ -68,29 +78,13 @@ fun SettingsSearchScreen( ) } is SettingsSearchState.SearchInProgress -> { - LazyColumn( + SearchResults( + store = store, + searchItems = state.searchResults, modifier = Modifier .padding(top = topPadding) .fillMaxSize(), - ) { - items(state.searchResults.size) { index -> - val settingsSearchItem = state.searchResults[index] - if (index != 0) { - HorizontalDivider() - } - SettingsSearchResultItem( - item = settingsSearchItem, - query = state.searchQuery, - onClick = { - store.dispatch( - SettingsSearchAction.ResultItemClicked( - settingsSearchItem, - ), - ) - }, - ) - } - } + ) } } } @@ -122,6 +116,37 @@ private fun SettingsSearchMessageContent( } } +@Composable +private fun SearchResults( + store: SettingsSearchStore, + searchItems: List<SettingsSearchItem>, + modifier: Modifier = Modifier, +) { + val state by store.observeAsComposableState { it } + + LazyColumn( + modifier = modifier, + ) { + items(searchItems.size) { index -> + val searchItem = searchItems[index] + if (index > 0) { + HorizontalDivider() + } + SettingsSearchResultItem( + item = searchItem, + query = state.searchQuery, + onClick = { + store.dispatch( + SettingsSearchAction.ResultItemClicked( + searchItem, + ), + ) + }, + ) + } + } +} + /** * Preview for the settings search screen initial state. */ @@ -135,3 +160,100 @@ private fun SettingsSearchScreenInitialStatePreview() { ) } } + +/** + * Preview for the settings search screen displaying a list of recent searches. + */ +@PreviewLightDark +@Composable +private fun SettingsSearchScreenWithRecentsPreview() { + val storeWithRecents = SettingsSearchStore( + initialState = SettingsSearchState.Default( + recentSearches = listOf( + SettingsSearchItem( + "Search engine", + "Choose your default", + "search_engine", + listOf("General"), + PreferenceFileInformation.SearchSettingsPreferences, + ), + SettingsSearchItem( + "Delete browsing data", + "Clear history, cookies, and more", + "delete_browsing_data", + listOf("Privacy"), + PreferenceFileInformation.PrivateBrowsingPreferences, + ), + ), + ), + ) + FirefoxTheme { + SettingsSearchScreen( + store = storeWithRecents, + onBackClick = {}, + ) + } +} + +/** + * Preview for the settings search screen displaying search results. + */ +@PreviewLightDark +@Composable +private fun SettingsSearchScreenWithResultsPreview() { + val storeWithResults = SettingsSearchStore( + initialState = SettingsSearchState.SearchInProgress( + searchQuery = "privacy", + searchResults = listOf( + SettingsSearchItem( + "Tracking Protection", + "Strict, Standard, or Custom", + "tracking_protection", + listOf("Privacy"), + PreferenceFileInformation.GeneralPreferences, + ), + SettingsSearchItem( + "Delete browsing data", + "Clear history, cookies, and more", + "delete_browsing_data", + listOf("Privacy"), + PreferenceFileInformation.GeneralPreferences, + ), + SettingsSearchItem( + "HTTPS-Only Mode", + "Enable in all tabs", + "https_only_mode", + listOf("Privacy", "Advanced"), + PreferenceFileInformation.GeneralPreferences, + ), + ), + recentSearches = emptyList(), + ), + ) + FirefoxTheme { + SettingsSearchScreen( + store = storeWithResults, + onBackClick = {}, + ) + } +} + +/** + * Preview for the settings search screen when no results are found. + */ +@PreviewLightDark +@Composable +private fun SettingsSearchScreenNoResultsPreview() { + val storeWithNoResults = SettingsSearchStore( + initialState = SettingsSearchState.NoSearchResults( + searchQuery = "nonexistent query", + recentSearches = emptyList(), + ), + ) + FirefoxTheme { + SettingsSearchScreen( + store = storeWithNoResults, + onBackClick = {}, + ) + } +} 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 @@ -6,7 +6,7 @@ package org.mozilla.fenix.settings.settingssearch import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.State -import mozilla.components.lib.state.Store +import mozilla.components.lib.state.UiStore /** * Store for the settings search screen. @@ -15,13 +15,17 @@ import mozilla.components.lib.state.Store * @param middleware List of [Middleware] to apply to the store. */ class SettingsSearchStore( - initialState: SettingsSearchState = SettingsSearchState.Default, + initialState: SettingsSearchState = SettingsSearchState.Default(emptyList()), middleware: List<Middleware<SettingsSearchState, SettingsSearchAction>> = emptyList(), -) : Store<SettingsSearchState, SettingsSearchAction>( +) : UiStore<SettingsSearchState, SettingsSearchAction>( initialState = initialState, reducer = ::reduce, middleware = middleware, -) +) { + init { + dispatch(SettingsSearchAction.Init) + } +} /** * Reducer for the settings search screen. @@ -30,20 +34,26 @@ private fun reduce(state: SettingsSearchState, action: SettingsSearchAction): Se return when (action) { is SettingsSearchAction.SearchQueryUpdated -> { if (action.query.isBlank()) { - SettingsSearchState.Default + SettingsSearchState.Default( + recentSearches = state.recentSearches, + ) } else { SettingsSearchState.SearchInProgress( searchQuery = action.query, searchResults = state.searchResults, + recentSearches = state.recentSearches, ) } } is SettingsSearchAction.NoResultsFound -> { if (action.query.isBlank()) { - SettingsSearchState.Default + SettingsSearchState.Default( + recentSearches = state.recentSearches, + ) } else { SettingsSearchState.NoSearchResults( searchQuery = action.query, + recentSearches = state.recentSearches, ) } } @@ -51,9 +61,17 @@ private fun reduce(state: SettingsSearchState, action: SettingsSearchAction): Se SettingsSearchState.SearchInProgress( searchQuery = action.query, searchResults = action.results, + recentSearches = state.recentSearches, ) } - is SettingsSearchAction.ResultItemClicked -> state + is SettingsSearchAction.RecentSearchesUpdated -> { + state.copyWith(recentSearches = action.recentSearches) + } + is SettingsSearchAction.Init, + is SettingsSearchAction.ResultItemClicked, + is SettingsSearchAction.EnvironmentCleared, + is SettingsSearchAction.EnvironmentRehydrated, + -> state } } @@ -62,16 +80,45 @@ private fun reduce(state: SettingsSearchState, action: SettingsSearchAction): Se * * @property searchQuery Current search query [String]. * @property searchResults List of [SettingsSearchItem]s that match the current search query, if any. + * @property recentSearches List of recently searched [SettingsSearchItem]s. */ sealed class SettingsSearchState( open val searchQuery: String = "", open val searchResults: List<SettingsSearchItem> = emptyList(), + open val recentSearches: List<SettingsSearchItem> = emptyList(), ) : State { + + /** + * Creates a new state of the same type with updated properties. + * This allows for clean state updates in the reducer. + */ + abstract fun copyWith( + searchQuery: String = this.searchQuery, + searchResults: List<SettingsSearchItem> = this.searchResults, + recentSearches: List<SettingsSearchItem> = this.recentSearches, + ): SettingsSearchState + /** * Default state. * No query, no results + * + * @property recentSearches List of recently searched [SettingsSearchItem]s. */ - data object Default : SettingsSearchState() + data class Default( + override val recentSearches: List<SettingsSearchItem>, + ) : SettingsSearchState( + recentSearches = recentSearches, + ) { + override fun copyWith( + searchQuery: String, + searchResults: List<SettingsSearchItem>, + recentSearches: List<SettingsSearchItem>, + ): SettingsSearchState { + // A Default state can't have a query or search results, so we ignore those parameters + // and return a new Default state, only considering the recentSearches. + return Default(recentSearches = recentSearches) + } + } /** * State when there is a query. @@ -79,17 +126,53 @@ sealed class SettingsSearchState( * * @property searchQuery Current search query [String]. * @property searchResults List of [SettingsSearchItem]s that match the current search query. + * @property recentSearches List of recently searched [SettingsSearchItem]s. */ data class SearchInProgress( override val searchQuery: String, override val searchResults: List<SettingsSearchItem>, - ) : SettingsSearchState(searchQuery, searchResults) + override val recentSearches: List<SettingsSearchItem>, + ) : SettingsSearchState( + searchQuery, + searchResults, + recentSearches, + ) { + override fun copyWith( + searchQuery: String, + searchResults: List<SettingsSearchItem>, + recentSearches: List<SettingsSearchItem>, + ): SettingsSearchState { + return this.copy( + searchQuery = searchQuery, + searchResults = searchResults, + recentSearches = recentSearches, + ) + } + } /** - * State when there is a query but it yields zero search reuslts. + * State when there is a query but it yields zero search results. * Query, no results. * * @property searchQuery Current search query [String]. + * @property recentSearches List of recently searched [SettingsSearchItem]s. */ - data class NoSearchResults(override val searchQuery: String) : SettingsSearchState(searchQuery) + data class NoSearchResults( + override val searchQuery: String, + override val recentSearches: List<SettingsSearchItem>, + ) : SettingsSearchState( + searchQuery, + recentSearches = recentSearches, + ) { + override fun copyWith( + searchQuery: String, + searchResults: List<SettingsSearchItem>, + recentSearches: List<SettingsSearchItem>, + ): SettingsSearchState { + return this.copy( + searchQuery = searchQuery, + recentSearches = recentSearches, + ) + } + } } diff --git a/mobile/android/fenix/app/src/main/proto/recent_settings_searches.proto b/mobile/android/fenix/app/src/main/proto/recent_settings_searches.proto @@ -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/. */ + +syntax = "proto3"; + +package proto; + +option java_package = "org.mozilla.fenix.settings.datastore"; +option java_multiple_files = true; + +// Represents the entire collection of recent searches +message RecentSettingsSearches { + repeated RecentSettingsSearchItem items = 1; +} + +// Represents a single recent settings search item +message RecentSettingsSearchItem { + string preference_key = 1; + string title = 2; + string summary = 3; + repeated string breadcrumbs = 4; + int32 xml_resource_id = 5; // We only need the ID to reconstruct the full object +} 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 @@ -4,18 +4,29 @@ package org.mozilla.fenix.settings.settingssearch +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.coVerify +import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest import mozilla.components.support.test.libstate.ext.waitUntilIdle import mozilla.components.support.test.middleware.CaptureActionsMiddleware +import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class SettingsSearchMiddlewareTest { @@ -23,59 +34,131 @@ class SettingsSearchMiddlewareTest { val coroutineRule = MainCoroutineRule() private val navController: NavController = mockk(relaxed = true) + private lateinit var lifecycleOwner: FakeLifecycleOwner + private lateinit var fragment: Fragment + private val recentSearchesRepository: FenixRecentSettingsSearchesRepository = mockk(relaxed = true) + private val recentSearchesFlow = MutableStateFlow<List<SettingsSearchItem>>(emptyList()) + + @Before + fun setUp() { + every { recentSearchesRepository.recentSearches } returns recentSearchesFlow + lifecycleOwner = FakeLifecycleOwner(Lifecycle.State.RESUMED) + fragment = spyk(Fragment()).apply { + every { context } returns testContext + } + every { fragment.viewLifecycleOwner } returns lifecycleOwner + } private fun buildMiddleware(): SettingsSearchMiddleware { return SettingsSearchMiddleware( - initialDependencies = SettingsSearchMiddleware.Companion.Dependencies( - navController = navController, - ), 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 = SettingsSearchMiddleware( - initialDependencies = SettingsSearchMiddleware.Companion.Dependencies( - navController = navController, - ), fenixSettingsIndexer = EmptyTestSettingsIndexer(), + dispatcher = coroutineRule.testDispatcher, ) - val capture = CaptureActionsMiddleware<SettingsSearchState, SettingsSearchAction>() - val query = "test" + val query = "longSample" val store = SettingsSearchStore( middleware = listOf( middleware, - capture, ), ) - + store.dispatch( + SettingsSearchAction.EnvironmentRehydrated( + environment = SettingsSearchEnvironment( + fragment = fragment, + navController = navController, + context = testContext, + recentSettingsSearchesRepository = recentSearchesRepository, + ), + ), + ) + store.waitUntilIdle() store.dispatch(SettingsSearchAction.SearchQueryUpdated(query)) store.waitUntilIdle() - capture.assertLastAction(SettingsSearchAction.NoResultsFound::class) assert(store.state is SettingsSearchState.NoSearchResults) assert(store.state.searchQuery == query) + assert(store.state.searchResults.isEmpty()) } @Test fun `WHEN the settings search query is updated and results are found THEN the state is updated`() { val middleware = buildMiddleware() val capture = CaptureActionsMiddleware<SettingsSearchState, SettingsSearchAction>() - val query = "test" + val query = "a" val store = SettingsSearchStore( middleware = listOf( middleware, capture, ), ) - + store.dispatch(SettingsSearchAction.Init) + store.dispatch( + SettingsSearchAction.EnvironmentRehydrated( + environment = SettingsSearchEnvironment( + fragment = fragment, + navController = navController, + context = testContext, + recentSettingsSearchesRepository = recentSearchesRepository, + ), + ), + ) store.dispatch(SettingsSearchAction.SearchQueryUpdated(query)) store.waitUntilIdle() - capture.assertLastAction(SettingsSearchAction.SearchResultsLoaded::class) assert(store.state is SettingsSearchState.SearchInProgress) assert(store.state.searchQuery == query) assert(store.state.searchResults == testList) } + + @Test + fun `WHEN a result item is clicked THEN it should be added to the recent searches repository`() { + val middleware = buildMiddleware() + val store = SettingsSearchStore( + middleware = listOf( + middleware, + ), + ) + 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)) + store.waitUntilIdle() + + coVerify { recentSearchesRepository.addRecentSearchItem(testItem) } + verify { navController.navigate(testItem.preferenceFileInformation.fragmentId, any()) } + } + + @Test + fun `WHEN RecentSearchesUpdated is dispatched THEN store state is updated correctly`() { + val store = SettingsSearchStore() + val updatedRecents = listOf(testList.first()) + + store.dispatch(SettingsSearchAction.RecentSearchesUpdated(updatedRecents)) + store.waitUntilIdle() + + assert(store.state.recentSearches == updatedRecents) + } + + @After + fun tearDown() = runTest { + lifecycleOwner.destroy() + } } val testList = listOf( @@ -122,3 +205,16 @@ class EmptyTestSettingsIndexer : SettingsIndexer { return emptyList() } } + +private class FakeLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner { + private val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle = registry + + init { + registry.currentState = initialState + } + + fun destroy() { + registry.currentState = Lifecycle.State.DESTROYED + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchStoreTest.kt @@ -5,12 +5,10 @@ package org.mozilla.fenix.settings.settingssearch import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.support.test.libstate.ext.waitUntilIdle import org.junit.Test import org.junit.runner.RunWith -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class SettingsSearchStoreTest { @@ -19,7 +17,7 @@ class SettingsSearchStoreTest { val query = "theme" val store = SettingsSearchStore() - val initialState = SettingsSearchState.Default + val initialState = SettingsSearchState.Default(recentSearches = emptyList()) assert(store.state == initialState) store.dispatch(SettingsSearchAction.SearchQueryUpdated(query)) @@ -32,13 +30,17 @@ class SettingsSearchStoreTest { @Test fun `GIVEN search in progress state WHEN SearchQueryUpdated action is dispatched with empty query THEN default state is dispatched`() { val store = SettingsSearchStore( - initialState = SettingsSearchState.SearchInProgress("theme", emptyList()), + initialState = SettingsSearchState.SearchInProgress( + "theme", + emptyList(), + emptyList(), + ), ) assert(store.state is SettingsSearchState.SearchInProgress) store.dispatch(SettingsSearchAction.SearchQueryUpdated("")) store.waitUntilIdle() - assert(store.state == SettingsSearchState.Default) + assert(store.state == SettingsSearchState.Default(emptyList())) } }