support Android 13 per-app languages (#2829)

* support Android 13 per-app languages

* fix tests

* fix language ids in locales_config.xml

* fix language setting default in ComposeActivity
This commit is contained in:
Konrad Pozniak 2022-11-16 19:45:18 +01:00 committed by GitHub
parent 9f7cd2fa32
commit c96a81571c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 165 additions and 89 deletions

View file

@ -18,7 +18,8 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/TuskyTheme"
android:usesCleartextTraffic="false">
android:usesCleartextTraffic="false"
android:localeConfig="@xml/locales_config">
<activity
android:name=".SplashActivity"

View file

@ -16,7 +16,6 @@
package com.keylesspalace.tusky;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
@ -92,11 +91,6 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
requesters = new HashMap<>();
}
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(TuskyApplication.getLocaleManager().setLocale(base));
}
protected boolean requiresLogin() {
return true;
}

View file

@ -16,8 +16,6 @@
package com.keylesspalace.tusky
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.util.Log
import androidx.preference.PreferenceManager
import androidx.work.WorkManager
@ -44,6 +42,9 @@ class TuskyApplication : Application(), HasAndroidInjector {
@Inject
lateinit var notificationWorkerFactory: NotificationWorkerFactory
@Inject
lateinit var localeManager: LocaleManager
override fun onCreate() {
// Uncomment me to get StrictMode violation logs
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
@ -74,6 +75,8 @@ class TuskyApplication : Application(), HasAndroidInjector {
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
ThemeUtils.setAppNightMode(theme)
localeManager.setLocale()
RxJavaPlugins.setErrorHandler {
Log.w("RxJava", "undeliverable exception", it)
}
@ -86,20 +89,5 @@ class TuskyApplication : Application(), HasAndroidInjector {
)
}
override fun attachBaseContext(base: Context) {
localeManager = LocaleManager(base)
super.attachBaseContext(localeManager.setLocale(base))
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
localeManager.setLocale(this)
}
override fun androidInjector() = androidInjector
companion object {
@JvmStatic
lateinit var localeManager: LocaleManager
}
}

View file

@ -47,6 +47,7 @@ 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
@ -561,7 +562,7 @@ class ComposeActivity :
private fun getInitialLanguage(language: String? = null): String {
return if (language.isNullOrEmpty()) {
// Setting the application ui preference sets the default locale
Locale.getDefault().language
AppCompatDelegate.getApplicationLocales()[0]?.language ?: Locale.getDefault().language
} else {
language
}

View file

@ -72,30 +72,17 @@ class PreferencesActivity :
setDisplayShowHomeEnabled(true)
}
val fragmentTag = "preference_fragment_$EXTRA_PREFERENCE_TYPE"
val preferenceType = intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)
val fragmentTag = "preference_fragment_$preferenceType"
val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag)
?: when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) {
GENERAL_PREFERENCES -> {
setTitle(R.string.action_view_preferences)
PreferencesFragment.newInstance()
}
ACCOUNT_PREFERENCES -> {
setTitle(R.string.action_view_account_preferences)
AccountPreferencesFragment.newInstance()
}
NOTIFICATION_PREFERENCES -> {
setTitle(R.string.pref_title_edit_notification_settings)
NotificationPreferencesFragment.newInstance()
}
TAB_FILTER_PREFERENCES -> {
setTitle(R.string.pref_title_post_tabs)
TabFilterPreferencesFragment.newInstance()
}
PROXY_PREFERENCES -> {
setTitle(R.string.pref_title_http_proxy_settings)
ProxyPreferencesFragment.newInstance()
}
?: when (preferenceType) {
GENERAL_PREFERENCES -> PreferencesFragment.newInstance()
ACCOUNT_PREFERENCES -> AccountPreferencesFragment.newInstance()
NOTIFICATION_PREFERENCES -> NotificationPreferencesFragment.newInstance()
TAB_FILTER_PREFERENCES -> TabFilterPreferencesFragment.newInstance()
PROXY_PREFERENCES -> ProxyPreferencesFragment.newInstance()
else -> throw IllegalArgumentException("preferenceType not known")
}
@ -103,6 +90,14 @@ class PreferencesActivity :
replace(R.id.fragment_container, fragment, fragmentTag)
}
when (preferenceType) {
GENERAL_PREFERENCES -> setTitle(R.string.action_view_preferences)
ACCOUNT_PREFERENCES -> setTitle(R.string.action_view_account_preferences)
NOTIFICATION_PREFERENCES -> setTitle(R.string.pref_title_edit_notification_settings)
TAB_FILTER_PREFERENCES -> setTitle(R.string.pref_title_post_tabs)
PROXY_PREFERENCES -> setTitle(R.string.pref_title_http_proxy_settings)
}
onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback)
restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
}
@ -141,10 +136,6 @@ class PreferencesActivity :
"enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR -> {
restartActivitiesOnBackPressedCallback.isEnabled = true
}
"language" -> {
restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity()
}
}
eventHub.dispatch(PreferenceChangedEvent(key))

