tor-browser

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

commit 3166cb355e99d04203aa8009403b2766d6333459
parent d4a1cd08e72f182710ea1249f38b28aefadf757a
Author: mcarare <48995920+mcarare@users.noreply.github.com>
Date:   Tue,  7 Oct 2025 06:44:20 +0000

Bug 1992735 - Refactor Language selection screen to improve performance and state management. r=android-reviewers,avirvara

This patch refactors the language selection screen for better performance and to follow modern Compose best practices.

The changes include:
- In `LocaleFragmentCompose.kt`, the `LanguagesList` composable is updated to be more efficient and stateless. It now lazily processes language items as they are displayed.
- State handling is improved by using `selectedTag` and an `onLanguageSelected` callback, making the component more reusable and predictable.
- In `LanguageFragment.kt`, the logic for creating language list items is removed, simplifying the fragment and delegating UI state to the `LanguagesList` composable. It now uses a local state for the selected tag to provide instant UI feedback.
- In `LanguageStorage.kt`, language list creation is optimized. A `languagesByTag` map is introduced for efficient lookups, and the list of languages is now lazily initialized once, preventing redundant processing.

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

Diffstat:
Mmobile/android/focus-android/app/src/main/java/org/mozilla/focus/locale/screen/LanguageFragment.kt | 59+++++++++++++++++++++--------------------------------------
Mmobile/android/focus-android/app/src/main/java/org/mozilla/focus/locale/screen/LanguageStorage.kt | 67+++++++++++++++++++++++++++++++++++--------------------------------
Mmobile/android/focus-android/app/src/main/java/org/mozilla/focus/locale/screen/LocaleFragmentCompose.kt | 104++++++++++++++++++++++++++++++++++++++++++-------------------------------------
3 files changed, 111 insertions(+), 119 deletions(-)

