[Feature] Add per-app language preference.

Fixes: #293
This commit is contained in:
Hai Zhang 2023-08-08 19:16:06 -07:00
parent 97c0a04aa3
commit 25d298aba6
13 changed files with 388 additions and 7 deletions

View File

@ -26,7 +26,7 @@ apply plugin: 'com.google.firebase.crashlytics'
android {
namespace 'me.zhanghai.android.files'
compileSdk 33
compileSdk 34
ndkVersion '25.2.9519653'
buildToolsVersion = '33.0.2'
defaultConfig {
@ -111,7 +111,8 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
implementation 'androidx.activity:activity-ktx:1.7.2'
implementation 'androidx.appcompat:appcompat:1.6.1'
// Appcompat 1.7.0-alpha01 is required for properly changing locale below API 24 (b/243119645).
implementation 'androidx.appcompat:appcompat:1.7.0-alpha03'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.drawerlayout:drawerlayout:1.2.0'
implementation 'androidx.exifinterface:exifinterface:1.3.6'

View File

@ -315,6 +315,15 @@
android:value="true" />
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<provider
android:name="me.zhanghai.android.files.app.AppProvider"
android:authorities="@string/app_provider_authority"

View File

@ -0,0 +1,198 @@
/*
* Copyright (c) 2023 Hai Zhang <dreaming.in.code.zh@gmail.com>
* All Rights Reserved.
*/
package me.zhanghai.android.files.compat
import android.app.LocaleConfig
import android.content.Context
import android.content.res.XmlResourceParser
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.XmlRes
import androidx.core.content.res.ResourcesCompat
import androidx.core.os.LocaleListCompat
import org.xmlpull.v1.XmlPullParser
import java.io.FileNotFoundException
/**
* @see android.app.LocaleConfig
*/
class LocaleConfigCompat(context: Context) {
var status = 0
private set
var supportedLocales: LocaleListCompat? = null
private set
init {
val impl = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Api33Impl(context)
} else {
Api21Impl(context)
}
status = impl.status
supportedLocales = impl.supportedLocales
}
companion object {
/**
* Succeeded reading the LocaleConfig structure stored in an XML file.
*/
const val STATUS_SUCCESS = 0
/**
* No android:localeConfig tag on <application>.
*/
const val STATUS_NOT_SPECIFIED = 1
/**
* Malformed input in the XML file where the LocaleConfig was stored.
*/
const val STATUS_PARSING_FAILED = 2
}
private abstract class Impl {
abstract val status: Int
abstract val supportedLocales: LocaleListCompat?
}
private class Api21Impl(context: Context) : Impl() {
override var status = 0
private set
override var supportedLocales: LocaleListCompat? = null
private set
init {
val resourceId = try {
getLocaleConfigResourceId(context)
} catch (e: Exception) {
Log.w(TAG, "The resource file pointed to by the given resource ID isn't found.", e)
}
if (resourceId == ResourcesCompat.ID_NULL) {
status = STATUS_NOT_SPECIFIED
} else {
val resources = context.resources
try {
supportedLocales = resources.getXml(resourceId).use { parseLocaleConfig(it) }
status = STATUS_SUCCESS
} catch (e: Exception) {
val resourceEntryName = resources.getResourceEntryName(resourceId)
Log.w(TAG, "Failed to parse XML configuration from $resourceEntryName", e)
status = STATUS_PARSING_FAILED
}
}
}
// @see com.android.server.pm.pkg.parsing.ParsingPackageUtils
@XmlRes
private fun getLocaleConfigResourceId(context: Context): Int {
var cookie = 1
while (true) {
val parser = try {
context.assets.openXmlResourceParser(cookie, FILE_NAME_ANDROID_MANIFEST)
} catch (e: FileNotFoundException) {
break
}
parser.use {
do {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
if (parser.name != TAG_MANIFEST) {
parser.skipCurrentTag()
continue
}
if (parser.getAttributeValue(null, ATTR_PACKAGE) != context.packageName) {
break
}
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
if (parser.name != TAG_APPLICATION) {
parser.skipCurrentTag()
continue
}
return parser.getAttributeResourceValue(
NAMESPACE_ANDROID, ATTR_LOCALE_CONFIG, ResourcesCompat.ID_NULL
)
}
} while (parser.next() != XmlPullParser.END_DOCUMENT)
}
++cookie
}
return ResourcesCompat.ID_NULL
}
private fun parseLocaleConfig(parser: XmlResourceParser): LocaleListCompat {
val localeNames = mutableSetOf<String>()
do {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
if (parser.name != TAG_LOCALE_CONFIG) {
parser.skipCurrentTag()
continue
}
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
if (parser.name != TAG_LOCALE) {
parser.skipCurrentTag()
continue
}
localeNames += parser.getAttributeValue(NAMESPACE_ANDROID, ATTR_NAME)
parser.skipCurrentTag()
}
} while (parser.next() != XmlPullParser.END_DOCUMENT)
return LocaleListCompat.forLanguageTags(localeNames.joinToString(","))
}
private fun XmlPullParser.skipCurrentTag() {
val outerDepth = depth
var type: Int
do {
type = next()
} while (type != XmlPullParser.END_DOCUMENT &&
(type != XmlPullParser.END_TAG || depth > outerDepth))
}
companion object {
private const val TAG = "LocaleConfigCompat"
private const val FILE_NAME_ANDROID_MANIFEST = "AndroidManifest.xml"
private const val TAG_APPLICATION = "application"
private const val TAG_LOCALE_CONFIG = "locale-config"
private const val TAG_LOCALE = "locale"
private const val TAG_MANIFEST = "manifest"
private const val NAMESPACE_ANDROID = "http://schemas.android.com/apk/res/android"
private const val ATTR_LOCALE_CONFIG = "localeConfig"
private const val ATTR_NAME = "name"
private const val ATTR_PACKAGE = "package"
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private class Api33Impl(context: Context) : Impl() {
override var status: Int = 0
private set
override var supportedLocales: LocaleListCompat? = null
private set
init {
val platformLocaleConfig = LocaleConfig(context)
status = platformLocaleConfig.status
supportedLocales = platformLocaleConfig.supportedLocales
?.let { LocaleListCompat.wrap(it) }
}
}
}

View File

@ -0,0 +1,121 @@
/*
* Copyright (c) 2023 Hai Zhang <dreaming.in.code.zh@gmail.com>
* All Rights Reserved.
*/
package me.zhanghai.android.files.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.core.app.LocaleManagerCompat
import androidx.core.os.LocaleListCompat
import androidx.preference.ListPreference
import androidx.preference.Preference.SummaryProvider
import me.zhanghai.android.files.R
import me.zhanghai.android.files.app.application
import me.zhanghai.android.files.compat.LocaleConfigCompat
import me.zhanghai.android.files.util.toList
import java.util.Locale
class LocalePreference : ListPreference {
lateinit var setApplicationLocalesPreApi33: (LocaleListCompat) -> Unit
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, @AttrRes defStyleAttr: Int) : super(
context, attrs, defStyleAttr
)
constructor(
context: Context,
attrs: AttributeSet?,
@AttrRes defStyleAttr: Int,
@StyleRes defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes)
init {
val context = context
val systemDefaultEntry = context.getString(R.string.system_default)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Prefer using the system setting because it has better support for locales.
intent = Intent(
Settings.ACTION_APP_LOCALE_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
summaryProvider = SummaryProvider<LocalePreference> {
applicationLocale?.sentenceCasedLocalizedDisplayName ?: systemDefaultEntry
}
} else {
setDefaultValue(VALUE_SYSTEM_DEFAULT)
val supportedLocales = LocaleConfigCompat(context).supportedLocales!!.toList()
.sortedBy { it.toLanguageTag() }
entries = supportedLocales.mapTo(mutableListOf(systemDefaultEntry)) {
it.sentenceCasedLocalizedDisplayName
}.toTypedArray<CharSequence>()
entryValues =
supportedLocales
.mapTo(mutableListOf(VALUE_SYSTEM_DEFAULT)) { it.toLanguageTag() }
.toTypedArray<CharSequence>()
summaryProvider = SimpleSummaryProvider.getInstance()
}
}
private val Locale.sentenceCasedLocalizedDisplayName: String
// See com.android.internal.app.LocaleHelper.toSentenceCase() for a proper case conversion
// implementation which requires android.icu.text.CaseMap that's only available on API 29+.
@Suppress("DEPRECATION")
get() = getDisplayName(this).capitalize(this)
override fun getPersistedString(defaultReturnValue: String?): String =
applicationLocale?.toLanguageTag() ?: VALUE_SYSTEM_DEFAULT
override fun persistString(value: String?): Boolean {
applicationLocale = if (value != null && value != VALUE_SYSTEM_DEFAULT) {
Locale.forLanguageTag(value)
} else {
null
}
return true
}
private var applicationLocale: Locale?
get() = LocaleManagerCompat.getApplicationLocales(application).toList().firstOrNull()
set(value) {
check(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
if (value == applicationLocale) {
return
}
val locales = if (value != null) {
LocaleListCompat.create(value)
} else {
LocaleListCompat.getEmptyLocaleList()
}
setApplicationLocalesPreApi33(locales)
}
override fun onClick() {
// Don't show dialog if we have an intent.
if (intent != null) {
return
}
super.onClick()
}
// Exposed for SettingsPreferenceFragment.onResume().
public override fun notifyChanged() {
super.notifyChanged()
}
companion object {
private const val VALUE_SYSTEM_DEFAULT = ""
}
}

View File

@ -63,7 +63,7 @@ abstract class SettingLiveData<T>(
defaultValue: T
): T
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
if (key == this.key) {
loadValue()
}

View File

@ -11,6 +11,8 @@ import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.fragment.app.add
import androidx.fragment.app.commit
import kotlinx.parcelize.Parcelize
@ -40,6 +42,13 @@ class SettingsActivity : AppActivity(), OnThemeChangedListener, OnNightModeChang
}
}
fun setApplicationLocalesPreApi33(locales: LocaleListCompat) {
// HACK: Prevent this activity from being recreated due to locale change.
delegate.onDestroy()
AppCompatDelegate.setApplicationLocales(locales)
restart()
}
override fun onThemeChanged(@StyleRes theme: Int) {
// ActivityCompat.recreate() may call ActivityRecreator.recreate() without calling
// Activity.recreate(), so we cannot simply override it. To work around this, we just

View File

@ -5,6 +5,7 @@
package me.zhanghai.android.files.settings
import android.os.Build
import android.os.Bundle
import me.zhanghai.android.files.R
import me.zhanghai.android.files.theme.custom.CustomThemeHelper
@ -14,6 +15,20 @@ import me.zhanghai.android.files.theme.night.NightModeHelper
import me.zhanghai.android.files.ui.PreferenceFragmentCompat
class SettingsPreferenceFragment : PreferenceFragmentCompat() {
private lateinit var localePreference: LocalePreference
override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.settings)
localePreference = preferenceScreen.findPreference(getString(R.string.pref_key_locale))!!
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
localePreference.setApplicationLocalesPreApi33 = { locales ->
val activity = requireActivity() as SettingsActivity
activity.setApplicationLocalesPreApi33(locales)
}
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
@ -33,10 +48,6 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
Settings.BLACK_NIGHT_MODE.observe(viewLifecycleOwner, this::onBlackNightModeChanged)
}
override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.settings)
}
private fun onThemeColorChanged(themeColor: ThemeColor) {
CustomThemeHelper.sync()
}
@ -52,4 +63,14 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
private fun onBlackNightModeChanged(blackNightMode: Boolean) {
CustomThemeHelper.sync()
}
override fun onResume() {
super.onResume()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Refresh locale preference summary because we aren't notified for an external change
// between system default and the locale that's the current system default.
localePreference.notifyChanged()
}
}
}

