tor-browser

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

commit baa3cf6e1213dd783eee4fe314551ebfd18d5929
parent 8e259787b0af8b5bf09262b0e0403302940add28
Author: fmasalha <fmasalha@mozilla.com>
Date:   Mon, 15 Dec 2025 18:52:57 +0000

Bug 1835381 - Added search functionality to the select bookmarks screen. r=android-reviewers,android-l10n-reviewers,flod,matt-tighe

Differential Revision: https://phabricator.services.mozilla.com/D272906

Diffstat:
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksAction.kt | 4++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksMiddleware.kt | 18++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksReducer.kt | 35++++++++++++++++++++++++++++++++++-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksScreen.kt | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksState.kt | 16+++++++++++++++-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksTelemetryMiddleware.kt | 4++++
Mmobile/android/fenix/app/src/main/res/values/strings.xml | 2++
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/bookmarks/BookmarksMiddlewareTest.kt | 32++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/bookmarks/BookmarksReducerTest.kt | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 327 insertions(+), 6 deletions(-)

diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksAction.kt @@ -117,7 +117,11 @@ internal sealed class EditBookmarkAction : BookmarksAction { internal sealed class SelectFolderAction : BookmarksAction { data object ViewAppeared : SelectFolderAction() data class FoldersLoaded(val folders: List<SelectFolderItem>) : SelectFolderAction() + data class FilteredFoldersLoaded(val folders: List<SelectFolderItem>) : SelectFolderAction() data class ItemClicked(val folder: SelectFolderItem) : SelectFolderAction() + data object SearchClicked : SelectFolderAction() + data object SearchDismissed : SelectFolderAction() + data class SearchQueryUpdated(val query: String) : SelectFolderAction() internal sealed class SortMenu : SelectFolderAction() { data object SortMenuButtonClicked : SortMenu() diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksMiddleware.kt @@ -375,6 +375,23 @@ internal class BookmarksMiddleware( context.store.tryDispatchLoadFolders() saveBookmarkSortOrder(context.store.state.sortOrder) } + is SelectFolderAction.SearchQueryUpdated -> { + scope.launch { + val state = context.store.state.bookmarksSelectFolderState + val filteredFolders = state?.folders + ?.filter { + it.title.startsWith( + state.searchQuery, + ignoreCase = true, + ) + } + filteredFolders?.let { + context.store.dispatch(SelectFolderAction.FilteredFoldersLoaded(it)) + } + } + } + SelectFolderAction.SearchClicked, + SelectFolderAction.SearchDismissed, is InitEditLoaded, SnackbarAction.Undo, is OpenTabsConfirmationDialogAction.Present, @@ -390,6 +407,7 @@ internal class BookmarksMiddleware( is AddFolderAction.FolderCreated, is AddFolderAction.TitleChanged, is SelectFolderAction.FoldersLoaded, + is SelectFolderAction.FilteredFoldersLoaded, is SelectFolderAction.ItemClicked, EditFolderAction.DeleteClicked, is ReceivedSyncSignInUpdate, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksReducer.kt @@ -22,7 +22,9 @@ internal fun bookmarksReducer(state: BookmarksState, action: BookmarksAction) = bookmarkItems = action.bookmarkItems.sortedWith(state.sortOrder.comparator), isLoading = false, ) - is SearchClicked -> state.copy(isSearching = true) + is SearchClicked -> { + state.copy(isSearching = true) + } is SearchDismissed -> state.copy(isSearching = false) is RecursiveSelectionCountLoaded -> state.copy(recursiveSelectedCount = action.count) is BookmarkLongClicked -> state.toggleSelectionOf(action.item) @@ -94,13 +96,44 @@ private fun BookmarksState.handleOpenTabsConfirmationDialogAction( private fun BookmarksState.handleSelectFolderAction(action: SelectFolderAction): BookmarksState { return when (action) { + is SelectFolderAction.SearchQueryUpdated -> copy( + bookmarksSelectFolderState = + bookmarksSelectFolderState?.copy( + searchQuery = action.query, + isLoading = true, + ), + ) + is SelectFolderAction.SearchClicked -> copy( + bookmarksSelectFolderState = + bookmarksSelectFolderState?.copy( + isSearching = true, + ), + ) + is SelectFolderAction.SearchDismissed -> copy( + bookmarksSelectFolderState = + bookmarksSelectFolderState?.copy( + isSearching = false, + ), + ) is SelectFolderAction.ItemClicked -> updateSelectedFolder(action.folder) is SelectFolderAction.FoldersLoaded -> copy( bookmarksSelectFolderState = bookmarksSelectFolderState?.copy( folders = action.folders, + // If filtered folders is not set on Folders loaded, when the search button is + // clicked, nothing will display until the query gets updated. + filteredFolders = action.folders, + isLoading = false, ) ?: BookmarksSelectFolderState( folders = action.folders, + filteredFolders = action.folders, outerSelectionGuid = BookmarkRoot.Mobile.id, + isLoading = false, + ), + ) + is SelectFolderAction.FilteredFoldersLoaded -> copy( + bookmarksSelectFolderState = bookmarksSelectFolderState?.copy( + filteredFolders = action.folders, + isLoading = false, ), ) is SelectFolderAction.SortMenu -> this.handleSortMenuAction(action) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -44,6 +45,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -85,6 +87,8 @@ import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -931,11 +935,38 @@ private fun SelectFolderScreen( store.dispatch(SelectFolderAction.ViewAppeared) } + BackInvokedHandler(state?.isSearching ?: false) { + store.dispatch(SelectFolderAction.SearchDismissed) + } + + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + Scaffold( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures( + onTap = { + focusManager.clearFocus() + keyboardController?.hide() + store.dispatch(SelectFolderAction.SearchDismissed) + }, + ) + }, topBar = { - SelectFolderTopBar(store = store) + if (state?.isSearching ?: false) { + SelectFolderSearchTopBar(store = store) + } else { + SelectFolderTopBar(store = store) + } }, ) { paddingValues -> + if (state?.isLoading ?: false) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Scaffold + } LazyColumn( modifier = Modifier .padding(paddingValues) @@ -943,10 +974,13 @@ private fun SelectFolderScreen( .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - items(state?.folders.orEmpty()) { folder -> + items( + items = state?.visibleFolders ?: listOf(), + ) { folder -> FolderListItem( folder = folder, isSelected = folder.guid == state?.selectedGuid, + showPadding = state?.isSearching ?: true, onClick = { store.dispatch(SelectFolderAction.ItemClicked(folder)) }, ) } @@ -961,13 +995,75 @@ private fun SelectFolderScreen( } @Composable +private fun SelectFolderSearchTopBar(store: BookmarksStore) { + val focusRequester = remember { FocusRequester() } + var text by remember { + mutableStateOf( + TextFieldValue( + store.state.bookmarksSelectFolderState?.searchQuery.orEmpty(), + selection = TextRange( + store.state.bookmarksSelectFolderState?.searchQuery?.length ?: 0, + ), + ), + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + val value = text.text + text = text.copy(selection = TextRange(value.length)) + } + + TopAppBar( + title = { + OutlinedTextField( + value = text, + onValueChange = { newValue -> + text = newValue + store.dispatch( + SelectFolderAction.SearchQueryUpdated(newValue.text), + ) + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { + stringResource(R.string.select_bookmark_search_button_content_description) + }, + leadingIcon = { + Icon( + painter = painterResource(iconsR.drawable.mozac_ic_search_24), + contentDescription = stringResource( + R.string.select_bookmark_search_button_content_description, + ), + ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + }, + navigationIcon = {}, + actions = {}, + windowInsets = WindowInsets( + top = 0.dp, + bottom = 0.dp, + ), + ) +} + +@Composable private fun FolderListItem( folder: SelectFolderItem, isSelected: Boolean, + showPadding: Boolean = true, onClick: () -> Unit, ) { if (folder.isDesktopRoot) { - Box(modifier = Modifier.padding(start = folder.startPadding)) { + Box( + modifier = Modifier.padding( + start = folder.startPadding, + ), + ) { Row(modifier = Modifier.width(FirefoxTheme.layout.size.containerMaxWidth)) { Spacer(modifier = Modifier.width(56.dp)) Text( @@ -978,7 +1074,7 @@ private fun FolderListItem( } } } else { - Box(modifier = Modifier.padding(start = folder.startPadding)) { + Box(modifier = Modifier.padding(start = if (!showPadding) folder.startPadding else 0.dp)) { SelectableIconListItem( label = folder.title, isSelected = isSelected, @@ -1046,6 +1142,17 @@ private fun SelectFolderTopBar(store: BookmarksStore) { SelectFolderSortOverflowMenu(store = store) } + IconButton(onClick = { + store.dispatch(SelectFolderAction.SearchClicked) + }) { + Icon( + painter = painterResource(iconsR.drawable.mozac_ic_search_24), + contentDescription = stringResource( + R.string.select_bookmark_search_button_content_description, + ), + ) + } + if (onNewFolderClick != null) { IconButton(onClick = { onNewFolderClick() }) { Icon( diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksState.kt @@ -251,12 +251,26 @@ internal data class SelectFolderItem( * this represents the selection GUID for the nest select screen where the newly added folder is being * placed. Optional since this screen may never be displayed. * @property folders The folders to display. + * @property filteredFolders The currently filtered collection of [folders] + * @property searchQuery The term used to filter the folders displayed. + * @property isLoading State representing if the initial load or the search has completed. + * @property isSearching State representing if currently in search mode. */ internal data class BookmarksSelectFolderState( val outerSelectionGuid: String, val innerSelectionGuid: String? = null, val folders: List<SelectFolderItem> = listOf(), -) { + val filteredFolders: List<SelectFolderItem> = listOf(), + val searchQuery: String = "", + val isLoading: Boolean = true, + val isSearching: Boolean = false, + ) { + val visibleFolders: List<SelectFolderItem> + get() = if (isSearching) { + filteredFolders.map { it.copy(indentation = 0) } + } else { + folders + } val selectedGuid: String get() = innerSelectionGuid ?: outerSelectionGuid } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksTelemetryMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksTelemetryMiddleware.kt @@ -46,6 +46,9 @@ internal class BookmarksTelemetryMiddleware : Middleware<BookmarksState, Bookmar BookmarksManagement.searchIconTapped.record(NoExtras()) } is BookmarksListMenuAction.SortMenu -> action.record() + SelectFolderAction.SearchClicked, + SelectFolderAction.SearchDismissed, + is SelectFolderAction.SearchQueryUpdated, CloseClicked, AddFolderClicked, is SelectFolderAction.SortMenu, @@ -59,6 +62,7 @@ internal class BookmarksTelemetryMiddleware : Middleware<BookmarksState, Bookmar EditBookmarkAction.FolderClicked, is FolderLongClicked, is SelectFolderAction.FoldersLoaded, + is SelectFolderAction.FilteredFoldersLoaded, Init, is SelectFolderAction.ItemClicked, AddFolderAction.ParentFolderClicked, diff --git a/mobile/android/fenix/app/src/main/res/values/strings.xml b/mobile/android/fenix/app/src/main/res/values/strings.xml @@ -1723,6 +1723,8 @@ <string name="bookmark_delete_folder_content_description">Delete folder</string> <!-- Content description for bookmark search floating action button --> <string name="bookmark_search_button_content_description">Search bookmarks</string> + <!-- Content description for select folder screen search action button ("search" is a verb) --> + <string name="select_bookmark_search_button_content_description">Search folders</string> <!-- Content description for the overflow menu for a bookmark item. %s is a folder name or bookmark title. --> <string name="bookmark_item_menu_button_content_description">Item Menu for %s</string> <!-- Content description for the "close" button in bookmarks screen. --> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/bookmarks/BookmarksMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/bookmarks/BookmarksMiddlewareTest.kt @@ -991,6 +991,38 @@ class BookmarksMiddlewareTest { } @Test + fun `GIVEN current screen select folder WHEN the search query is updated THEN FilteredFoldersLoaded gets dispatched with the filtered folders`() = runTestOnMain { + val bookmarksFolder = SelectFolderItem(0, BookmarkItem.Folder("Bookmarks", "guid0", null)) + val folders = listOf( + bookmarksFolder, + SelectFolderItem(1, BookmarkItem.Folder("Nested One", "guid0", null)), + SelectFolderItem(2, BookmarkItem.Folder("Nested Two", "guid0", null)), + SelectFolderItem(2, BookmarkItem.Folder("Nested Two", "guid0", null)), + SelectFolderItem(1, BookmarkItem.Folder("Nested One", "guid0", null)), + SelectFolderItem(2, BookmarkItem.Folder("Nested Two", "guid1", null)), + SelectFolderItem(3, BookmarkItem.Folder("Nested Three", "guid0", null)), + SelectFolderItem(0, BookmarkItem.Folder("Nested 0", "guid0", null)), + ) + + val middleware = buildMiddleware() + val store = middleware.makeStore( + initialState = BookmarksState.default.copy( + bookmarksSelectFolderState = BookmarksSelectFolderState( + outerSelectionGuid = "selection guid", + isSearching = true, + folders = folders, + filteredFolders = folders, + ), + ), + ) + + val searchQueryNew = "bookmarks" + + store.dispatch(SelectFolderAction.SearchQueryUpdated(searchQueryNew)) + assertEquals(listOf(bookmarksFolder), store.state.bookmarksSelectFolderState?.filteredFolders) + } + + @Test fun `WHEN edit clicked in bookmark item menu THEN nav to edit screen`() = runTestOnMain { `when`(bookmarksStorage.countBookmarksInTrees(listOf(BookmarkRoot.Menu.id, BookmarkRoot.Toolbar.id, BookmarkRoot.Unfiled.id))).thenReturn(0u) `when`(bookmarksStorage.getTree(BookmarkRoot.Mobile.id)).thenReturn(Result.success(generateBookmarkTree())) diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/bookmarks/BookmarksReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/bookmarks/BookmarksReducerTest.kt @@ -529,6 +529,70 @@ class BookmarksReducerTest { } @Test + fun `GIVEN we are on the select folder screen WHEN search is clicked THEN select folder enters search mode`() { + val state = BookmarksState.default.copy( + bookmarksSelectFolderState = BookmarksSelectFolderState( + outerSelectionGuid = "selection guid", + isSearching = false, + ), + ) + + val result = bookmarksReducer(state, SelectFolderAction.SearchClicked) + + assertTrue(result.bookmarksSelectFolderState!!.isSearching) + } + + @Test + fun `GIVEN select folder search is active WHEN dismissed THEN select folder exits search mode`() { + val state = BookmarksState.default.copy( + bookmarksSelectFolderState = BookmarksSelectFolderState( + outerSelectionGuid = "selection guid", + isSearching = true, + ), + ) + + val result = bookmarksReducer(state, SelectFolderAction.SearchDismissed) + + assertFalse(result.bookmarksSelectFolderState!!.isSearching) + } + + @Test + fun `GIVEN we are on the select folder screen WHEN the search query changes THEN persist the new query`() { + val folder1 = + SelectFolderItem( + 0, + BookmarkItem + .Folder( + title = "one folder", + guid = "", + position = null, + ), + ) + val folder2 = + SelectFolderItem( + 0, + BookmarkItem + .Folder( + title = "other", + guid = "", + position = null, + ), + ) + val state = BookmarksState.default.copy( + bookmarksSelectFolderState = BookmarksSelectFolderState( + folders = listOf(folder1, folder2), + outerSelectionGuid = "selection guid", + searchQuery = "one folder", + ), + ) + + val newQuery = "other" + val result = bookmarksReducer(state, SelectFolderAction.SearchQueryUpdated(newQuery)) + + assertEquals(newQuery, result.bookmarksSelectFolderState!!.searchQuery) + } + + @Test fun `GIVEN we are on the select folder screen WHEN folders are loaded THEN attach loaded folders on the select screen state`() { val state = BookmarksState.default.copy( bookmarksSelectFolderState = BookmarksSelectFolderState(outerSelectionGuid = "selection guid"), @@ -551,6 +615,49 @@ class BookmarksReducerTest { bookmarksSelectFolderState = BookmarksSelectFolderState( outerSelectionGuid = "selection guid", folders = folders, + filteredFolders = folders, + isLoading = false, + isSearching = false, + ), + ) + + assertEquals(expected, result) + } + + @Test + fun `GIVEN we are on the select folder screen and search is active WHEN filtered folders are loaded THEN filtered folders gets updated`() { + val bookmarksFolder = SelectFolderItem(0, BookmarkItem.Folder("Bookmarks", "guid0", null)) + val folders = listOf( + bookmarksFolder, + SelectFolderItem(1, BookmarkItem.Folder("Nested One", "guid0", null)), + SelectFolderItem(2, BookmarkItem.Folder("Nested Two", "guid0", null)), + SelectFolderItem(2, BookmarkItem.Folder("Nested Two", "guid0", null)), + SelectFolderItem(1, BookmarkItem.Folder("Nested One", "guid0", null)), + SelectFolderItem(2, BookmarkItem.Folder("Nested Two", "guid1", null)), + SelectFolderItem(3, BookmarkItem.Folder("Nested Three", "guid0", null)), + SelectFolderItem(0, BookmarkItem.Folder("Nested 0", "guid0", null)), + ) + + val state = BookmarksState.default.copy( + bookmarksSelectFolderState = + BookmarksSelectFolderState( + outerSelectionGuid = "selection guid", + folders = folders, + isSearching = true, + ), + ) + + val filteredFolders = folders.filter { it.title.startsWith("bookmarks", ignoreCase = true) } + + val result = bookmarksReducer(state, SelectFolderAction.FilteredFoldersLoaded(filteredFolders)) + + val expected = state.copy( + bookmarksSelectFolderState = BookmarksSelectFolderState( + outerSelectionGuid = "selection guid", + folders = folders, + filteredFolders = listOf(bookmarksFolder), + isLoading = false, + isSearching = true, ), )