tor-browser

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

commit 57429b92e714cb1c8e40f1aa0b67e9dffd9285c3
parent b4d447c4b6285c7e27ea52cc842546d9a3c9bc2e
Author: Harrison Oglesby <oglesby.harrison@gmail.com>
Date:   Wed, 22 Oct 2025 17:29:33 +0000

Bug 1990029 - Indexing of Fenix preference xml pages r=android-reviewers,petru

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

Diffstat:
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/Components.kt | 5+++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/DefaultFenixSettingsIndexer.kt | 350+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsIndexer.kt | 2+-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchFragment.kt | 5++---
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsSearchMiddleware.kt | 36++++++++++++++++--------------------
5 files changed, 367 insertions(+), 31 deletions(-)

diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/Components.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/Components.kt @@ -71,6 +71,7 @@ import org.mozilla.fenix.perf.StartupStateProvider import org.mozilla.fenix.perf.StrictModeManager import org.mozilla.fenix.perf.lazyMonitored import org.mozilla.fenix.reviewprompt.ReviewPromptMiddleware +import org.mozilla.fenix.settings.settingssearch.DefaultFenixSettingsIndexer import org.mozilla.fenix.termsofuse.TermsOfUseManager import org.mozilla.fenix.utils.Settings import org.mozilla.fenix.utils.isLargeScreenSize @@ -349,6 +350,10 @@ class Components(private val context: Context) { val termsOfUseManager by lazyMonitored { TermsOfUseManager(settings) } + + val settingsIndexer by lazyMonitored { + DefaultFenixSettingsIndexer(context) + } } /** diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/DefaultFenixSettingsIndexer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/DefaultFenixSettingsIndexer.kt @@ -4,18 +4,37 @@ package org.mozilla.fenix.settings.settingssearch +import android.content.Context +import android.content.res.Resources +import android.content.res.XmlResourceParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.mozilla.fenix.R +import java.io.IOException + /** * Indexes Settings preferences for the Settings Search screen. + * + * All the preference files that are parsed and indexed are listed in the companion object. */ -class DefaultFenixSettingsIndexer : SettingsIndexer { +class DefaultFenixSettingsIndexer(private val context: Context) : SettingsIndexer { private val settings: MutableList<SettingsSearchItem> = mutableListOf() + private val breadcrumbs: MutableList<String> = mutableListOf() /** * Index all settings. */ override fun indexAllSettings() { settings.clear() - // index settings + + for (preferenceFileInformation in preferenceFileInformationList) { + breadcrumbs.clear() + val settingFileParser = getXmlParserForFile(preferenceFileInformation.xmlResourceId) + breadcrumbs.add(context.getString(preferenceFileInformation.topBreadcrumbResourceId)) + if (settingFileParser != null) { + parseXmlFile(settingFileParser) + } + } } /** @@ -24,12 +43,329 @@ class DefaultFenixSettingsIndexer : SettingsIndexer { * @param query Query [String] to filter by. * @return List of [SettingsSearchItem]s that match the query. */ - override fun getSettingsWithQuery(query: String): List<SettingsSearchItem> { + override suspend fun getSettingsWithQuery(query: String): List<SettingsSearchItem> { if (query.isBlank()) return emptyList() - return settings.filter { item -> - item.title.contains(query, ignoreCase = true) || - item.summary.contains(query, ignoreCase = true) - }.toList() + val trimmedQuery = query.trim() + + return withContext(Dispatchers.Default) { + settings.distinctBy { it.preferenceKey }.filter { item -> + item.title.contains(trimmedQuery, ignoreCase = true) || + item.summary.contains(trimmedQuery, ignoreCase = true) + } + } + } + + private fun getXmlParserForFile(xmlResourceId: Int): XmlResourceParser? { + try { + if (xmlResourceId == 0) return null + return context.resources.getXml(xmlResourceId) + } catch (e: Resources.NotFoundException) { + println("Error: failed to find resource $xmlResourceId. ${e.message}") + } catch (e: IOException) { + println("Error: I/O exception while parsing ${e.message}") + } + return null + } + + @Suppress("NestedBlockDepth") + private fun parseXmlFile( + parser: XmlResourceParser, + ) { + try { + var eventType = parser.next() + var categoryItem: SettingsSearchItem? = null + var categoryItemAdded = false + + while (eventType != XmlResourceParser.END_DOCUMENT) { + when (eventType) { + XmlResourceParser.START_TAG -> { + when (parser.name) { + PREFERENCE_CATEGORY_TAG -> { + addCategoryToBreadcrumbs(parser) + categoryItem = createCategoryItem(parser) + } + CHECKBOX_PREFERENCE_TAG, + CUSTOM_CBH_SWITCH_PREFERENCE_TAG, + DEFAULT_BROWSER_PREFERENCE_TAG, + PREFERENCE_TAG, + SWITCH_PREFERENCE_TAG, + TEXT_PERCENTAGE_SEEK_BAR_PREFERENCE_TAG, + TOGGLE_RADIO_BUTTON_PREFERENCE_TAG, + -> { + val item = createSettingsSearchItemFromAttributes(parser) + if (item != null) { + settings.add(item) + } + } + RADIO_BUTTON_PREFERENCE_TAG, + SWITCH_PREFERENCE_PLAIN_TAG, + -> { + if (categoryItem != null && !categoryItemAdded) { + categoryItemAdded = true + val preferenceKey = getPreferenceKeyForRadioButtonPref(parser) + settings.add(categoryItem.copy(preferenceKey = preferenceKey ?: "")) + categoryItem = null + } else { + val item = createSettingsSearchItemFromAttributes(parser) + if (item != null) { + settings.add(item) + } + } + } + } + } + XmlResourceParser.END_TAG -> { + when (parser.name) { + PREFERENCE_CATEGORY_TAG -> { + categoryItem = null + categoryItemAdded = false + if (breadcrumbs.isNotEmpty()) { + breadcrumbs.removeLastOrNull() + } + } + } + } + } + eventType = parser.next() + } + } catch (e: IOException) { + println("Error: I/O exception while parsing ${e.message}") + } finally { + parser.close() + } + } + + private fun addCategoryToBreadcrumbs( + parser: XmlResourceParser, + ) { + for (i in 0 until parser.attributeCount) { + val attributeName = parser.getAttributeName(i) + val attributeValue = parser.getAttributeValue(i) + + when (attributeName) { + TITLE_ATTRIBUTE_NAME -> { + val categoryName = getStringResource(attributeValue.substring(1)) + if (categoryName.isNotBlank()) { + breadcrumbs.add(categoryName) + } + } + } + } + } + + private fun createSettingsSearchItemFromAttributes( + parser: XmlResourceParser, + ): SettingsSearchItem? { + var key: String? = null + var title: String? = null + var summary = "" + + for (i in 0 until parser.attributeCount) { + val attributeName = parser.getAttributeName(i) + val attributeValue = parser.getAttributeValue(i) + + when (attributeName) { + KEY_ATTRIBUTE_NAME -> { + key = attributeValue.takeIf { it.isNotBlank() } + ?.substring(1) + ?.let { getStringResource(it) } + } + TITLE_ATTRIBUTE_NAME -> { + title = attributeValue.takeIf { it.isNotBlank() } + ?.substring(1) + ?.let { getStringResource(it) } + } + SUMMARY_ATTRIBUTE_NAME -> { + summary = attributeValue.takeIf { it.isNotBlank() } + ?.substring(1) + ?.let { getStringResource(it) } + ?: "" + } + IS_VISIBLE_ATTRIBUTE_NAME -> { + if (attributeValue == "false") { + return null + } + } + } + } + + if (key == null || title == null) return null + + return SettingsSearchItem( + preferenceKey = key, + title = title, + summary = summary, + breadcrumbs = breadcrumbs.toList(), + ) + } + + /** + * Create a category item in case the category contains only radio buttons. + * + * The category item will be the reference for searching and the first radio button + * in the category will be used for navigation. + * + * @param parser [XmlResourceParser] for the category. + */ + private fun createCategoryItem( + parser: XmlResourceParser, + ): SettingsSearchItem? { + var key: String? = null + var title: String? = null + var summary = "" + + for (i in 0 until parser.attributeCount) { + val attributeName = parser.getAttributeName(i) + val attributeValue = parser.getAttributeValue(i) + + when (attributeName) { + KEY_ATTRIBUTE_NAME -> key = getStringResource(attributeValue.substring(1)) + TITLE_ATTRIBUTE_NAME -> title = getStringResource(attributeValue.substring(1)) + SUMMARY_ATTRIBUTE_NAME -> summary = getStringResource(attributeValue.substring(1)) + IS_VISIBLE_ATTRIBUTE_NAME, IS_ENABLED_ATTRIBUTE_NAME -> { + if (attributeValue == "false") { + return null + } + } + } + } + + return SettingsSearchItem( + preferenceKey = key ?: "", + title = title ?: "", + summary = summary, + breadcrumbs = breadcrumbs.toList(), + ) + } + + /** + * Get the preference key for a radio button preference. + * + * @param parser [XmlResourceParser] for the radio button preference. + */ + private fun getPreferenceKeyForRadioButtonPref(parser: XmlResourceParser): String? { + var key: String? = null + for (i in 0 until parser.attributeCount) { + val attributeName = parser.getAttributeName(i) + val attributeValue = parser.getAttributeValue(i) + + when (attributeName) { + KEY_ATTRIBUTE_NAME -> key = getStringResource(attributeValue.substring(1)) + } + } + return key + } + + /** + * Get the string resource from the given resource name. + * Uses the locale context. + * + * @param resourceName The name of the resource. + */ + private fun getStringResource(resourceName: String): String { + return try { + val resourceId = context.resources.getIdentifier( + resourceName, "string", context.packageName, + ) + if (resourceId != 0) { + context.getString(resourceId) + } else { + "" + } + } catch (e: Resources.NotFoundException) { + "" + } + } + + companion object { + // Attribute names + private const val PREFERENCE_CATEGORY_TAG = "androidx.preference.PreferenceCategory" + private const val CHECKBOX_PREFERENCE_TAG = "androidx.preference.CheckBoxPreference" + private const val PREFERENCE_TAG = "androidx.preference.Preference" + private const val SWITCH_PREFERENCE_TAG = "androidx.preference.SwitchPreference" + private const val SWITCH_PREFERENCE_PLAIN_TAG = "SwitchPreference" + private const val CUSTOM_CBH_SWITCH_PREFERENCE_TAG = + "org.mozilla.fenix.settings.cookiebannerhandling.CustomCBHSwitchPreference" + private const val DEFAULT_BROWSER_PREFERENCE_TAG = "org.mozilla.fenix.settings.DefaultBrowserPreference" + private const val TEXT_PERCENTAGE_SEEK_BAR_PREFERENCE_TAG = + "org.mozilla.fenix.settings.TextPercentageSeekBarPreference" + private const val RADIO_BUTTON_PREFERENCE_TAG = "org.mozilla.fenix.settings.RadioButtonPreference" + private const val TOGGLE_RADIO_BUTTON_PREFERENCE_TAG = "org.mozilla.fenix.settings.ToggleRadioButtonPreference" + private const val KEY_ATTRIBUTE_NAME = "key" + private const val TITLE_ATTRIBUTE_NAME = "title" + private const val SUMMARY_ATTRIBUTE_NAME = "summary" + private const val IS_VISIBLE_ATTRIBUTE_NAME = "isPreferenceVisible" + private const val IS_ENABLED_ATTRIBUTE_NAME = "enabled" + + /** + * All the preference xml files to load with information for the indexer. + * In a [List] of [PreferenceFileInformation]s. + */ + val preferenceFileInformationList = listOf( + PreferenceFileInformation( + xmlResourceId = R.xml.preferences, + topBreadcrumbResourceId = R.string.settings_title, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.accessibility_preferences, + topBreadcrumbResourceId = R.string.preferences_accessibility, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.autofill_preferences, + topBreadcrumbResourceId = R.string.preferences_autofill, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.customization_preferences, + topBreadcrumbResourceId = R.string.preferences_customize, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.default_search_engine_preferences, + topBreadcrumbResourceId = R.string.preferences_default_search_engine, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.downloads_settings_preferences, + topBreadcrumbResourceId = R.string.preferences_downloads, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.home_preferences, + topBreadcrumbResourceId = R.string.preferences_home_2, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.open_links_in_apps_preferences, + topBreadcrumbResourceId = R.string.preferences_open_links_in_apps, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.private_browsing_preferences, + topBreadcrumbResourceId = R.string.preferences_private_browsing_options, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.search_settings_preferences, + topBreadcrumbResourceId = R.string.preferences_search, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.tabs_preferences, + topBreadcrumbResourceId = R.string.preferences_tabs, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.tracking_protection_preferences, + topBreadcrumbResourceId = R.string.preference_enhanced_tracking_protection, + ), + PreferenceFileInformation( + xmlResourceId = R.xml.save_logins_preferences, + topBreadcrumbResourceId = R.string.preferences_passwords_save_logins_2, + ), + ) } } + +/** + * Data class for a settings search item navigation information based on the xml file it comes from. + * + * @property xmlResourceId The resource ID of the xml file that the item comes from. + * @property topBreadcrumbResourceId The top breadcrumb of the item as a string resource. + */ +data class PreferenceFileInformation( + val xmlResourceId: Int, + val topBreadcrumbResourceId: Int, +) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsIndexer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/settingssearch/SettingsIndexer.kt @@ -19,5 +19,5 @@ interface SettingsIndexer { * @param query The query to search for. * @return A list of [SettingsSearchItem]s that match the query. */ - fun getSettingsWithQuery(query: String): List<SettingsSearchItem> + suspend fun getSettingsWithQuery(query: String): List<SettingsSearchItem> } 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 @@ -16,6 +16,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.compose.content import androidx.navigation.fragment.findNavController import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.ext.components import org.mozilla.fenix.theme.FirefoxTheme /** @@ -53,9 +54,7 @@ class SettingsSearchFragment : Fragment() { initialState = SettingsSearchState.Default, middleware = listOf( SettingsSearchMiddleware( - SettingsSearchMiddleware.Companion.Dependencies( - context = requireContext(), - ), + fenixSettingsIndexer = requireContext().components.settingsIndexer, ), ), ) 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 @@ -4,21 +4,20 @@ package org.mozilla.fenix.settings.settingssearch -import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.MiddlewareContext /** * [Middleware] for the settings search screen. * - * @param initialDependencies [Dependencies] for the middleware. * @property fenixSettingsIndexer [SettingsIndexer] to use for indexing and querying settings. */ class SettingsSearchMiddleware( - initialDependencies: Dependencies, - val fenixSettingsIndexer: SettingsIndexer = DefaultFenixSettingsIndexer(), + val fenixSettingsIndexer: SettingsIndexer, ) : Middleware<SettingsSearchState, SettingsSearchAction> { - var dependencies = initialDependencies init { fenixSettingsIndexer.indexAllSettings() @@ -33,17 +32,18 @@ class SettingsSearchMiddleware( when (action) { is SettingsSearchAction.SearchQueryUpdated -> { next(action) - val store = context.store as SettingsSearchStore - val results = fenixSettingsIndexer.getSettingsWithQuery(action.query) - if (results.isEmpty()) { - store.dispatch(SettingsSearchAction.NoResultsFound(action.query)) - } else { - store.dispatch( - SettingsSearchAction.SearchResultsLoaded( - query = action.query, - results = results, - ), - ) + CoroutineScope(Dispatchers.Main).launch { + val results = fenixSettingsIndexer.getSettingsWithQuery(action.query) + if (results.isEmpty()) { + store.dispatch(SettingsSearchAction.NoResultsFound(action.query)) + } else { + store.dispatch( + SettingsSearchAction.SearchResultsLoaded( + query = action.query, + results = results, + ), + ) + } } } else -> { @@ -52,8 +52,4 @@ class SettingsSearchMiddleware( } } } - - companion object { - data class Dependencies(val context: Context) - } }