View File

@ -0,0 +1,11 @@
/*
* Copyright (c) 2023 Hai Zhang <dreaming.in.code.zh@gmail.com>
* All Rights Reserved.
*/
package me.zhanghai.android.files.util
import androidx.core.os.LocaleListCompat
import java.util.Locale
fun LocaleListCompat.toList(): List<Locale> = List(size()) { this[it]!! }

View File

@ -43,6 +43,7 @@
<string name="show">显示</string>
<string name="skip">跳过</string>
<string name="stop">停止</string>
<string name="system_default">系统默认</string>
<string name="unknown">未知</string>
<string name="view">查看</string>
@ -532,6 +533,7 @@
<string name="settings_title">设置</string>
<string name="settings_interface_title">界面</string>
<string name="settings_locale_title">语言</string>
<string name="settings_theme_color_title">主题色</string>
<string name="settings_theme_color_summary">应用中最常见的颜色</string>
<string name="settings_material_design_3_title">质感设计 3</string>

View File

@ -43,6 +43,7 @@
<string name="show">顯示</string>
<string name="skip">跳過</string>
<string name="stop">停止</string>
<string name="system_default">系統預設</string>
<string name="unknown">不明</string>
<string name="view">查看</string>
@ -532,6 +533,7 @@
<string name="settings_title">設定</string>
<string name="settings_interface_title">介面</string>
<string name="settings_locale_title">語言</string>
<string name="settings_theme_color_title">主題色</string>
<string name="settings_theme_color_summary">應用程式中最常見的色彩</string>
<string name="settings_material_design_3_title">質感設計 3</string>

