-
Notifications
You must be signed in to change notification settings - Fork 410
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Feature] Add per-app language preference.
Fixes: #293
- Loading branch information
Showing
13 changed files
with
388 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
198 changes: 198 additions & 0 deletions
198
app/src/main/java/me/zhanghai/android/files/compat/LocaleConfigCompat.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,198 @@ | ||
/* | ||
* Copyright (c) 2023 Hai Zhang <[email protected]> | ||
* 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) } | ||
} | ||
} | ||
} |
121 changes: 121 additions & 0 deletions
121
app/src/main/java/me/zhanghai/android/files/settings/LocalePreference.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
/* | ||
* Copyright (c) 2023 Hai Zhang <[email protected]> | ||
* 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 = "" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.