From ea2411a88b16eddacfb098961eb6587a9f8c5337 Mon Sep 17 00:00:00 2001 From: Mihai Branescu Date: Sat, 4 Jan 2020 04:15:35 +0200 Subject: [PATCH] Feature/#220 language menu (#7070) * For #220 - Added advanced header + locale settings item in the settings fragment * For #220 - Added locale selection page with lib state + handling of locale changes * For #220 - Removed registering for locale changes in the manifest, allow system to restart activity in that scenario * For #220 - Added unit tests for locale settings page * For #220: fixed an outdated unit test ga-a Co-authored-by: Severin Rudie --- app/build.gradle | 18 +++ app/src/main/AndroidManifest.xml | 6 +- .../org/mozilla/fenix/FenixApplication.kt | 10 +- .../java/org/mozilla/fenix/HomeActivity.kt | 7 +- .../bookmarks/BookmarkFragmentStore.kt | 4 +- .../fenix/settings/SettingsFragment.kt | 5 +- .../DefaultLocaleSettingsController.kt | 41 +++++ .../fenix/settings/advanced/LocaleAdapter.kt | 150 ++++++++++++++++++ .../advanced/LocaleManagerExtension.kt | 49 ++++++ .../advanced/LocaleSettingsFragment.kt | 78 +++++++++ .../advanced/LocaleSettingsInteractor.kt | 23 +++ .../settings/advanced/LocaleSettingsStore.kt | 61 +++++++ .../settings/advanced/LocaleSettingsView.kt | 62 ++++++++ .../fenix/widget/VoiceSearchActivity.kt | 2 + .../res/drawable/locale_search_background.xml | 4 + .../res/layout/component_locale_settings.xml | 36 +++++ .../res/layout/fragment_locale_settings.xml | 16 ++ .../main/res/layout/locale_settings_item.xml | 52 ++++++ app/src/main/res/navigation/nav_graph.xml | 7 + app/src/main/res/values/dimens.xml | 11 ++ app/src/main/res/values/strings.xml | 10 ++ app/src/main/res/xml/preferences.xml | 9 ++ .../advanced/LocaleManagerExtensionTest.kt | 76 +++++++++ .../advanced/LocaleSettingsControllerTest.kt | 85 ++++++++++ .../advanced/LocaleSettingsInteractorTest.kt | 47 ++++++ .../advanced/LocaleSettingsStoreTest.kt | 44 +++++ buildSrc/src/main/java/Dependencies.kt | 1 + 27 files changed, 897 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/settings/advanced/DefaultLocaleSettingsController.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleAdapter.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtension.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractor.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStore.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsView.kt create mode 100644 app/src/main/res/drawable/locale_search_background.xml create mode 100644 app/src/main/res/layout/component_locale_settings.xml create mode 100644 app/src/main/res/layout/fragment_locale_settings.xml create mode 100644 app/src/main/res/layout/locale_settings_item.xml create mode 100644 app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtensionTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsControllerTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractorTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStoreTest.kt diff --git a/app/build.gradle b/app/build.gradle index 53fbfbb4e..6bb2aa788 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -429,6 +429,7 @@ dependencies { implementation Deps.mozilla_support_ktx implementation Deps.mozilla_support_rustlog implementation Deps.mozilla_support_utils + implementation Deps.mozilla_support_locale // We only care about support-migration in builds that will be overwriting Fennec. fennecProductionImplementation Deps.mozilla_support_migration @@ -602,6 +603,23 @@ task printVariants { } } +task buildTranslationArray { + def foundLocales = new StringBuilder() + foundLocales.append("new String[]{") + + fileTree("src/main/res").visit { FileVisitDetails details -> + if(details.file.path.endsWith("/strings.xml")){ + def languageCode = details.file.parent.tokenize('/').last().replaceAll('values-','').replaceAll('-r','-') + languageCode = (languageCode == "values") ? "en-US" : languageCode + foundLocales.append("\"").append(languageCode).append("\"").append(",") + } + } + + foundLocales.append("}") + def foundLocalesString = foundLocales.toString().replaceAll(',}','}') + android.defaultConfig.buildConfigField "String[]", "SUPPORTED_LOCALE_ARRAY", foundLocalesString +} + def glean_android_components_tag = ( Versions.mozilla_android_components.endsWith('-SNAPSHOT') ? 'master' : diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6be9be394..0fe9fc110 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,7 +50,7 @@ @@ -81,7 +81,7 @@ diff --git a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt index 486339924..04a83634b 100644 --- a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt +++ b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt @@ -5,19 +5,18 @@ package org.mozilla.fenix import android.annotation.SuppressLint -import android.app.Application import android.os.Build import android.os.Build.VERSION.SDK_INT import android.os.StrictMode import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.getSystemService -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import mozilla.appservices.Megazord import mozilla.components.concept.push.PushProcessor import mozilla.components.service.experiments.Experiments @@ -29,6 +28,7 @@ import mozilla.components.support.base.log.logger.Logger import mozilla.components.support.base.log.sink.AndroidLogSink import mozilla.components.support.ktx.android.content.isMainProcess import mozilla.components.support.ktx.android.content.runOnlyInMainProcess +import mozilla.components.support.locale.LocaleAwareApplication import mozilla.components.support.rusthttp.RustHttpConfig import mozilla.components.support.rustlog.RustLog import org.mozilla.fenix.GleanMetrics.ExperimentsMetrics @@ -40,7 +40,7 @@ import java.io.File @SuppressLint("Registered") @Suppress("TooManyFunctions") -open class FenixApplication : Application() { +open class FenixApplication : LocaleAwareApplication() { lateinit var fretboard: Fretboard lateinit var experimentLoader: Deferred diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index b611010ed..58ff88c44 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -13,7 +13,6 @@ import androidx.annotation.CallSuper import androidx.annotation.IdRes import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.PROTECTED -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDestination @@ -30,6 +29,7 @@ import mozilla.components.service.fxa.sync.SyncReason import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.ktx.kotlin.isUrl import mozilla.components.support.ktx.kotlin.toNormalizedUrl +import mozilla.components.support.locale.LocaleAwareAppCompatActivity import mozilla.components.support.utils.SafeIntent import mozilla.components.support.utils.toSafeIntent import org.mozilla.fenix.browser.UriOpenedObserver @@ -62,7 +62,7 @@ import org.mozilla.fenix.theme.DefaultThemeManager import org.mozilla.fenix.theme.ThemeManager @SuppressWarnings("TooManyFunctions", "LargeClass") -open class HomeActivity : AppCompatActivity() { +open class HomeActivity : LocaleAwareAppCompatActivity() { lateinit var themeManager: ThemeManager lateinit var browsingModeManager: BrowsingModeManager @@ -239,8 +239,9 @@ open class HomeActivity : AppCompatActivity() { } fun openToBrowser(from: BrowserDirection, customTabSessionId: String? = null) { - if (sessionObserver == null) + if (sessionObserver == null) { sessionObserver = UriOpenedObserver(this) + } if (navHost.navController.alreadyOnDestination(R.id.browserFragment)) return @IdRes val fragmentId = if (from.fragmentId != 0) from.fragmentId else null diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStore.kt index 0005323f9..8bdcd4617 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentStore.kt @@ -11,9 +11,9 @@ import mozilla.components.lib.state.State import mozilla.components.lib.state.Store class BookmarkFragmentStore( - initalState: BookmarkFragmentState + initialState: BookmarkFragmentState ) : Store( - initalState, ::bookmarkFragmentStateReducer + initialState, ::bookmarkFragmentStateReducer ) /** diff --git a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt index 512d2ebcf..50359f907 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt @@ -62,7 +62,6 @@ import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.settings.account.AccountAuthErrorPreference import org.mozilla.fenix.settings.account.AccountPreference -import org.mozilla.fenix.utils.ItsNotBrokenSnack @Suppress("LargeClass") class SettingsFragment : PreferenceFragmentCompat() { @@ -210,9 +209,7 @@ class SettingsFragment : PreferenceFragmentCompat() { SettingsFragmentDirections.actionSettingsFragmentToAccessibilityFragment() } resources.getString(pref_key_language) -> { - // TODO #220 - ItsNotBrokenSnack(requireContext()).showSnackbar(issueNumber = "220") - null + SettingsFragmentDirections.actionSettingsFragmentToLocaleSettingsFragment() } resources.getString(pref_key_make_default_browser) -> { SettingsFragmentDirections.actionSettingsFragmentToDefaultBrowserSettingsFragment() diff --git a/app/src/main/java/org/mozilla/fenix/settings/advanced/DefaultLocaleSettingsController.kt b/app/src/main/java/org/mozilla/fenix/settings/advanced/DefaultLocaleSettingsController.kt new file mode 100644 index 000000000..2bd481c84 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/advanced/DefaultLocaleSettingsController.kt @@ -0,0 +1,41 @@ +/* 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.settings.advanced + +import android.app.Activity +import android.content.Context +import mozilla.components.support.locale.LocaleManager +import java.util.Locale + +interface LocaleSettingsController { + fun handleLocaleSelected(locale: Locale) + fun handleSearchQueryTyped(query: String) + fun handleDefaultLocaleSelected() +} + +class DefaultLocaleSettingsController( + private val context: Context, + private val localeSettingsStore: LocaleSettingsStore +) : LocaleSettingsController { + + override fun handleLocaleSelected(locale: Locale) { + if (localeSettingsStore.state.selectedLocale == locale) { + return + } + localeSettingsStore.dispatch(LocaleSettingsAction.Select(locale)) + LocaleManager.setNewLocale(context, locale.toLanguageTag()) + (context as Activity).recreate() + } + + override fun handleDefaultLocaleSelected() { + localeSettingsStore.dispatch(LocaleSettingsAction.Select(localeSettingsStore.state.localeList[0])) + LocaleManager.resetToSystemDefault(context) + (context as Activity).recreate() + } + + override fun handleSearchQueryTyped(query: String) { + localeSettingsStore.dispatch(LocaleSettingsAction.Search(query)) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleAdapter.kt b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleAdapter.kt new file mode 100644 index 000000000..1faba7e62 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleAdapter.kt @@ -0,0 +1,150 @@ +/* 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.settings.advanced + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import kotlinx.android.synthetic.main.locale_settings_item.view.locale_selected_icon +import kotlinx.android.synthetic.main.locale_settings_item.view.locale_subtitle_text +import kotlinx.android.synthetic.main.locale_settings_item.view.locale_title_text +import org.mozilla.fenix.R +import java.util.Locale + +class LocaleAdapter(private val interactor: LocaleSettingsViewInteractor) : + RecyclerView.Adapter() { + + private var localeList: List = listOf() + private var selectedLocale: Locale = Locale.getDefault() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseLocaleViewHolder { + val view = + LayoutInflater.from(parent.context) + .inflate(R.layout.locale_settings_item, parent, false) + + return when (viewType) { + ItemType.DEFAULT.ordinal -> SystemLocaleViewHolder( + view, + interactor, + selectedLocale + ) + ItemType.LOCALE.ordinal -> LocaleViewHolder( + view, + interactor, + selectedLocale + ) + else -> throw IllegalStateException("ViewType $viewType does not match to a ViewHolder") + } + } + + override fun getItemCount(): Int { + return localeList.size + } + + override fun onBindViewHolder(holder: BaseLocaleViewHolder, position: Int) { + holder.bind(localeList[position]) + } + + override fun getItemViewType(position: Int): Int { + return when (position) { + 0 -> ItemType.DEFAULT + else -> ItemType.LOCALE + }.ordinal + } + + fun updateData(localeList: List, selectedLocale: Locale) { + val diffUtil = DiffUtil.calculateDiff( + LocaleDiffUtil( + this.localeList, + localeList, + this.selectedLocale, + selectedLocale + ) + ) + this.localeList = localeList + this.selectedLocale = selectedLocale + + diffUtil.dispatchUpdatesTo(this) + } + + inner class LocaleDiffUtil( + private val old: List, + private val new: List, + private val oldSelectedLocale: Locale, + private val newSelectedLocale: Locale + ) : DiffUtil.Callback() { + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val selectionChanged = + old[oldItemPosition] == oldSelectedLocale && oldSelectedLocale != newSelectedLocale + return old[oldItemPosition] == new[newItemPosition] && !selectionChanged + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + old[oldItemPosition].toLanguageTag() == new[newItemPosition].toLanguageTag() + + override fun getOldListSize(): Int = old.size + override fun getNewListSize(): Int = new.size + } + + enum class ItemType { + DEFAULT, LOCALE; + } +} + +class LocaleViewHolder( + view: View, + private val interactor: LocaleSettingsViewInteractor, + private val selectedLocale: Locale +) : BaseLocaleViewHolder(view) { + private val icon = view.locale_selected_icon + private val title = view.locale_title_text + private val subtitle = view.locale_subtitle_text + + override fun bind(locale: Locale) { + // capitalisation is done using the rules of the appropriate locale (endonym and exonym) + title.text = locale.getDisplayName(locale).capitalize(locale) + subtitle.text = locale.displayName.capitalize(Locale.getDefault()) + icon.isVisible = locale === selectedLocale + + itemView.setOnClickListener { + interactor.onLocaleSelected(locale) + } + } +} + +class SystemLocaleViewHolder( + view: View, + private val interactor: LocaleSettingsViewInteractor, + private val selectedLocale: Locale +) : BaseLocaleViewHolder(view) { + private val icon = view.locale_selected_icon + private val title = view.locale_title_text + private val subtitle = view.locale_subtitle_text + + override fun bind(locale: Locale) { + title.text = itemView.context.getString(R.string.default_locale_text) + subtitle.visibility = View.GONE + icon.isVisible = locale === selectedLocale + + itemView.setOnClickListener { + interactor.onDefaultLocaleSelected() + } + } +} + +abstract class BaseLocaleViewHolder(view: View) : RecyclerView.ViewHolder(view) { + abstract fun bind(locale: Locale) +} + +/** + * Similar to Kotlin's capitalize with locale parameter, but that method is currently experimental + */ +private fun String.capitalize(locale: Locale): String { + return substring(0, 1).toUpperCase(locale) + substring(1) +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtension.kt b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtension.kt new file mode 100644 index 000000000..17da2e811 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtension.kt @@ -0,0 +1,49 @@ +/* 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.settings.advanced + +import android.content.Context +import mozilla.components.support.locale.LocaleManager +import mozilla.components.support.locale.toLocale +import org.mozilla.fenix.BuildConfig +import java.util.Locale + +/** + * Returns a list of currently supported locales, with the system default set as the first one + */ +fun LocaleManager.getSupportedLocales(): List { + val resultLocaleList: MutableList = ArrayList() + resultLocaleList.add(0, getSystemDefault() ?: Locale.getDefault()) + + resultLocaleList.addAll(BuildConfig.SUPPORTED_LOCALE_ARRAY + .toList() + .map { + it.toLocale() + }.sortedWith(compareBy( + { it.displayLanguage }, + { it.displayCountry } + ))) + return resultLocaleList +} + +/** + * Returns the locale that corresponds to the language stored locally by us. If no suitable one is found, + * return default. + */ +fun LocaleManager.getSelectedLocale( + context: Context, + localeList: List = getSupportedLocales() +): Locale { + val selectedLocale = getCurrentLocale(context)?.toLanguageTag() + val defaultLocale = getSystemDefault() ?: Locale.getDefault() + + return if (selectedLocale == null) { + defaultLocale + } else { + val supportedMatch = localeList + .firstOrNull { it.toLanguageTag() == selectedLocale } + supportedMatch ?: defaultLocale + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt new file mode 100644 index 000000000..1448c3f37 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsFragment.kt @@ -0,0 +1,78 @@ +/* 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.settings.advanced + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import kotlinx.android.synthetic.main.fragment_locale_settings.view.locale_container +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.lib.state.ext.consumeFrom +import mozilla.components.support.ktx.android.view.hideKeyboard +import mozilla.components.support.locale.LocaleManager +import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.ext.showToolbar + +class LocaleSettingsFragment : Fragment() { + + private lateinit var store: LocaleSettingsStore + private lateinit var interactor: LocaleSettingsInteractor + private lateinit var localeView: LocaleSettingsView + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_locale_settings, container, false) + + store = getStore() + interactor = LocaleSettingsInteractor( + controller = DefaultLocaleSettingsController( + context = requireContext(), + localeSettingsStore = store + ) + ) + localeView = LocaleSettingsView(view.locale_container, interactor) + return view + } + + override fun onResume() { + super.onResume() + localeView.onResume() + showToolbar(getString(R.string.preferences_language)) + } + + override fun onPause() { + view?.hideKeyboard() + super.onPause() + } + + @ExperimentalCoroutinesApi + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + consumeFrom(store) { + localeView.update(it) + } + } + + private fun getStore(): LocaleSettingsStore { + val supportedLocales = LocaleManager.getSupportedLocales() + val selectedLocale = LocaleManager.getSelectedLocale(requireContext()) + + return StoreProvider.get(this) { + LocaleSettingsStore( + LocaleSettingsState( + supportedLocales, + supportedLocales, + selectedLocale + ) + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractor.kt new file mode 100644 index 000000000..f7f99c26d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractor.kt @@ -0,0 +1,23 @@ +/* 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.settings.advanced + +import java.util.Locale + +class LocaleSettingsInteractor(private val controller: LocaleSettingsController) : + LocaleSettingsViewInteractor { + + override fun onLocaleSelected(locale: Locale) { + controller.handleLocaleSelected(locale) + } + + override fun onDefaultLocaleSelected() { + controller.handleDefaultLocaleSelected() + } + + override fun onSearchQueryTyped(query: String) { + controller.handleSearchQueryTyped(query) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStore.kt b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStore.kt new file mode 100644 index 000000000..98dc4ce66 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStore.kt @@ -0,0 +1,61 @@ +/* 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.settings.advanced + +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import java.util.Locale + +class LocaleSettingsStore( + initialState: LocaleSettingsState +) : Store( + initialState, ::localeSettingsStateReducer +) + +/** + * The state of the language selection page + * @property localeList The full list of locales available + * @property searchedLocaleList The list of locales starting with a search query + * @property selectedLocale The current selected locale + */ +data class LocaleSettingsState( + val localeList: List, + val searchedLocaleList: List, + val selectedLocale: Locale +) : State + +/** + * Actions to dispatch through the `LocaleSettingsStore` to modify `LocaleSettingsState` through the reducer. + */ +sealed class LocaleSettingsAction : Action { + data class Select(val selectedItem: Locale) : LocaleSettingsAction() + data class Search(val query: String) : LocaleSettingsAction() +} + +/** + * Reduces the locale state from the current state and an action performed on it. + * @param state the current locale state + * @param action the action to perform + * @return the new locale state + */ +private fun localeSettingsStateReducer( + state: LocaleSettingsState, + action: LocaleSettingsAction +): LocaleSettingsState { + return when (action) { + is LocaleSettingsAction.Select -> { + state.copy(selectedLocale = action.selectedItem) + } + is LocaleSettingsAction.Search -> { + val searchedItems = state.localeList.filter { + it.getDisplayLanguage(it).startsWith(action.query, ignoreCase = true) || + it.displayLanguage.startsWith(action.query, ignoreCase = true) || + it === state.localeList[0] + } + state.copy(searchedLocaleList = searchedItems) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsView.kt b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsView.kt new file mode 100644 index 000000000..0a204f5ee --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/advanced/LocaleSettingsView.kt @@ -0,0 +1,62 @@ +/* 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.settings.advanced + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SearchView +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.android.synthetic.main.component_locale_settings.view.locale_list +import kotlinx.android.synthetic.main.component_locale_settings.view.toolbar_container +import org.mozilla.fenix.R +import java.util.Locale + +interface LocaleSettingsViewInteractor { + + fun onLocaleSelected(locale: Locale) + + fun onDefaultLocaleSelected() + + fun onSearchQueryTyped(query: String) +} + +class LocaleSettingsView( + container: ViewGroup, + val interactor: LocaleSettingsViewInteractor +) { + + val view: View = LayoutInflater.from(container.context) + .inflate(R.layout.component_locale_settings, container, true) + + private val localeAdapter: LocaleAdapter + + init { + view.locale_list.apply { + localeAdapter = LocaleAdapter(interactor) + adapter = localeAdapter + layoutManager = LinearLayoutManager(context) + } + val searchView: SearchView = view.toolbar_container + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + return false + } + + override fun onQueryTextChange(newText: String): Boolean { + interactor.onSearchQueryTyped(newText) + return false + } + }) + } + + fun update(state: LocaleSettingsState) { + localeAdapter.updateData(state.searchedLocaleList, state.selectedLocale) + } + + fun onResume() { + view.requestFocus() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/widget/VoiceSearchActivity.kt b/app/src/main/java/org/mozilla/fenix/widget/VoiceSearchActivity.kt index 03f701ed9..c9232ba8b 100644 --- a/app/src/main/java/org/mozilla/fenix/widget/VoiceSearchActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/widget/VoiceSearchActivity.kt @@ -9,6 +9,7 @@ import android.content.Intent import android.os.Bundle import android.speech.RecognizerIntent import androidx.appcompat.app.AppCompatActivity +import mozilla.components.support.locale.LocaleManager import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.IntentReceiverActivity import org.mozilla.fenix.components.metrics.Event @@ -57,6 +58,7 @@ class VoiceSearchActivity : AppCompatActivity() { private fun displaySpeechRecognizer() { val intentSpeech = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, LocaleManager.getCurrentLocale(this@VoiceSearchActivity)) } metrics.track(Event.SearchWidgetVoiceSearchPressed) diff --git a/app/src/main/res/drawable/locale_search_background.xml b/app/src/main/res/drawable/locale_search_background.xml new file mode 100644 index 000000000..d12848427 --- /dev/null +++ b/app/src/main/res/drawable/locale_search_background.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/component_locale_settings.xml b/app/src/main/res/layout/component_locale_settings.xml new file mode 100644 index 000000000..bdb330d4e --- /dev/null +++ b/app/src/main/res/layout/component_locale_settings.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_locale_settings.xml b/app/src/main/res/layout/fragment_locale_settings.xml new file mode 100644 index 000000000..2ce083109 --- /dev/null +++ b/app/src/main/res/layout/fragment_locale_settings.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/locale_settings_item.xml b/app/src/main/res/layout/locale_settings_item.xml new file mode 100644 index 000000000..cca72bc3d --- /dev/null +++ b/app/src/main/res/layout/locale_settings_item.xml @@ -0,0 +1,52 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 054e772f8..dd7eaacf8 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -397,6 +397,9 @@ + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 34cc1a56c..8d00a8390 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -93,4 +93,15 @@ 16sp 4dp + + 8dp + -12dp + 16dp + 8dp + 16dp + 40dp + 16sp + 12sp + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12fac2498..ccd79ba38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -108,6 +108,16 @@ Appearance + + + Selected language + + Search + + Follow device language + + Search language + Scan diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 50f3c5c3b..fc69c266a 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -101,6 +101,15 @@ app:isPreferenceVisible="@bool/IS_DEBUG" /> + + + + diff --git a/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtensionTest.kt b/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtensionTest.kt new file mode 100644 index 000000000..ed9a4a5be --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleManagerExtensionTest.kt @@ -0,0 +1,76 @@ +/* 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.settings.advanced + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import mozilla.components.support.locale.LocaleManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.BuildConfig +import org.mozilla.fenix.TestApplication +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(application = TestApplication::class) +class LocaleManagerExtensionTest { + + @Before + fun setup() { + mockkStatic("org.mozilla.fenix.settings.advanced.LocaleManagerExtensionKt") + } + + @Test + @Config(qualifiers = "en-rUS") + fun `build supported locale list`() { + val list = LocaleManager.getSupportedLocales() + + // Expect all supported locales + 'follow default option' + val expectedSize = BuildConfig.SUPPORTED_LOCALE_ARRAY.size + 1 + + assertEquals(expectedSize, list.size) + assertTrue(list.isNotEmpty()) + } + + @Test + @Config(qualifiers = "en-rUS") + fun `match current stored locale string with a Locale from our list`() { + val context: Context = mockk() + mockkObject(LocaleManager) + val otherLocale = Locale("fr") + val selectedLocale = Locale("en", "UK") + val localeList = ArrayList() + localeList.add(otherLocale) + localeList.add(selectedLocale) + + every { LocaleManager.getCurrentLocale(context) } returns selectedLocale + + assertEquals(selectedLocale, LocaleManager.getSelectedLocale(context, localeList)) + } + + @Test + @Config(qualifiers = "en-rUS") + fun `match null stored locale with the default Locale from our list`() { + val context: Context = mockk() + mockkObject(LocaleManager) + val firstLocale = Locale("fr") + val secondLocale = Locale("en", "UK") + val localeList = ArrayList() + localeList.add(firstLocale) + localeList.add(secondLocale) + + every { LocaleManager.getCurrentLocale(context) } returns null + + assertEquals("en-US", LocaleManager.getSelectedLocale(context, localeList).toLanguageTag()) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsControllerTest.kt b/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsControllerTest.kt new file mode 100644 index 000000000..cc5b0110b --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsControllerTest.kt @@ -0,0 +1,85 @@ +/* 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.settings.advanced + +import android.app.Activity +import android.content.Context +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import mozilla.components.support.locale.LocaleManager +import mozilla.components.support.test.mock +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class LocaleSettingsControllerTest { + + private val context: Context = mockk(relaxed = true) + private val localeSettingsStore: LocaleSettingsStore = mockk(relaxed = true) + + private lateinit var controller: LocaleSettingsController + + @Before + fun setup() { + controller = DefaultLocaleSettingsController(context, localeSettingsStore) + } + + @Test + fun `set a new locale from the list`() { + val selectedLocale = Locale("en", "UK") + val otherLocale: Locale = mock() + every { localeSettingsStore.state } returns LocaleSettingsState( + mockk(), + mockk(), + otherLocale + ) + mockkObject(LocaleManager) + every { + LocaleManager.setNewLocale( + context, + selectedLocale.toLanguageTag() + ) + } returns context + + controller.handleLocaleSelected(selectedLocale) + + verify { localeSettingsStore.dispatch(LocaleSettingsAction.Select(selectedLocale)) } + verify { LocaleManager.setNewLocale(context, selectedLocale.toLanguageTag()) } + verify { (context as Activity).recreate() } + } + + @Test + fun `set the default locale as the new locale`() { + val selectedLocale = Locale("en", "UK") + val localeList = ArrayList() + localeList.add(selectedLocale) + every { localeSettingsStore.state } returns LocaleSettingsState( + localeList, + mockk(), + mockk() + ) + mockkObject(LocaleManager) + every { LocaleManager.resetToSystemDefault(context) } just Runs + + controller.handleDefaultLocaleSelected() + + verify { localeSettingsStore.dispatch(LocaleSettingsAction.Select(selectedLocale)) } + verify { LocaleManager.resetToSystemDefault(context) } + verify { (context as Activity).recreate() } + } + + @Test + fun `handle search query typed`() { + val query = "Eng" + + controller.handleSearchQueryTyped(query) + + verify { localeSettingsStore.dispatch(LocaleSettingsAction.Search(query)) } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractorTest.kt new file mode 100644 index 000000000..0eb75c9c7 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsInteractorTest.kt @@ -0,0 +1,47 @@ +/* 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.settings.advanced + +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class LocaleSettingsInteractorTest { + + private lateinit var interactor: LocaleSettingsInteractor + private val controller: LocaleSettingsController = mockk(relaxed = true) + + @Before + fun setup() { + interactor = LocaleSettingsInteractor(controller) + } + + @Test + fun `locale was selected from list`() { + val locale: Locale = mockk() + + interactor.onLocaleSelected(locale) + + verify { controller.handleLocaleSelected(locale) } + } + + @Test + fun `default locale was selected from list`() { + interactor.onDefaultLocaleSelected() + + verify { controller.handleDefaultLocaleSelected() } + } + + @Test + fun `search query was typed`() { + val query = "Eng" + + interactor.onSearchQueryTyped(query) + + verify { controller.handleSearchQueryTyped(query) } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStoreTest.kt b/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStoreTest.kt new file mode 100644 index 000000000..24862c5a8 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/advanced/LocaleSettingsStoreTest.kt @@ -0,0 +1,44 @@ +/* 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.settings.advanced + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class LocaleSettingsStoreTest { + + private lateinit var localeSettingsStore: LocaleSettingsStore + private val selectedLocale = Locale("en", "UK") + private val otherLocale = Locale("fr") + + @Before + fun setup() { + val localeList = ArrayList() + localeList.add(Locale("fr")) // default + localeList.add(otherLocale) + localeList.add(selectedLocale) + + localeSettingsStore = + LocaleSettingsStore(LocaleSettingsState(localeList, localeList, selectedLocale)) + } + + @Test + fun `change selected locale`() = runBlocking { + localeSettingsStore.dispatch(LocaleSettingsAction.Select(otherLocale)).join() + + assertEquals(otherLocale, localeSettingsStore.state.selectedLocale) + } + + @Test + fun `change selected list by search query`() = runBlocking { + localeSettingsStore.dispatch(LocaleSettingsAction.Search("Eng")).join() + + assertEquals(2, (localeSettingsStore.state.searchedLocaleList as ArrayList).size) + assertEquals(selectedLocale, localeSettingsStore.state.searchedLocaleList[1]) + } +} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 21535a5f9..4c38ce81a 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -147,6 +147,7 @@ object Deps { const val mozilla_support_utils = "org.mozilla.components:support-utils:${Versions.mozilla_android_components}" const val mozilla_support_test = "org.mozilla.components:support-test:${Versions.mozilla_android_components}" const val mozilla_support_migration = "org.mozilla.components:support-migration:${Versions.mozilla_android_components}" + const val mozilla_support_locale = "org.mozilla.components:support-locale:${Versions.mozilla_android_components}" const val sentry = "io.sentry:sentry-android:${Versions.sentry}" const val leakcanary = "com.squareup.leakcanary:leakcanary-android:${Versions.leakcanary}"