Skip to content

Commit

Permalink
[Feature] Add per-app language preference.
Browse files Browse the repository at this point in the history
Fixes: #293
  • Loading branch information
zhanghai committed Aug 9, 2023
1 parent 97c0a04 commit 25d298a
Show file tree
Hide file tree
Showing 13 changed files with 388 additions and 7 deletions.
5 changes: 3 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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'
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
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) }
}
}
}
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 = ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 25d298a

Please sign in to comment.