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:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/TuskyTheme" android:theme="@style/TuskyTheme"
android:usesCleartextTraffic="false"> android:usesCleartextTraffic="false"
android:localeConfig="@xml/locales_config">
<activity <activity
android:name=".SplashActivity" android:name=".SplashActivity"

View file

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

View file

@ -16,8 +16,6 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.app.Application import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.util.Log import android.util.Log
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.work.WorkManager import androidx.work.WorkManager
@ -44,6 +42,9 @@ class TuskyApplication : Application(), HasAndroidInjector {
@Inject @Inject
lateinit var notificationWorkerFactory: NotificationWorkerFactory lateinit var notificationWorkerFactory: NotificationWorkerFactory
@Inject
lateinit var localeManager: LocaleManager
override fun onCreate() { override fun onCreate() {
// Uncomment me to get StrictMode violation logs // Uncomment me to get StrictMode violation logs
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { // 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) val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
ThemeUtils.setAppNightMode(theme) ThemeUtils.setAppNightMode(theme)
localeManager.setLocale()
RxJavaPlugins.setErrorHandler { RxJavaPlugins.setErrorHandler {
Log.w("RxJava", "undeliverable exception", it) 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 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.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
@ -561,7 +562,7 @@ class ComposeActivity :
private fun getInitialLanguage(language: String? = null): String { private fun getInitialLanguage(language: String? = null): String {
return if (language.isNullOrEmpty()) { return if (language.isNullOrEmpty()) {
// Setting the application ui preference sets the default locale // Setting the application ui preference sets the default locale
Locale.getDefault().language AppCompatDelegate.getApplicationLocales()[0]?.language ?: Locale.getDefault().language
} else { } else {
language language
} }

View file

@ -72,30 +72,17 @@ class PreferencesActivity :
setDisplayShowHomeEnabled(true) 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) val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag)
?: when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) { ?: when (preferenceType) {
GENERAL_PREFERENCES -> { GENERAL_PREFERENCES -> PreferencesFragment.newInstance()
setTitle(R.string.action_view_preferences) ACCOUNT_PREFERENCES -> AccountPreferencesFragment.newInstance()
PreferencesFragment.newInstance() NOTIFICATION_PREFERENCES -> NotificationPreferencesFragment.newInstance()
} TAB_FILTER_PREFERENCES -> TabFilterPreferencesFragment.newInstance()
ACCOUNT_PREFERENCES -> { PROXY_PREFERENCES -> ProxyPreferencesFragment.newInstance()
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()
}
else -> throw IllegalArgumentException("preferenceType not known") else -> throw IllegalArgumentException("preferenceType not known")
} }
@ -103,6 +90,14 @@ class PreferencesActivity :
replace(R.id.fragment_container, fragment, fragmentTag) 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) onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback)
restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
} }
@ -141,10 +136,6 @@ class PreferencesActivity :
"enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR -> { "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR -> {
restartActivitiesOnBackPressedCallback.isEnabled = true restartActivitiesOnBackPressedCallback.isEnabled = true
} }
"language" -> {
restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity()
}
} }
eventHub.dispatch(PreferenceChangedEvent(key)) 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.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.ThemeUtils 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
@ -46,6 +47,9 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject @Inject
lateinit var accountManager: AccountManager lateinit var accountManager: AccountManager
@Inject
lateinit var localeManager: LocaleManager
private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
private var httpProxyPref: Preference? = null private var httpProxyPref: Preference? = null
@ -71,10 +75,11 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
setDefaultValue("default") setDefaultValue("default")
setEntries(R.array.language_entries) setEntries(R.array.language_entries)
setEntryValues(R.array.language_values) setEntryValues(R.array.language_values)
key = PrefKeys.LANGUAGE key = PrefKeys.LANGUAGE + "_" // deliberately not the actual key, the real handling happens in LocaleManager
setSummaryProvider { entry } setSummaryProvider { entry }
setTitle(R.string.pref_title_language) setTitle(R.string.pref_title_language)
icon = makeIcon(GoogleMaterial.Icon.gmd_translate) icon = makeIcon(GoogleMaterial.Icon.gmd_translate)
preferenceDataStore = localeManager
} }
listPreference { listPreference {

View file

@ -17,25 +17,89 @@ package com.keylesspalace.tusky.util
import android.content.Context import android.content.Context
import android.content.SharedPreferences 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 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) private var prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
fun setLocale(context: Context): Context { fun setLocale() {
val language = prefs.getNonNullString("language", "default") val language = prefs.getNonNullString(PrefKeys.LANGUAGE, DEFAULT)
if (language == "default") {
return context
}
val locale = Locale.forLanguageTag(language)
Locale.setDefault(locale)
val res = context.resources if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val config = Configuration(res.configuration) if (language != HANDLED_BY_SYSTEM) {
config.setLocale(locale) // app is being opened on Android 13+ for the first time
return context.createConfigurationContext(config) // 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>cs</item>
<item>cy</item> <item>cy</item>
<item>de</item> <item>de</item>
<item>en-gb</item> <item>en-GB</item>
<item>en</item> <item>en</item>
<item>eo</item> <item>eo</item>
<item>es</item> <item>es</item>
@ -103,7 +103,7 @@
<item>it</item> <item>it</item>
<item>hu</item> <item>hu</item>
<item>nl</item> <item>nl</item>
<item>nb-no</item> <item>nb-NO</item>
<item>oc</item> <item>oc</item>
<item>pl</item> <item>pl</item>
<item>pt-BR</item> <item>pt-BR</item>
@ -118,8 +118,8 @@
<item>uk</item> <item>uk</item>
<item>ar</item> <item>ar</item>
<item>ckb</item> <item>ckb</item>
<item>bn-bd</item> <item>bn-BD</item>
<item>bn-in</item> <item>bn-IN</item>
<item>fa</item> <item>fa</item>
<item>hi</item> <item>hi</item>
<item>sa</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 package com.keylesspalace.tusky
import android.app.Application 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_defaults.DefaultEmojiPackList
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
@ -29,19 +26,4 @@ class TuskyApplication : Application() {
super.onCreate() super.onCreate()
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this)) 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] [versions]
agp = "7.2.2" agp = "7.2.2"
androidx-activity = "1.6.0" androidx-activity = "1.6.0"
androidx-appcompat = "1.5.1" androidx-appcompat = "1.6.0-rc01"
androidx-browser = "1.4.0" androidx-browser = "1.4.0"
androidx-cardview = "1.0.0" androidx-cardview = "1.0.0"
androidx-constraintlayout = "2.1.4" androidx-constraintlayout = "2.1.4"