tor-browser

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

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:
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/redux/middleware/TabSearchMiddleware.kt | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagementFragment.kt | 2++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabsearch/TabSearchScreen.kt | 9++++++++-
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/redux/middleware/TabSearchMiddlewareTest.kt | 374+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()) + } +}