1
0
Fork 0

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 <Baron-Severin@users.noreply.github.com>
master
Mihai Branescu 2020-01-04 04:15:35 +02:00 committed by Severin Rudie
parent 9cbc3f7a4a
commit ea2411a88b
27 changed files with 897 additions and 17 deletions

View File

@ -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' :

View File

@ -50,7 +50,7 @@
<activity
android:name=".HomeActivity"
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|locale|layoutDirection|smallestScreenSize|screenLayout"
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|layoutDirection|smallestScreenSize|screenLayout"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@ -81,7 +81,7 @@
<activity
android:name=".customtabs.ExternalAppBrowserActivity"
android:autoRemoveFromRecents="false"
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|locale|layoutDirection|smallestScreenSize|screenLayout"
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|layoutDirection|smallestScreenSize|screenLayout"
android:exported="false"
android:label="@string/app_name"
android:persistableMode="persistNever"
@ -151,7 +151,7 @@
<activity
android:name=".settings.account.AuthCustomTabActivity"
android:autoRemoveFromRecents="false"
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|locale|layoutDirection|smallestScreenSize|screenLayout"
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|layoutDirection|smallestScreenSize|screenLayout"
android:exported="false"
android:taskAffinity=""
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />

View File

@ -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<Boolean>

View File

@ -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

View File

@ -11,9 +11,9 @@ import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
class BookmarkFragmentStore(
initalState: BookmarkFragmentState
initialState: BookmarkFragmentState
) : Store<BookmarkFragmentState, BookmarkFragmentAction>(
initalState, ::bookmarkFragmentStateReducer
initialState, ::bookmarkFragmentStateReducer
)
/**

View File

@ -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()

View File

@ -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))
}
}

View File

@ -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<BaseLocaleViewHolder>() {
private var localeList: List<Locale> = 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<Locale>, 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<Locale>,
private val new: List<Locale>,
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)
}

View File

@ -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<Locale> {
val resultLocaleList: MutableList<Locale> = 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<Locale> = 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
}
}

View File

@ -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
)
)
}
}
}

View File

@ -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)
}
}

View File

@ -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<LocaleSettingsState, LocaleSettingsAction>(
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<Locale>,
val searchedLocaleList: List<Locale>,
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)
}
}
}

View File

@ -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()
}
}

View File

@ -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)

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?foundation" />
</shape>

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="true"
android:focusableInTouchMode="true">
<SearchView
android:id="@+id/toolbar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/locale_search_bar_margin"
android:background="@drawable/search_url_background"
android:closeIcon="@drawable/ic_close"
android:iconifiedByDefault="false"
android:paddingStart="@dimen/locale_search_bar_padding_start"
android:paddingEnd="0dp"
android:queryBackground="@android:color/transparent"
android:queryHint="@string/locale_search_hint"
android:searchIcon="@drawable/ic_search"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/locale_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="@dimen/locale_list_margin"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/locale_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/locale_selected_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/locale_item_vertical_margin"
android:layout_marginBottom="@dimen/locale_item_vertical_margin"
android:contentDescription="@string/a11y_selected_locale_content_description"
android:src="@drawable/mozac_ic_check"
android:tint="?primaryText"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/locale_title_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/locale_item_text_margin_start"
android:layout_marginTop="@dimen/locale_item_vertical_margin"
android:textColor="?primaryText"
app:layout_goneMarginStart="@dimen/locale_item_text_margin_gone_start"
android:textSize="@dimen/locale_item_title_size"
app:layout_constraintBottom_toTopOf="@+id/locale_subtitle_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/locale_selected_icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:layout_goneMarginBottom="@dimen/locale_item_vertical_margin" />
<TextView
android:id="@+id/locale_subtitle_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/locale_item_text_margin_start"
android:layout_marginBottom="@dimen/locale_item_vertical_margin"
android:textColor="?secondaryText"
android:textSize="@dimen/locale_item_subtitle_size"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_goneMarginStart="@dimen/locale_item_text_margin_gone_start"
app:layout_constraintStart_toEndOf="@+id/locale_selected_icon"
app:layout_constraintTop_toBottomOf="@+id/locale_title_text"
app:layout_constraintVertical_chainStyle="packed" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -397,6 +397,9 @@
<action
android:id="@+id/action_settingsFragment_to_toolbarSettingsFragment"
app:destination="@id/toolbarSettingsFragment" />
<action
android:id="@+id/action_settingsFragment_to_localeSettingsFragment"
app:destination="@id/localeSettingsFragment" />
</fragment>
<fragment
android:id="@+id/dataChoicesFragment"
@ -687,4 +690,8 @@
android:id="@+id/toolbarSettingsFragment"
android:name="org.mozilla.fenix.settings.ToolbarSettingsFragment"
android:label="ToolbarSettingsFragment" />
<fragment
android:id="@+id/localeSettingsFragment"
android:name="org.mozilla.fenix.settings.advanced.LocaleSettingsFragment"
android:label="LanguageSettingsFragment" />
</navigation>

View File

@ -93,4 +93,15 @@
<dimen name="about_items_text_size">16sp</dimen>
<dimen name="about_header_text_line_spacing_extra">4dp</dimen>
<!-- Locale Settings Fragment -->
<dimen name="locale_search_bar_margin">8dp</dimen>
<dimen name="locale_search_bar_padding_start">-12dp</dimen>
<dimen name="locale_list_margin">16dp</dimen>
<dimen name="locale_item_vertical_margin">8dp</dimen>
<dimen name="locale_item_text_margin_start">16dp</dimen>
<dimen name="locale_item_text_margin_gone_start">40dp</dimen>
<dimen name="locale_item_title_size">16sp</dimen>
<dimen name="locale_item_subtitle_size">12sp</dimen>
</resources>

View File

@ -108,6 +108,16 @@
<!-- Browser menu button to configure reader mode appearance e.g. the used font type and size -->
<string name="browser_menu_read_appearance">Appearance</string>
<!-- Locale Settings Fragment -->
<!-- Content description for tick mark on selected language -->
<string name="a11y_selected_locale_content_description">Selected language</string>
<!-- Content description for search icon -->
<string name="a11y_search_icon_content_description">Search</string>
<!-- Text for default locale item -->
<string name="default_locale_text">Follow device language</string>
<!-- Placeholder text shown in the search bar before a user enters text -->
<string name="locale_search_hint">Search language</string>
<!-- Search Fragment -->
<!-- Button in the search view that lets a user search by scanning a QR code -->
<string name="search_scan_button">Scan</string>

View File

@ -101,6 +101,15 @@
app:isPreferenceVisible="@bool/IS_DEBUG" />
</androidx.preference.PreferenceCategory>
<PreferenceCategory
android:title="@string/preferences_category_advanced"
app:iconSpaceReserved="false">
<androidx.preference.Preference
android:icon="@drawable/ic_language"
android:key="@string/pref_key_language"
android:title="@string/preferences_language" />
</PreferenceCategory>
<PreferenceCategory
android:title="@string/developer_tools_category"
app:iconSpaceReserved="false">

View File

@ -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<Locale>()
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<Locale>()
localeList.add(firstLocale)
localeList.add(secondLocale)
every { LocaleManager.getCurrentLocale(context) } returns null
assertEquals("en-US", LocaleManager.getSelectedLocale(context, localeList).toLanguageTag())
}
}

View File

@ -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<Activity>(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<Locale>()
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)) }
}
}

View File

@ -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) }
}
}

View File

@ -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<Locale>()
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])
}
}

View File

@ -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}"