commit b2358de489029d15290ea1cd1d69b7a76ce588bc
parent e2a9540f683d8f8749acd78f7d13663119ba44f4
Author: Akhil Pindiprolu <apindiprolu@mozilla.com>
Date: Fri, 19 Dec 2025 21:14:08 +0000
Bug 1994287 - [Tab Management Search] Implement logic to generate search results r=android-reviewers,007
Differential Revision: https://phabricator.services.mozilla.com/D276857
Diffstat:
4 files changed, 450 insertions(+), 1 deletion(-)
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/redux/middleware/TabSearchMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/redux/middleware/TabSearchMiddleware.kt
@@ -0,0 +1,66 @@
+/* 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.tabstray.redux.middleware
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.lib.state.Middleware
+import mozilla.components.lib.state.Store
+import org.mozilla.fenix.tabstray.Page
+import org.mozilla.fenix.tabstray.TabSearchAction
+import org.mozilla.fenix.tabstray.TabsTrayAction
+import org.mozilla.fenix.tabstray.TabsTrayState
+
+/**
+ * [Middleware] that reacts to [TabSearchAction.SearchQueryChanged].
+ *
+ * @param scope The [CoroutineScope] for running the tab filtering off of the main thread.
+ * @param mainScope The [CoroutineScope] used for returning to the main thread.
+ **/
+class TabSearchMiddleware(
+ private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
+ private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main),
+) : Middleware<TabsTrayState, TabsTrayAction> {
+
+ override fun invoke(
+ store: Store<TabsTrayState, TabsTrayAction>,
+ next: (TabsTrayAction) -> Unit,
+ action: TabsTrayAction,
+ ) {
+ next(action)
+
+ when (action) {
+ is TabSearchAction.SearchQueryChanged -> {
+ scope.launch {
+ val tabs = when (store.state.selectedPage) {
+ Page.NormalTabs -> store.state.normalTabs + store.state.inactiveTabs
+ Page.PrivateTabs -> store.state.privateTabs
+ else -> emptyList()
+ }
+
+ val query = action.query.trim()
+
+ val filteredTabs = if (query.isBlank()) {
+ emptyList()
+ } else {
+ tabs.filter { it.contains(text = query) }
+ }
+
+ mainScope.launch {
+ store.dispatch(TabSearchAction.SearchResultsUpdated(filteredTabs))
+ }
+ }
+ }
+
+ else -> {} // no-op
+ }
+ }
+
+ private fun TabSessionState.contains(text: String): Boolean {
+ return content.url.contains(text, ignoreCase = true) || content.title.contains(text, ignoreCase = true)
+ }
+}
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
@@ -86,6 +86,7 @@ import org.mozilla.fenix.tabstray.controller.DefaultTabManagerInteractor
import org.mozilla.fenix.tabstray.controller.TabManagerController
import org.mozilla.fenix.tabstray.controller.TabManagerInteractor
import org.mozilla.fenix.tabstray.navigation.TabManagerNavDestination
+import org.mozilla.fenix.tabstray.redux.middleware.TabSearchMiddleware
import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsIntegration
import org.mozilla.fenix.tabstray.ui.animation.defaultPredictivePopTransitionSpec
import org.mozilla.fenix.tabstray.ui.animation.defaultTransitionSpec
@@ -178,6 +179,7 @@ class TabManagementFragment : DialogFragment() {
),
middlewares = listOf(
TabsTrayTelemetryMiddleware(requireComponents.nimbus.events),
+ TabSearchMiddleware(),
),
)
}
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabsearch/TabSearchScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabsearch/TabSearchScreen.kt
@@ -30,6 +30,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -56,6 +57,7 @@ import org.mozilla.fenix.tabstray.TabsTrayAction
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.ext.toDisplayTitle
+import org.mozilla.fenix.tabstray.redux.middleware.TabSearchMiddleware
import org.mozilla.fenix.tabstray.redux.state.TabSearchState
import org.mozilla.fenix.tabstray.ui.tabitems.BasicTabListItem
import org.mozilla.fenix.tabstray.ui.tabpage.EmptyTabPage
@@ -297,9 +299,14 @@ private class TabSearchParameterProvider : PreviewParameterProvider<TabsTrayStat
private fun TabSearchScreenPreview(
@PreviewParameter(TabSearchParameterProvider::class) state: TabsTrayState,
) {
+ val scope = rememberCoroutineScope()
val store = remember {
- TabsTrayStore(initialState = state)
+ TabsTrayStore(
+ initialState = state,
+ middlewares = listOf(TabSearchMiddleware(scope = scope)),
+ )
}
+
FirefoxTheme {
TabSearchScreen(store = store)
}
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/redux/middleware/TabSearchMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/redux/middleware/TabSearchMiddlewareTest.kt
@@ -0,0 +1,374 @@
+/* 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.tabstray.redux.middleware
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mozilla.fenix.tabstray.Page
+import org.mozilla.fenix.tabstray.TabSearchAction
+import org.mozilla.fenix.tabstray.TabsTrayState
+import org.mozilla.fenix.tabstray.TabsTrayStore
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+class TabSearchMiddlewareTest {
+
+ @Test
+ fun `WHEN SearchQueryChanged on NormalTabs THEN search results include matching normal and inactive tabs`() = runTest {
+ val expectedNormalTabs = listOf(
+ createTab(url = "mozilla.org"),
+ createTab(url = "mozilla.com"),
+ )
+ val otherNormalTabs = listOf(
+ createTab(url = "example.com"),
+ )
+
+ val expectedInactiveTabs = listOf(
+ createTab(url = "support.mozilla.org"),
+ )
+ val otherInactiveTabs = listOf(
+ createTab(url = "example2.com"),
+ )
+
+ val store = TabsTrayStore(
+ middlewares = listOf(
+ TabSearchMiddleware(
+ scope = this,
+ mainScope = this,
+ ),
+ ),
+ initialState = TabsTrayState(
+ selectedPage = Page.NormalTabs,
+ normalTabs = expectedNormalTabs + otherNormalTabs,
+ inactiveTabs = expectedInactiveTabs + otherInactiveTabs,
+ ),
+ )
+
+ store.dispatch(TabSearchAction.SearchQueryChanged("mozilla"))
+ advanceUntilIdle()
+
+ val expectedSearchResults = expectedNormalTabs + expectedInactiveTabs
+ assertEquals(expectedSearchResults, store.state.tabSearchState.searchResults)
+ }
+
+ @Test
+ fun `WHEN SearchQueryChanged on PrivateTabs THEN search results include matching private tabs only`() = runTest {
+ val expectedPrivateTabs = listOf(
+ createTab(
+ url = "mozilla.com",
+ private = true,
+ ),
+ createTab(
+ url = "developer.mozilla.org",
+ private = true,
+ ),
+ )
+ val otherPrivateTabs = listOf(
+ createTab(
+ url = "example.com",
+ private = true,
+ ),
+ )
+
+ val store = TabsTrayStore(
+ middlewares = listOf(
+ TabSearchMiddleware(
+ scope = this,
+ mainScope = this,
+ ),
+ ),
+ initialState = TabsTrayState(
+ selectedPage = Page.PrivateTabs,
+ privateTabs = expectedPrivateTabs + otherPrivateTabs,
+ ),
+ )
+
+ store.dispatch(TabSearchAction.SearchQueryChanged("mozilla"))
+ advanceUntilIdle()
+
+ assertEquals(expectedPrivateTabs, store.state.tabSearchState.searchResults)
+ }
+
+ @Test
+ fun `WHEN SearchQueryChanged with multiple words in query on NormalTabs THEN search results include matching normal and inactive tabs`() = runTest {
+ val expectedNormalTabs = listOf(
+ createTab(
+ url = "mozilla.org",
+ title = "Mozilla Homepage",
+ ),
+ )
+
+ val otherNormalTabs = listOf(
+ createTab(
+ url = "mozilla.com",
+ title = "Mozilla Example",
+ ),
+ createTab(
+ url = "example.com",
+ title = "example title",
+ ),
+ createTab(
+ url = "example2.com",
+ title = "example 2 title",
+ ),
+ )
+
+ val expectedInactiveTabs = listOf(
+ createTab(
+ url = "support.mozilla.org",
+ title = "Mozilla Homepage - Support",
+ ),
+ )
+ val otherInactiveTabs = listOf(
+ createTab(
+ url = "inactive.com",
+ title = "example 3 title",
+ ),
+ )
+
+ val store = TabsTrayStore(
+ middlewares = listOf(
+ TabSearchMiddleware(
+ scope = this,
+ mainScope = this,
+ ),
+ ),
+ initialState = TabsTrayState(
+ selectedPage = Page.NormalTabs,
+ normalTabs = expectedNormalTabs + otherNormalTabs,
+ inactiveTabs = expectedInactiveTabs + otherInactiveTabs,
+ ),
+ )
+
+ store.dispatch(TabSearchAction.SearchQueryChanged("mozilla homepage"))
+ advanceUntilIdle()
+
+ val expectedSearchResults = expectedNormalTabs + expectedInactiveTabs
+ assertEquals(expectedSearchResults, store.state.tabSearchState.searchResults)
+ }
+
+ @Test
+ fun `WHEN SearchQueryChanged with multiple words in query on PrivateTabs THEN search results include matching private tabs only`() = runTest {
+ val expectedPrivateTabs = listOf(
+ createTab(
+ url = "mozilla.org",
+ title = "Mozilla Homepage",
+ private = true,
+ ),
+ createTab(
+ url = "support.mozilla.org",
+ title = "Mozilla Homepage - Support",
+ private = true,
+ ),
+ )
+ val otherPrivateTabs = listOf(
+ createTab(
+ url = "mozilla.com",
+ title = "Mozilla Example",
+ private = true,
+ ),
+ createTab(
+ url = "example.com",
+ title = "example title",
+ private = true,
+ ),
+ )
+
+ val store = TabsTrayStore(
+ middlewares = listOf(
+ TabSearchMiddleware(
+ scope = this,
+ mainScope = this,
+ ),
+ ),
+ initialState = TabsTrayState(
+ selectedPage = Page.PrivateTabs,
+ privateTabs = expectedPrivateTabs + otherPrivateTabs,
+ ),
+ )
+
+ store.dispatch(TabSearchAction.SearchQueryChanged("mozilla homepage"))
+ advanceUntilIdle()
+
+ assertEquals(expectedPrivateTabs, store.state.tabSearchState.searchResults)
+ }
+
+ @Test
+ fun `WHEN SearchQueryChanged on NormalTabs THEN search results match query in title for both normal and inactive tabs`() = runTest {
+ val expectedNormalTabs = listOf(
+ createTab(
+ url = "example.com",
+ title = "Mozilla Homepage",
+ ),
+ )
+ val otherNormalTabs = listOf(
+ createTab(
+ url = "example2.com",
+ title = "Unrelated title",
+ ),
+ )
+
+ val expectedInactiveTabs = listOf(
+ createTab(
+ url = "inactive-example.com",
+ title = "Mozilla Inactive Tab",
+ ),
+ )
+ val otherInactiveTabs = listOf(
+ createTab(
+ url = "inactive-example2.com",
+ title = "Another title",
+ ),
+ )
+
+ val store = TabsTrayStore(
+ middlewares = listOf(
+ TabSearchMiddleware(
+ scope = this,
+ mainScope = this,
+ ),
+ ),
+ initialState = TabsTrayState(
+ selectedPage = Page.NormalTabs,
+ normalTabs = expectedNormalTabs + otherNormalTabs,
+ inactiveTabs = expectedInactiveTabs + otherInactiveTabs,
+ ),
+ )
+
+ store.dispatch(TabSearchAction.SearchQueryChanged("mozilla"))
+ advanceUntilIdle()
+
+ val expectedSearchResults = expectedNormalTabs + expectedInactiveTabs
+ assertEquals(expectedSearchResults, store.state.tabSearchState.searchResults)
+ }
+
+ @Test
+ fun `WHEN SearchQueryChanged on PrivateTabs THEN search results match query in title even if url does not contain it`() = runTest {
+ val expectedPrivateTabs = listOf(
+ createTab(
+ url = "https://example.com",
+ title = "Mozilla Private Tab",
+ private = true,
+ ),
+ )
+ val otherPrivateTabs = listOf(
+ createTab(
+ url = "https://example2.com",
+ title = "Unrelated title",
+ private = true,
+ ),
+ )
+
+ val store = TabsTrayStore(
+ middlewares = listOf(
+ TabSearchMiddleware(
+ scope = this,
+ mainScope = this,
+ ),
+ ),
+ initialState = TabsTrayState(
+ selectedPage = Page.PrivateTabs,
+ privateTabs = expectedPrivateTabs + otherPrivateTabs,
+ ),
+ )
+
+ store.dispatch(TabSearchAction.SearchQueryChanged("mozilla"))
+ advanceUntilIdle()
+
+ assertEquals(expectedPrivateTabs, store.state.tabSearchState.searchResults)
+ }
+
+ @Test
+ fun `WHEN SearchQueryChanged with empty query on NormalTabs THEN search results are empty`() = runTest {
+ val normalTabs = listOf(
+ createTab(url = "mozilla.org"),
+ createTab(url = "example.com"),
+ )
+ val inactiveTabs = listOf(
+ createTab(url = "mozilla.com"),
+ )
+
+ val store = TabsTrayStore(
+ middlewares = listOf(
+ TabSearchMiddleware(
+ scope = this,
+ mainScope = this,
+ ),
+ ),
+ initialState = TabsTrayState(
+ selectedPage = Page.NormalTabs,
+ normalTabs = normalTabs,
+ inactiveTabs = inactiveTabs,
+ ),
+ )
+
+ store.dispatch(TabSearchAction.SearchQueryChanged(""))
+ advanceUntilIdle()
+
+ val expectedSearchResults = emptyList<TabSessionState>()
+ assertEquals(expectedSearchResults, store.state.tabSearchState.searchResults)
+ }
+
+ @Test
+ fun `WHEN SearchQueryChanged with empty query on PrivateTabs THEN search results are empty`() = runTest {
+ val privateTabs = listOf(
+ createTab(url = "mozilla.com", private = true),
+ createTab(url = "example.com", private = true),
+ )
+
+ val store = TabsTrayStore(
+ middlewares = listOf(
+ TabSearchMiddleware(
+ scope = this,
+ mainScope = this,
+ ),
+ ),
+ initialState = TabsTrayState(
+ selectedPage = Page.PrivateTabs,
+ privateTabs = privateTabs,
+ ),
+ )
+
+ store.dispatch(TabSearchAction.SearchQueryChanged(""))
+ advanceUntilIdle()
+
+ val expectedSearchResults = emptyList<TabSessionState>()
+ assertEquals(expectedSearchResults, store.state.tabSearchState.searchResults)
+ }
+
+ @Test
+ fun `WHEN SearchQueryChanged with whitespace only query on PrivateTabs THEN search results are empty`() = runTest {
+ val privateTabs = listOf(
+ createTab(url = "mozilla.com", private = true),
+ createTab(url = "example.com", private = true),
+ )
+
+ val store = TabsTrayStore(
+ middlewares = listOf(
+ TabSearchMiddleware(
+ scope = this,
+ mainScope = this,
+ ),
+ ),
+ initialState = TabsTrayState(
+ selectedPage = Page.PrivateTabs,
+ privateTabs = privateTabs,
+ ),
+ )
+
+ store.dispatch(TabSearchAction.SearchQueryChanged(" "))
+ advanceUntilIdle()
+
+ assertTrue(store.state.tabSearchState.searchResults.isEmpty())
+ }
+}