From 588307f7a1b06c1145edf20397e0d35804cc8476 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Fri, 2 Dec 2022 19:19:17 +0100 Subject: [PATCH] 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 --- .../tusky/adapter/LocaleAdapter.kt | 4 +- .../components/compose/ComposeActivity.kt | 70 +-------------- .../preference/AccountPreferencesFragment.kt | 35 +++++++- .../preference/PreferencesFragment.kt | 10 +-- .../tusky/network/MastodonApi.kt | 3 +- .../tusky/settings/SettingsConstants.kt | 1 + .../com/keylesspalace/tusky/util/IconUtils.kt | 31 +++++++ .../tusky/util/LocaleExtensions.kt | 25 ++++++ .../keylesspalace/tusky/util/LocaleUtils.kt | 89 +++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + 10 files changed, 190 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt index a1ec7371f..ef5edd1d4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt @@ -22,6 +22,7 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.TextView import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.getTuskyDisplayName import com.keylesspalace.tusky.util.modernLanguageCode import java.util.Locale @@ -37,8 +38,7 @@ class LocaleAdapter(context: Context, resource: Int, locales: List) : Ar override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { return (super.getDropDownView(position, convertView, parent) as TextView).apply { setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary)) - val locale = super.getItem(position) - text = "${locale?.displayLanguage} (${locale?.getDisplayLanguage(locale)})" + text = super.getItem(position)?.getTuskyDisplayName(context) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 067107f39..8f85f3435 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -47,11 +47,9 @@ import androidx.annotation.ColorInt import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatDelegate import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider -import androidx.core.os.LocaleListCompat import androidx.core.view.ContentInfoCompat import androidx.core.view.OnReceiveContentListener 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.ThemeUtils 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.hide import com.keylesspalace.tusky.util.highlightSpans @@ -265,7 +265,7 @@ class ComposeActivity : binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) } - setupLanguageSpinner(getInitialLanguage(composeOptions?.language)) + setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount)) setupComposeField(preferences, viewModel.startingText) setupContentWarningField(composeOptions?.contentWarning) setupPollView() @@ -536,54 +536,7 @@ class ComposeActivity : ) } - private fun mergeLocaleListCompat(list: MutableList, 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, 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) { - val locales = mutableListOf() - 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 { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode @@ -594,26 +547,11 @@ class ComposeActivity : } } 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) } } - 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() { setSupportActionBar(binding.toolbar) supportActionBar?.run { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index 19090ea99..d60210ba5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -48,6 +48,10 @@ import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference 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.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -67,6 +71,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var eventHub: EventHub + private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val context = requireContext() makePreferenceScreen { @@ -169,7 +175,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - // TODO language preferenceCategory(R.string.pref_publishing) { listPreference { 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 { setTitle(R.string.pref_default_media_sensitivity) setIcon(R.drawable.ic_eye_24dp) @@ -317,8 +345,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null) { - mastodonApi.accountUpdateSource(visibility, sensitive) + private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null, language: String? = null) { + mastodonApi.accountUpdateSource(visibility, sensitive, language) .enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { val account = response.body() @@ -328,6 +356,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC it.defaultMediaSensitivity = account.source?.sensitive ?: false + it.defaultPostLanguage = language ?: "" accountManager.saveAccount(it) } } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 6e6d2d438..fd79c3380 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -31,14 +31,12 @@ import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.LocaleManager -import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.getNonNullString +import com.keylesspalace.tusky.util.makeIcon import com.keylesspalace.tusky.util.serialize 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 import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import javax.inject.Inject @@ -275,11 +273,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { } private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable { - val context = requireContext() - return IconicsDrawable(context, icon).apply { - sizePx = iconSize - colorInt = ThemeUtils.getColor(context, R.attr.iconColor) - } + return makeIcon(requireContext(), icon, iconSize) } override fun onResume() { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 965d58a42..42652a122 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -267,7 +267,8 @@ interface MastodonApi { @PATCH("api/v1/accounts/update_credentials") fun accountUpdateSource( @Field("source[privacy]") privacy: String?, - @Field("source[sensitive]") sensitive: Boolean? + @Field("source[sensitive]") sensitive: Boolean?, + @Field("source[language]") language: String?, ): Call @Multipart diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 90c51de39..2eaca64e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -45,6 +45,7 @@ object PrefKeys { const val HTTP_PROXY_PORT = "httpProxyPort" const val DEFAULT_POST_PRIVACY = "defaultPostPrivacy" + const val DEFAULT_POST_LANGUAGE = "defaultPostLanguage" const val DEFAULT_MEDIA_SENSITIVITY = "defaultMediaSensitivity" const val MEDIA_PREVIEW_ENABLED = "mediaPreviewEnabled" const val ALWAYS_SHOW_SENSITIVE_MEDIA = "alwaysShowSensitiveMedia" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt new file mode 100644 index 000000000..59b0b15d7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/IconUtils.kt @@ -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 . */ + +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) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt index c1c6425ec..caab21927 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt @@ -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 . */ + package com.keylesspalace.tusky.util +import android.content.Context +import com.keylesspalace.tusky.R import java.util.Locale // When a language code has changed, `language` *explicitly* returns the obsolete version, @@ -9,3 +26,11 @@ val Locale.modernLanguageCode: String get() { 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) + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt new file mode 100644 index 000000000..1cc3f3be0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt @@ -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 . */ + +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, 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, 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 { + val locales = mutableListOf() + 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 +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0cbfaa2e..78113e924 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -280,6 +280,7 @@ HTTP proxy port Default post privacy + Default posting language Always mark media as sensitive Publishing (synced with server) Failed to sync settings @@ -690,6 +691,7 @@ By logging in you agree to the rules of %s. %s rules + %s (%s) Rule violation Spam