View file

@ -30,6 +30,7 @@ import com.keylesspalace.tusky.settings.makePreferenceScreen
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
@ -46,6 +47,9 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var localeManager: LocaleManager
private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
private var httpProxyPref: Preference? = null
@ -71,10 +75,11 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
setDefaultValue("default")
setEntries(R.array.language_entries)
setEntryValues(R.array.language_values)
key = PrefKeys.LANGUAGE
key = PrefKeys.LANGUAGE + "_" // deliberately not the actual key, the real handling happens in LocaleManager
setSummaryProvider { entry }
setTitle(R.string.pref_title_language)
icon = makeIcon(GoogleMaterial.Icon.gmd_translate)
preferenceDataStore = localeManager
}
listPreference {

View file

@ -17,25 +17,89 @@ package com.keylesspalace.tusky.util
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Configuration
import android.os.Build
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceDataStore
import androidx.preference.PreferenceManager
import java.util.Locale
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.settings.PrefKeys
import javax.inject.Inject
import javax.inject.Singleton
class LocaleManager(context: Context) {
@Singleton
class LocaleManager @Inject constructor(
val context: Context
) : PreferenceDataStore() {
private var prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
fun setLocale(context: Context): Context {
val language = prefs.getNonNullString("language", "default")
if (language == "default") {
return context
}
val locale = Locale.forLanguageTag(language)
Locale.setDefault(locale)
fun setLocale() {
val language = prefs.getNonNullString(PrefKeys.LANGUAGE, DEFAULT)
val res = context.resources
val config = Configuration(res.configuration)
config.setLocale(locale)
return context.createConfigurationContext(config)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (language != HANDLED_BY_SYSTEM) {
// app is being opened on Android 13+ for the first time
// hand over the old setting to the system and save a dummy value in Shared Preferences
applyLanguageToApp(language)
prefs.edit()
.putString(PrefKeys.LANGUAGE, HANDLED_BY_SYSTEM)
.apply()
}
} else {
// on Android < 13 we have to apply the language at every app start
applyLanguageToApp(language)
}
}
override fun putString(key: String?, value: String?) {
// if we are on Android < 13 we have to save the selected language so we can apply it at appstart
// on Android 13+ the system handles it for us
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
prefs.edit()
.putString(PrefKeys.LANGUAGE, value)
.apply()
}
applyLanguageToApp(value)
}
override fun getString(key: String?, defValue: String?): String? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val selectedLanguage = AppCompatDelegate.getApplicationLocales()
if (selectedLanguage.isEmpty) {
DEFAULT
} else {
// Android lets users select all variants of languages we support in the system settings,
// so we need to find the closest match
// it should not happen that we find no match, but returning null is fine (picker will show default)
val availableLanguages = context.resources.getStringArray(R.array.language_values)
return availableLanguages.find { it == selectedLanguage[0]!!.toLanguageTag() }
?: availableLanguages.find { language ->
language.startsWith(selectedLanguage[0]!!.language)
}
}
} else {
prefs.getNonNullString(PrefKeys.LANGUAGE, DEFAULT)
}
}
private fun applyLanguageToApp(language: String?) {
val localeList = if (language == DEFAULT) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(language)
}
AppCompatDelegate.setApplicationLocales(localeList)
}
companion object {
private const val DEFAULT = "default"
private const val HANDLED_BY_SYSTEM = "handled_by_system"
}
}