View File

@ -32,6 +32,7 @@
<string name="pref_key_ftp_server_writable">key_ftp_server_writable</string>
<bool name="pref_default_value_ftp_server_writable">true</bool>
<string name="pref_key_locale">key_locale</string>
<string name="pref_key_theme_color">key_theme_color</string>
<string name="pref_default_value_theme_color">0</string>
<string name="pref_key_material_design_3">key_material_design_3</string>

View File

@ -44,6 +44,7 @@
<string name="show">Show</string>
<string name="skip">Skip</string>
<string name="stop">Stop</string>
<string name="system_default">System default</string>
<string name="unknown">Unknown</string>
<string name="view">View</string>
@ -664,6 +665,7 @@
<string name="settings_title">Settings</string>
<string name="settings_interface_title">Interface</string>
<string name="settings_locale_title">Language</string>
<string name="settings_theme_color_title">Theme color</string>
<string name="settings_theme_color_summary">Color that appears most frequently in the app</string>
<string name="settings_material_design_3_title">Material Design 3</string>

View File

@ -11,6 +11,10 @@
<PreferenceCategory android:title="@string/settings_interface_title">
<me.zhanghai.android.files.settings.LocalePreference
android:key="@string/pref_key_locale"
android:title="@string/settings_locale_title" />
<me.zhanghai.android.files.theme.custom.ThemeColorPreference
android:key="@string/pref_key_theme_color"
android:title="@string/settings_theme_color_title"