Enable setting the default posting language from Tusky (#2946)

* Extract locale utils

* Extract makeIcon

* Allow setting the (server-synchronized) default posting language from Tusky.
Closes #2902

* Add copyright headers

* Address review feedback
This commit is contained in:
Levi Bard 2022-12-02 19:19:17 +01:00 committed by GitHub
parent cc790ccf69
commit 588307f7a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 190 additions and 80 deletions

View File

@ -22,6 +22,7 @@ import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.TextView import android.widget.TextView
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getTuskyDisplayName
import com.keylesspalace.tusky.util.modernLanguageCode import com.keylesspalace.tusky.util.modernLanguageCode
import java.util.Locale import java.util.Locale
@ -37,8 +38,7 @@ class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : Ar
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
return (super.getDropDownView(position, convertView, parent) as TextView).apply { return (super.getDropDownView(position, convertView, parent) as TextView).apply {
setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary)) setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary))
val locale = super.getItem(position) text = super.getItem(position)?.getTuskyDisplayName(context)
text = "${locale?.displayLanguage} (${locale?.getDisplayLanguage(locale)})"
} }
} }
} }

View File

@ -47,11 +47,9 @@ import androidx.annotation.ColorInt
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.os.LocaleListCompat
import androidx.core.view.ContentInfoCompat import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone import androidx.core.view.isGone
@ -90,6 +88,8 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.PickMediaFiles import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged import com.keylesspalace.tusky.util.afterTextChanged
import com.keylesspalace.tusky.util.getInitialLanguage
import com.keylesspalace.tusky.util.getLocaleList
import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.highlightSpans
@ -265,7 +265,7 @@ class ComposeActivity :
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
} }
setupLanguageSpinner(getInitialLanguage(composeOptions?.language)) setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount))
setupComposeField(preferences, viewModel.startingText) setupComposeField(preferences, viewModel.startingText)
setupContentWarningField(composeOptions?.contentWarning) setupContentWarningField(composeOptions?.contentWarning)
setupPollView() setupPollView()
@ -536,54 +536,7 @@ class ComposeActivity :
) )
} }
private fun mergeLocaleListCompat(list: MutableList<Locale>, localeListCompat: LocaleListCompat) {
for (index in 0 until localeListCompat.size()) {
val locale = localeListCompat[index]
if (locale != null && list.none { locale.language == it.language }) {
list.add(locale)
}
}
}
// Ensure that the locale whose code matches the given language is first in the list
private fun ensureLanguageIsFirst(locales: MutableList<Locale>, language: String) {
var currentLocaleIndex = locales.indexOfFirst { it.language == language }
if (currentLocaleIndex < 0) {
// Recheck against modern language codes
// This should only happen when replying or when the per-account post language is set
// to a modern code
currentLocaleIndex = locales.indexOfFirst { it.modernLanguageCode == language }
if (currentLocaleIndex < 0) {
// This can happen when:
// - Your per-account posting language is set to one android doesn't know (e.g. toki pona)
// - Replying to a post in a language android doesn't know
locales.add(0, Locale(language))
Log.w(TAG, "Attempting to use unknown language tag '$language'")
return
}
}
if (currentLocaleIndex > 0) {
// Move preselected locale to the top
locales.add(0, locales.removeAt(currentLocaleIndex))
}
}
private fun setupLanguageSpinner(initialLanguage: String) { private fun setupLanguageSpinner(initialLanguage: String) {
val locales = mutableListOf<Locale>()
mergeLocaleListCompat(locales, AppCompatDelegate.getApplicationLocales()) // configured app languages first
mergeLocaleListCompat(locales, LocaleListCompat.getDefault()) // then configured system languages
locales.addAll( // finally, other languages
// Only "base" languages, "en" but not "en_DK"
Locale.getAvailableLocales().filter {
it.country.isNullOrEmpty() &&
it.script.isNullOrEmpty() &&
it.variant.isNullOrEmpty()
}
)
ensureLanguageIsFirst(locales, initialLanguage)
binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode
@ -594,26 +547,11 @@ class ComposeActivity :
} }
} }
binding.composePostLanguageButton.apply { binding.composePostLanguageButton.apply {
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, locales) adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguage))
setSelection(0) setSelection(0)
} }
} }
private fun getInitialLanguage(language: String? = null): String {
return if (language.isNullOrEmpty()) {
// Account-specific language set on the server
if (accountManager.activeAccount?.defaultPostLanguage?.isNotEmpty() == true) {
accountManager.activeAccount?.defaultPostLanguage!!
} else {
// Setting the application ui preference sets the default locale
AppCompatDelegate.getApplicationLocales()[0]?.language
?: Locale.getDefault().language
}
} else {
language
}
}
private fun setupActionBar() { private fun setupActionBar() {
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
supportActionBar?.run { supportActionBar?.run {

View File

@ -48,6 +48,10 @@ import com.keylesspalace.tusky.settings.preference
import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getInitialLanguage
import com.keylesspalace.tusky.util.getLocaleList
import com.keylesspalace.tusky.util.getTuskyDisplayName
import com.keylesspalace.tusky.util.makeIcon
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
@ -67,6 +71,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject @Inject
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val context = requireContext() val context = requireContext()
makePreferenceScreen { makePreferenceScreen {
@ -169,7 +175,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
// TODO language
preferenceCategory(R.string.pref_publishing) { preferenceCategory(R.string.pref_publishing) {
listPreference { listPreference {
setTitle(R.string.pref_default_post_privacy) setTitle(R.string.pref_default_post_privacy)
@ -189,6 +194,29 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
listPreference {
val locales = getLocaleList(getInitialLanguage(null, accountManager.activeAccount))
setTitle(R.string.pref_default_post_language)
// Explicitly add "System default" to the start of the list
entries = (
listOf(context.getString(R.string.system_default)) + locales.map {
it.getTuskyDisplayName(context)
}
).toTypedArray()
entryValues = (listOf("") + locales.map { it.language }).toTypedArray()
key = PrefKeys.DEFAULT_POST_LANGUAGE
icon = makeIcon(requireContext(), GoogleMaterial.Icon.gmd_translate, iconSize)
value = accountManager.activeAccount?.defaultPostLanguage ?: ""
isPersistent = false // This will be entirely server-driven
setSummaryProvider { entry }
setOnPreferenceChangeListener { _, newValue ->
syncWithServer(language = (newValue as String))
eventHub.dispatch(PreferenceChangedEvent(key))
true
}
}
switchPreference { switchPreference {
setTitle(R.string.pref_default_media_sensitivity) setTitle(R.string.pref_default_media_sensitivity)
setIcon(R.drawable.ic_eye_24dp) setIcon(R.drawable.ic_eye_24dp)
@ -317,8 +345,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null) { private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null, language: String? = null) {
mastodonApi.accountUpdateSource(visibility, sensitive) mastodonApi.accountUpdateSource(visibility, sensitive, language)
.enqueue(object : Callback<Account> { .enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>, response: Response<Account>) { override fun onResponse(call: Call<Account>, response: Response<Account>) {
val account = response.body() val account = response.body()
@ -328,6 +356,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
it.defaultPostPrivacy = account.source?.privacy it.defaultPostPrivacy = account.source?.privacy
?: Status.Visibility.PUBLIC ?: Status.Visibility.PUBLIC
it.defaultMediaSensitivity = account.source?.sensitive ?: false it.defaultMediaSensitivity = account.source?.sensitive ?: false
it.defaultPostLanguage = language ?: ""
accountManager.saveAccount(it) accountManager.saveAccount(it)
} }
} else { } else {

View File

@ -31,14 +31,12 @@ import com.keylesspalace.tusky.settings.preference
import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.deserialize
import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.makeIcon
import com.keylesspalace.tusky.util.serialize import com.keylesspalace.tusky.util.serialize
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizePx
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
import javax.inject.Inject import javax.inject.Inject
@ -275,11 +273,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable { private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable {
val context = requireContext() return makeIcon(requireContext(), icon, iconSize)
return IconicsDrawable(context, icon).apply {
sizePx = iconSize
colorInt = ThemeUtils.getColor(context, R.attr.iconColor)
}
} }
override fun onResume() { override fun onResume() {

View File

@ -267,7 +267,8 @@ interface MastodonApi {
@PATCH("api/v1/accounts/update_credentials") @PATCH("api/v1/accounts/update_credentials")
fun accountUpdateSource( fun accountUpdateSource(
@Field("source[privacy]") privacy: String?, @Field("source[privacy]") privacy: String?,
@Field("source[sensitive]") sensitive: Boolean? @Field("source[sensitive]") sensitive: Boolean?,
@Field("source[language]") language: String?,
): Call<Account> ): Call<Account>
@Multipart @Multipart

View File

@ -45,6 +45,7 @@ object PrefKeys {
const val HTTP_PROXY_PORT = "httpProxyPort" const val HTTP_PROXY_PORT = "httpProxyPort"
const val DEFAULT_POST_PRIVACY = "defaultPostPrivacy" const val DEFAULT_POST_PRIVACY = "defaultPostPrivacy"
const val DEFAULT_POST_LANGUAGE = "defaultPostLanguage"
const val DEFAULT_MEDIA_SENSITIVITY = "defaultMediaSensitivity" const val DEFAULT_MEDIA_SENSITIVITY = "defaultMediaSensitivity"
const val MEDIA_PREVIEW_ENABLED = "mediaPreviewEnabled" const val MEDIA_PREVIEW_ENABLED = "mediaPreviewEnabled"
const val ALWAYS_SHOW_SENSITIVE_MEDIA = "alwaysShowSensitiveMedia" const val ALWAYS_SHOW_SENSITIVE_MEDIA = "alwaysShowSensitiveMedia"

View File

@ -0,0 +1,31 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util
import android.content.Context
import androidx.annotation.Px
import com.keylesspalace.tusky.R
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizePx
fun makeIcon(context: Context, icon: GoogleMaterial.Icon, @Px iconSize: Int): IconicsDrawable {
return IconicsDrawable(context, icon).apply {
sizePx = iconSize
colorInt = ThemeUtils.getColor(context, R.attr.iconColor)
}
}

View File

@ -1,5 +1,22 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util package com.keylesspalace.tusky.util
import android.content.Context
import com.keylesspalace.tusky.R
import java.util.Locale import java.util.Locale
// When a language code has changed, `language` *explicitly* returns the obsolete version, // When a language code has changed, `language` *explicitly* returns the obsolete version,
@ -9,3 +26,11 @@ val Locale.modernLanguageCode: String
get() { get() {
return this.toLanguageTag().split('-', limit = 2)[0] return this.toLanguageTag().split('-', limit = 2)[0]
} }
fun Locale.getTuskyDisplayName(context: Context): String {
return context.getString(
R.string.language_display_name_format,
this?.displayLanguage,
this?.getDisplayLanguage(this)
)
}

View File

@ -0,0 +1,89 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.keylesspalace.tusky.db.AccountEntity
import java.util.Locale
private const val TAG: String = "LocaleUtils"
private fun mergeLocaleListCompat(list: MutableList<Locale>, localeListCompat: LocaleListCompat) {
for (index in 0 until localeListCompat.size()) {
val locale = localeListCompat[index]
if (locale != null && list.none { locale.language == it.language }) {
list.add(locale)
}
}
}
// Ensure that the locale whose code matches the given language is first in the list
private fun ensureLanguageIsFirst(locales: MutableList<Locale>, language: String) {
var currentLocaleIndex = locales.indexOfFirst { it.language == language }
if (currentLocaleIndex < 0) {
// Recheck against modern language codes
// This should only happen when replying or when the per-account post language is set
// to a modern code
currentLocaleIndex = locales.indexOfFirst { it.modernLanguageCode == language }
if (currentLocaleIndex < 0) {
// This can happen when:
// - Your per-account posting language is set to one android doesn't know (e.g. toki pona)
// - Replying to a post in a language android doesn't know
locales.add(0, Locale(language))
Log.w(TAG, "Attempting to use unknown language tag '$language'")
return
}
}
if (currentLocaleIndex > 0) {
// Move preselected locale to the top
locales.add(0, locales.removeAt(currentLocaleIndex))
}
}
fun getInitialLanguage(language: String? = null, activeAccount: AccountEntity? = null): String {
return if (language.isNullOrEmpty()) {
// Account-specific language set on the server
if (activeAccount?.defaultPostLanguage?.isNotEmpty() == true) {
activeAccount.defaultPostLanguage
} else {
// Setting the application ui preference sets the default locale
AppCompatDelegate.getApplicationLocales()[0]?.language
?: Locale.getDefault().language
}
} else {
language
}
}
fun getLocaleList(initialLanguage: String): List<Locale> {
val locales = mutableListOf<Locale>()
mergeLocaleListCompat(locales, AppCompatDelegate.getApplicationLocales()) // configured app languages first
mergeLocaleListCompat(locales, LocaleListCompat.getDefault()) // then configured system languages
locales.addAll( // finally, other languages
// Only "base" languages, "en" but not "en_DK"
Locale.getAvailableLocales().filter {
it.country.isNullOrEmpty() &&
it.script.isNullOrEmpty() &&
it.variant.isNullOrEmpty()
}
)
ensureLanguageIsFirst(locales, initialLanguage)
return locales
}

View File

@ -280,6 +280,7 @@
<string name="pref_title_http_proxy_port">HTTP proxy port</string> <string name="pref_title_http_proxy_port">HTTP proxy port</string>
<string name="pref_default_post_privacy">Default post privacy</string> <string name="pref_default_post_privacy">Default post privacy</string>
<string name="pref_default_post_language">Default posting language</string>
<string name="pref_default_media_sensitivity">Always mark media as sensitive</string> <string name="pref_default_media_sensitivity">Always mark media as sensitive</string>
<string name="pref_publishing">Publishing (synced with server)</string> <string name="pref_publishing">Publishing (synced with server)</string>
<string name="pref_failed_to_sync">Failed to sync settings</string> <string name="pref_failed_to_sync">Failed to sync settings</string>
@ -690,6 +691,7 @@
<string name="instance_rule_info">By logging in you agree to the rules of %s.</string> <string name="instance_rule_info">By logging in you agree to the rules of %s.</string>
<string name="instance_rule_title">%s rules</string> <string name="instance_rule_title">%s rules</string>
<string name="language_display_name_format">%s (%s)</string>
<string name="report_category_violation">Rule violation</string> <string name="report_category_violation">Rule violation</string>
<string name="report_category_spam">Spam</string> <string name="report_category_spam">Spam</string>