View file

@ -90,7 +90,7 @@
<item>cs</item>
<item>cy</item>
<item>de</item>
<item>en-gb</item>
<item>en-GB</item>
<item>en</item>
<item>eo</item>
<item>es</item>
@ -103,7 +103,7 @@
<item>it</item>
<item>hu</item>
<item>nl</item>
<item>nb-no</item>
<item>nb-NO</item>
<item>oc</item>
<item>pl</item>
<item>pt-BR</item>
@ -118,8 +118,8 @@
<item>uk</item>
<item>ar</item>
<item>ckb</item>
<item>bn-bd</item>
<item>bn-in</item>
<item>bn-BD</item>
<item>bn-IN</item>
<item>fa</item>
<item>hi</item>
<item>sa</item>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en"/>
<locale android:name="ca"/>
<locale android:name="cs"/>
<locale android:name="cy"/>
<locale android:name="de"/>
<locale android:name="en-GB"/>
<locale android:name="en"/>
<locale android:name="eo"/>
<locale android:name="es"/>
<locale android:name="eu"/>
<locale android:name="fr"/>
<locale android:name="ga"/>
<locale android:name="gd"/>
<locale android:name="gl"/>
<locale android:name="is"/>
<locale android:name="it"/>
<locale android:name="hu"/>
<locale android:name="nl"/>
<locale android:name="nb-NO"/>
<locale android:name="oc"/>
<locale android:name="pl"/>
<locale android:name="pt-BR"/>
<locale android:name="pt-PT"/>
<locale android:name="sl"/>
<locale android:name="sv"/>
<locale android:name="kab"/>
<locale android:name="vi"/>
<locale android:name="tr"/>
<locale android:name="bg"/>
<locale android:name="ru"/>
<locale android:name="uk"/>
<locale android:name="ar"/>
<locale android:name="ckb"/>
<locale android:name="bn-BD"/>
<locale android:name="bn-IN"/>
<locale android:name="fa"/>
<locale android:name="hi"/>
<locale android:name="sa"/>
<locale android:name="ta"/>
<locale android:name="th"/>
<locale android:name="ko"/>
<locale android:name="zh-TW"/>
<locale android:name="zh-SG"/>
<locale android:name="zh-MO"/>
<locale android:name="zh-CN"/>
<locale android:name="zh-HK"/>
<locale android:name="ja"/>
</locale-config>

View file

@ -16,9 +16,6 @@
package com.keylesspalace.tusky
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import com.keylesspalace.tusky.util.LocaleManager
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
@ -29,19 +26,4 @@ class TuskyApplication : Application() {
super.onCreate()
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this))
}
override fun attachBaseContext(base: Context) {
localeManager = LocaleManager(base)
super.attachBaseContext(localeManager.setLocale(base))
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
localeManager.setLocale(this)
}
companion object {
@JvmStatic
lateinit var localeManager: LocaleManager
}
}

View file

@ -1,7 +1,7 @@
[versions]
agp = "7.2.2"
androidx-activity = "1.6.0"
androidx-appcompat = "1.5.1"
androidx-appcompat = "1.6.0-rc01"
androidx-browser = "1.4.0"
androidx-cardview = "1.0.0"
androidx-constraintlayout = "2.1.4"