tor-browser

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

commit 931064cb72b2f552fcd69d7c4fb1deb946928107
parent a3f5f005f6b53636bbc34ed2bdb1590d1c177394
Author: Harrison Oglesby <oglesby.harrison@gmail.com>
Date:   Mon, 17 Nov 2025 22:38:29 +0000

Bug 1998028 - Part 3: Fix text highlighting for Settings Search r=android-reviewers,petru

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

Diffstat:
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchResultItem.kt | 37+++++++++++++------------------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/TextHighlightingTest.kt | 75+++++++++++++++++++++++++++++++++++++++++++++------------------------------
2 files changed, 58 insertions(+), 54 deletions(-)

diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchResultItem.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchResultItem.kt @@ -103,7 +103,8 @@ internal fun shouldShowSummary( } /** - * Highlights the query matching text. + * Highlights the query matching text. Only the first instance of the matching text. + * Works with even with mismatched capitalization. * * @param text Text to highlight. * @param query Query to highlight. @@ -114,36 +115,24 @@ internal fun highlightQueryMatchingText( query: String, highlight: SpanStyle, ): AnnotatedString { - val tokens = query.trim().split(Regex("\\s+")).filter { it.isNotBlank() } - if (tokens.isEmpty()) return AnnotatedString(text) - - // Build one regex that matches *any* token — even inside words - // For example: "data privacy" → matches "metadata", "data-saving", "privacy-aware" - val pattern = tokens.joinToString("|") { Regex.escape(it) } - val regex = Regex(pattern, RegexOption.IGNORE_CASE) + val trimmedQuery = query.trim() + if (trimmedQuery.isBlank()) { + return AnnotatedString(text) + } - // Find all match ranges - val matches = regex.findAll(text).map { it.range }.toList() - if (matches.isEmpty()) return AnnotatedString(text) + var match = Regex.escape(trimmedQuery).toRegex(RegexOption.IGNORE_CASE).find(text) - // Merge overlapping or adjacent ranges - val merged = matches.sortedBy { it.first }.fold(mutableListOf<IntRange>()) { acc, range -> - if (acc.isEmpty()) { - acc += range - } else { - val last = acc.last() - if (range.first <= last.last + 1) { - acc[acc.lastIndex] = (last.first..maxOf(last.last, range.last)) - } else { - acc += range - } + if (match == null) { + val tokens = trimmedQuery.split(Regex("\\s+")).filter { it.isNotBlank() } + if (tokens.isNotEmpty()) { + val pattern = tokens.joinToString("|") { Regex.escape(it) } + match = Regex(pattern, RegexOption.IGNORE_CASE).find(text) } - acc } return buildAnnotatedString { append(text) - merged.forEach { range -> + match?.range?.let { range -> addStyle(highlight, range.first, range.last + 1) } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/TextHighlightingTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/settingssearch/TextHighlightingTest.kt @@ -3,99 +3,114 @@ package org.mozilla.fenix.settings.settingssearch import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight +import org.junit.Assert.assertEquals import org.junit.Test class TextHighlightingTest { private val highlightStyle = SpanStyle(color = Color.Red, fontWeight = FontWeight.Bold) @Test - fun `test single word query highlights correctly`() { + fun `GIVEN a query and a text with matching text THEN it highlights the query`() { val text = "Set your search engine" val query = "search" val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) val spanStyles = annotatedString.spanStyles - assert(spanStyles.size == 1) - assert(spanStyles.first().start == 9) - assert(spanStyles.first().end == 15) - assert(spanStyles.first().item == highlightStyle) + assertEquals(1, spanStyles.size) + assertEquals(9, spanStyles.first().start) + assertEquals(15, spanStyles.first().end) + assertEquals(highlightStyle, spanStyles.first().item) } @Test - fun `test case-insensitive query highlights correctly`() { + fun `GIVEN a query and a text with matching text with mismatching capitalization THEN it highlights the query`() { val text = "Set Your Search Engine" val query = "search" val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) val spanStyles = annotatedString.spanStyles - assert(spanStyles.size == 1) - assert(spanStyles.first().start == 9) - assert(spanStyles.first().end == 15) + assertEquals(1, spanStyles.size) + assertEquals(9, spanStyles.first().start) + assertEquals(15, spanStyles.first().end) } @Test - fun `test query with extra whitespace is handled`() { + fun `GIVEN a query with leading and trailing whitespace THEN it handles the whitespace`() { val text = "Set your search engine" val query = " search " val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) val spanStyles = annotatedString.spanStyles - assert(spanStyles.size == 1) - assert(spanStyles.first().start == 9) - assert(spanStyles.first().end == 15) + assertEquals(1, spanStyles.size) + assertEquals(9, spanStyles.first().start) + assertEquals(15, spanStyles.first().end) } @Test - fun `test overlapping matches are merged into one span`() { + fun `GIVEN a query WHEN there are multiple matches THEN it highlights the first match`() { val text = "datadata" val query = "data" val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) val spanStyles = annotatedString.spanStyles - assert(spanStyles.size == 1) - assert(spanStyles.first().start == 0) - assert(spanStyles.first().end == 8) + assertEquals(1, spanStyles.size) + assertEquals(0, spanStyles.first().start) + assertEquals(4, spanStyles.first().end) + assertEquals(highlightStyle, spanStyles.first().item) } @Test - fun `test no matches returns unstyled string`() { + fun `GIVEN a query WHEN there are no matches THEN it returns an unstyled AnnotatedString`() { val text = "Set your search engine" val query = "firefox" val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) - assert(annotatedString.spanStyles.isEmpty()) - assert(annotatedString.text == text) + assertEquals(text, annotatedString.text) + assertEquals(true, annotatedString.spanStyles.isEmpty()) } @Test - fun `test empty query returns unstyled string`() { + fun `GIVEN an empty query THEN it returns an unstyled AnnotatedString`() { val text = "Set your search engine" val query = "" val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) - assert(annotatedString.spanStyles.isEmpty()) - assert(annotatedString.text == text) + assertEquals(text, annotatedString.text) + assertEquals(true, annotatedString.spanStyles.isEmpty()) } @Test - fun `test empty text returns empty annotated string`() { + fun `GIVEN an empty text THEN it returns an empty AnnotatedString`() { val text = "" val query = "search" val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) - assert(annotatedString.spanStyles.isEmpty()) - assert(annotatedString.text.isEmpty()) + assertEquals(true, annotatedString.text.isEmpty()) + assertEquals(true, annotatedString.spanStyles.isEmpty()) } @Test - fun `test partial word matching`() { + fun `GIVEN a partial word query THEN it highlights the matching partial word`() { val text = "Enable data privacy features" val query = "priv" val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) val spanStyles = annotatedString.spanStyles - assert(spanStyles.size == 1) - assert(spanStyles.first().start == 12) - assert(spanStyles.first().end == 16) + assertEquals(1, spanStyles.size) + assertEquals(12, spanStyles.first().start) + assertEquals(16, spanStyles.first().end) + } + + @Test + fun `GIVEN a multi-word query THEN it highlights all words in the query`() { + val text = "Enable data privacy features" + val query = "data privacy" + val annotatedString = highlightQueryMatchingText(text, query, highlightStyle) + + val spanStyles = annotatedString.spanStyles + assertEquals(1, spanStyles.size) + assertEquals(7, spanStyles.first().start) + assertEquals(19, spanStyles.first().end) + assertEquals("data privacy", annotatedString.substring(7, 19)) } }