diff --git a/mobile/android/focus-android/app/src/main/java/org/mozilla/focus/locale/screen/LanguageFragment.kt b/mobile/android/focus-android/app/src/main/java/org/mozilla/focus/locale/screen/LanguageFragment.kt @@ -8,10 +8,11 @@ import android.os.Bundle import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState +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 kotlinx.coroutines.launch import mozilla.components.browser.state.store.BrowserStore import mozilla.components.lib.state.ext.observeAsComposableState @@ -45,6 +46,8 @@ class LanguageFragment : BaseComposeFragment() { ), ), ) + languageScreenStore.state.languageList + defaultLanguageScreenInteractor = DefaultLanguageScreenInteractor( languageScreenStore = languageScreenStore, ) @@ -55,55 +58,35 @@ class LanguageFragment : BaseComposeFragment() { @Composable override fun Content() { - val languagesList = languageScreenStore - .observeAsComposableState { state -> state.languageList }.value - val languageSelected = languageScreenStore.observeAsComposableState { state -> - state.selectedLanguage - }.value - if (languageSelected != null) { - Languages(languageSelected = languageSelected, languages = languagesList) - } - } + val languagesList by languageScreenStore.observeAsComposableState { it.languageList } + val languageSelected by languageScreenStore.observeAsComposableState { it.selectedLanguage } - private fun createLanguageListItem( - languages: List<Language>, - state: MutableState<String>, - ): List<LanguageListItem> { - val languageListItems = ArrayList<LanguageListItem>() - languages.forEach { language -> - if (language.tag == LanguageStorage.LOCALE_SYSTEM_DEFAULT) { - language.displayName = context?.getString(R.string.preference_language_systemdefault) - } - val languageListItem = LanguageListItem( - language = language, - onClick = { - state.value = language.tag - defaultLanguageScreenInteractor.handleLanguageSelected(language) - }, - ) - languageListItems.add(languageListItem) - } - return languageListItems + languageSelected?.let { Languages(languageSelected = it, languages = languagesList) } } @Composable private fun Languages(languageSelected: Language, languages: List<Language>) { val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() + + // This local state for the selected tag allows for instant UI feedback upon click. + var selectedTag by remember(languageSelected) { mutableStateOf(languageSelected.tag) } + LaunchedEffect(languageSelected.index) { coroutineScope.launch { - languageSelected.let { listState.scrollToItem(it.index) } + if (languageSelected.index in languages.indices) { + listState.scrollToItem(languageSelected.index) + } } } - val state = remember { - mutableStateOf(languageSelected.tag) - } + LanguagesList( - languageListItems = createLanguageListItem( - languages = languages, - state = state, - ), - state = state, + languages = languages, + selectedTag = selectedTag, + onLanguageSelected = { + selectedTag = it.tag + defaultLanguageScreenInteractor.handleLanguageSelected(it) + }, listState = listState, ) } diff --git a/mobile/android/focus-android/app/src/main/java/org/mozilla/focus/locale/screen/LanguageStorage.kt b/mobile/android/focus-android/app/src/main/java/org/mozilla/focus/locale/screen/LanguageStorage.kt @@ -19,8 +19,35 @@ class LanguageStorage(private val context: Context) { context.resources.getString(R.string.pref_key_locale) } + /** + * A map of available languages, keyed by their language tag. + * + * This map is lazily initialized and includes: + * 1. The "System default" language. + * 2. All bundled locales from [LocalesList.BUNDLED_LOCALES], sorted alphabetically + * by their native display names. + * + * This provides efficient look-up of a [Language] object by its tag. + */ + private val languagesByTag: Map<String, Language> by lazy { + buildMap { + put(systemDefaultLanguage.tag, systemDefaultLanguage) + LocalesList.BUNDLED_LOCALES + .map { LocaleDescriptor(it) } + .sorted() + .forEach { descriptor -> + val language = Language( + displayName = descriptor.getNativeName(), + tag = descriptor.getTag(), + index = size, + ) + put(language.tag, language) + } + } + } + internal val languages: List<Language> by lazy { - getLanguageList() + languagesByTag.values.toList() } private val systemDefaultLanguage: Language by lazy { @@ -32,37 +59,22 @@ class LanguageStorage(private val context: Context) { } /** - * The current selected Language or System default Language if nothing is selected + * The language currently selected by the user. If no language has been explicitly + * selected, this will default to the "System default" language. + * + * The value is retrieved from SharedPreferences using the `pref_key_locale`. + * If the stored language tag doesn't match any available language, it also + * falls back to the system default. */ internal val selectedLanguage: Language get() { val savedLanguageTag = sharedPref.getString(localePrefKey, LOCALE_SYSTEM_DEFAULT) ?: LOCALE_SYSTEM_DEFAULT - val matchingLanguage = languages.firstOrNull { it.tag == savedLanguageTag } - - return matchingLanguage ?: systemDefaultLanguage + return languagesByTag[savedLanguageTag] ?: systemDefaultLanguage } /** - * The full list of available languages. - * System default Language will be the first item in the list. - */ - private fun getLanguageList(): List<Language> { - return listOf( - systemDefaultLanguage, - ) + getUsableLocales().mapIndexedNotNull { i, descriptor -> - descriptor?.let { - Language( - displayName = descriptor.getNativeName(), - tag = it.getTag(), - index = i + 1, - ) - } - } - } - - /** * Saves the current selected language tag * * @param languageTag the tag of the language @@ -73,15 +85,6 @@ class LanguageStorage(private val context: Context) { } } - /** - * This method generates the descriptor array. - */ - private fun getUsableLocales(): Array<LocaleDescriptor?> { - return LocalesList.BUNDLED_LOCALES.map { - LocaleDescriptor(it) - }.sorted().toTypedArray() - } - companion object { const val LOCALE_SYSTEM_DEFAULT = "LOCALE_SYSTEM_DEFAULT" } diff --git a/mobile/android/focus-android/app/src/main/java/org/mozilla/focus/locale/screen/LocaleFragmentCompose.kt b/mobile/android/focus-android/app/src/main/java/org/mozilla/focus/locale/screen/LocaleFragmentCompose.kt @@ -22,65 +22,67 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState +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 +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch +import org.mozilla.focus.R import org.mozilla.focus.ui.theme.FocusTheme import org.mozilla.focus.ui.theme.focusColors -private fun getFakeLanguages(): List<LanguageListItem> { - return mutableListOf<LanguageListItem>().apply { - var index = 0 - add(LanguageListItem(Language("Română", "ro", index++), onClick = {})) - add(LanguageListItem(Language("Slovenčina", "sk", index++), onClick = {})) - add(LanguageListItem(Language("Português (Brasil)", "pt-BR", index++), onClick = {})) - add(LanguageListItem(Language("Nederlands", "nl", index++), onClick = {})) - add(LanguageListItem(Language("Magyar", "hu", index++), onClick = {})) - add(LanguageListItem(Language("Lietuvių", "lt", ++index), onClick = {})) - } -} - @Composable @Preview private fun LanguagesListComposablePreview() { FocusTheme { val listState = rememberLazyListState() - val coroutineScope = rememberCoroutineScope() - LaunchedEffect(0) { - coroutineScope.launch { - listState.scrollToItem(0) - } - } - val state = remember { - mutableStateOf("ro") + + val fakeLanguages = remember { + listOf( + Language("Română", "ro", 0), + Language("Slovenčina", "sk", 1), + Language("Português (Brasil)", "pt-BR", 2), + Language("Nederlands", "nl", 3), + Language("Magyar", "hu", 4), + Language("Lietuvių", "lt", 5), + ) } + + var selectedTag by remember { mutableStateOf("ro") } + LanguagesList( - languageListItems = getFakeLanguages(), - state = state, + languages = fakeLanguages, + selectedTag = selectedTag, + onLanguageSelected = { language -> + selectedTag = language.tag + }, listState = listState, ) } } /** - * Displays a list of Languages in a listView + * Displays a lazily-loaded list of languages. * - * @param languageListItems The list of Languages items to be displayed. - * @param state the current Selected Language - * @param listState scrolls to the current selected Language + * This composable is optimized for performance by processing each language item only when it's + * about to be displayed. It also handles the special case for the "System Default" language + * by resolving its display name from string resources. + * + * @param languages The list of [Language] data to display. + * @param selectedTag The tag of the currently selected language, used to highlight the correct item. + * @param onLanguageSelected A callback invoked with the selected [Language] object when an item is clicked. + * @param listState The [LazyListState] for controlling and observing the scroll state of the list. */ @Composable fun LanguagesList( - languageListItems: List<LanguageListItem>, - state: MutableState<String>, + languages: List<Language>, + selectedTag: String, + onLanguageSelected: (Language) -> Unit, listState: LazyListState, ) { FocusTheme { @@ -88,11 +90,19 @@ fun LanguagesList( state = listState, contentPadding = PaddingValues(horizontal = 12.dp), ) { - items(languageListItems) { item -> + items(languages, key = { it.tag }) { language -> + // By performing this logic here, inside the `items` block, we ensure + // that the work is only done for visible items, making the list fast. + val languageForDisplay = if (language.tag == LanguageStorage.LOCALE_SYSTEM_DEFAULT) { + language.copy(displayName = stringResource(R.string.preference_language_systemdefault)) + } else { + language + } + LanguageNameAndTagItem( - language = item.language, - isSelected = state.value == item.language.tag, - onClick = item.onClick, + language = languageForDisplay, + isSelected = selectedTag == language.tag, + onClick = { onLanguageSelected(language) }, ) } } @@ -100,22 +110,22 @@ fun LanguagesList( } @Composable -fun LanguageNameAndTagItem( +private fun LanguageNameAndTagItem( language: Language, isSelected: Boolean, - onClick: (String) -> Unit, + onClick: (Language) -> Unit, ) { Row( Modifier .fillMaxWidth() - .wrapContentHeight(), + .wrapContentHeight() + .clickable { onClick(language) }, horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { LanguageRadioButton( - language = language, isSelected = isSelected, - onClick = onClick, + onClick = { onClick(language) }, ) Spacer(modifier = Modifier.width(18.dp)) language.displayName?.let { @@ -130,22 +140,18 @@ fun LanguageNameAndTagItem( /** * Displays a single language radiobutton * - * @param language of the item * @param isSelected check or uncheck the radioButton if the language is selected or not * @param onClick Callback when the user taps on Language */ @Composable private fun LanguageRadioButton( - language: Language, isSelected: Boolean, - onClick: (String) -> Unit, + onClick: () -> Unit, ) { RadioButton( selected = isSelected, colors = RadioButtonDefaults.colors(selectedColor = focusColors.radioButtonSelected), - onClick = { - onClick(language.tag) - }, + onClick = onClick, ) } @@ -156,12 +162,12 @@ private fun LanguageRadioButton( * @param onClick Callback when the user taps on Language text , the same like on the radioButton */ @Composable -private fun LanguageDisplayName(language: Language, onClick: (String) -> Unit) { +private fun LanguageDisplayName(language: Language, onClick: (Language) -> Unit) { Text( text = AnnotatedString(language.displayName!!), style = MaterialTheme.typography.bodyLarge, modifier = Modifier .padding(10.dp) - .clickable { onClick(language.tag) }, + .clickable { onClick(language) }, ) }