Skip to content

Commit 75fe77f

Browse files
Support Per-app language preferences (#579)
* enable locale config generation * add AppLocalesMetadataHolderService to manifest * refactor LocaleUtils and update settings references * add system_default string to translations * update locale utils and language preference add tests for titlecaseFirst extension function in StringUtilsTest * refactor locale handling to use AppCompatDelegate for application locales * update LocaleUtils and refactor language preference tests * reorder and update supported locales in LocaleUtils and LocaleUtilsTest * rename variable * remove createContext from CompatUtils and CompatUtilsTest * add support for Chinese language tags and update tests * update SettingsTest with appLocale and syncLanguage tests * refactor LocaleUtils to use a getter for countriesLocales * refactor LocaleUtils to extract findSupportedLocale method * add tests for locale matching logic in LocaleUtils * add tests for language preference change handling with application locales * register and unregister shared preference change listener in MainActivity * sort and include system default in language preference options * fix language preference tests --------- Co-authored-by: VREM Software Development <vremsoftwaredevelopment@gmail.com>
1 parent ad9dbf8 commit 75fe77f

41 files changed

Lines changed: 445 additions & 202 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ android {
134134
lint {
135135
lintConfig = file("lint.xml")
136136
}
137+
138+
androidResources {
139+
generateLocaleConfig = true
140+
}
137141
}
138142

139143
allOpen {

app/src/main/AndroidManifest.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,14 @@
5555
<category android:name="android.intent.category.DEFAULT" />
5656
</intent-filter>
5757
</activity>
58+
59+
<service
60+
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
61+
android:enabled="false"
62+
android:exported="false">
63+
<meta-data
64+
android:name="autoStoreLocales"
65+
android:value="true" />
66+
</service>
5867
</application>
5968
</manifest>

app/src/main/kotlin/com/vrem/util/CompatUtils.kt

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,9 @@ package com.vrem.util
2020
import android.content.Context
2121
import android.content.pm.PackageInfo
2222
import android.content.pm.PackageManager.PackageInfoFlags
23-
import android.content.res.Configuration
24-
import android.content.res.Resources
2523
import android.net.wifi.ScanResult
2624
import android.os.Build
2725
import androidx.annotation.RequiresApi
28-
import java.util.Locale
29-
30-
fun Context.createContext(newLocale: Locale): Context {
31-
val resources: Resources = resources
32-
val configuration: Configuration = resources.configuration
33-
configuration.setLocale(newLocale)
34-
return createConfigurationContext(configuration)
35-
}
3626

3727
fun Context.packageInfo(): PackageInfo =
3828
if (buildMinVersionT()) {

app/src/main/kotlin/com/vrem/util/LocaleUtils.kt

Lines changed: 76 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -20,80 +20,103 @@ package com.vrem.util
2020
import java.util.Locale
2121
import java.util.SortedMap
2222

23-
private object SyncAvoid {
24-
val defaultLocale: Locale = Locale.getDefault()
25-
val countryCodes: Set<String> = Locale.getISOCountries().toSet()
26-
val availableLocales: List<Locale> = Locale.getAvailableLocales().filter { countryCodes.contains(it.country) }
27-
28-
val countriesLocales: SortedMap<String, Locale> =
23+
private val currentLocale: Locale get() = Locale.getDefault()
24+
private val countryCodes: Set<String> = Locale.getISOCountries().toSet()
25+
private val availableLocales: List<Locale> = Locale.getAvailableLocales().filter { countryCodes.contains(it.country) }
26+
private val countriesLocales: SortedMap<String, Locale>
27+
get() =
2928
availableLocales
30-
.associateBy { it.country.toCapitalize(Locale.getDefault()) }
29+
.associateBy { it.country.toCapitalize(currentLocale) }
3130
.toSortedMap()
32-
val supportedLocales: List<Locale> =
33-
setOf(
34-
BULGARIAN,
35-
DUTCH,
36-
GREEK,
37-
HUNGARIAN,
38-
Locale.SIMPLIFIED_CHINESE,
39-
Locale.TRADITIONAL_CHINESE,
40-
Locale.ENGLISH,
41-
Locale.FRENCH,
42-
Locale.GERMAN,
43-
Locale.ITALIAN,
44-
Locale.JAPANESE,
45-
POLISH,
46-
PORTUGUESE_BRAZIL,
47-
PORTUGUESE_PORTUGAL,
48-
SPANISH,
49-
RUSSIAN,
50-
TURKISH,
51-
UKRAINIAN,
52-
defaultLocale,
53-
).toList()
54-
}
5531

5632
val BULGARIAN: Locale = Locale.forLanguageTag("bg")
33+
val CHINESE: Locale = Locale.forLanguageTag("zh")
34+
val CHINESE_SIMPLIFIED: Locale = Locale.forLanguageTag("zh-Hans")
35+
val CHINESE_TRADITIONAL: Locale = Locale.forLanguageTag("zh-Hant")
5736
val DUTCH: Locale = Locale.forLanguageTag("nl")
37+
val ENGLISH: Locale = Locale.forLanguageTag("en")
38+
val FRENCH: Locale = Locale.forLanguageTag("fr")
39+
val GERMAN: Locale = Locale.forLanguageTag("de")
5840
val GREEK: Locale = Locale.forLanguageTag("el")
5941
val HUNGARIAN: Locale = Locale.forLanguageTag("hu")
42+
val ITALIAN: Locale = Locale.forLanguageTag("it")
43+
val JAPANESE: Locale = Locale.forLanguageTag("ja")
6044
val POLISH: Locale = Locale.forLanguageTag("pl")
61-
val PORTUGUESE_PORTUGAL: Locale = Locale.forLanguageTag("pt-PT")
6245
val PORTUGUESE_BRAZIL: Locale = Locale.forLanguageTag("pt-BR")
63-
val SPANISH: Locale = Locale.forLanguageTag("es")
46+
val PORTUGUESE_PORTUGAL: Locale = Locale.forLanguageTag("pt-PT")
6447
val RUSSIAN: Locale = Locale.forLanguageTag("ru")
48+
val SPANISH: Locale = Locale.forLanguageTag("es")
6549
val TURKISH: Locale = Locale.forLanguageTag("tr")
6650
val UKRAINIAN: Locale = Locale.forLanguageTag("uk")
6751

68-
private const val SEPARATOR: String = "_"
52+
val baseSupportedLocales: List<Locale> =
53+
listOf(
54+
BULGARIAN,
55+
CHINESE_SIMPLIFIED,
56+
CHINESE_TRADITIONAL,
57+
DUTCH,
58+
ENGLISH,
59+
FRENCH,
60+
GERMAN,
61+
GREEK,
62+
HUNGARIAN,
63+
ITALIAN,
64+
JAPANESE,
65+
POLISH,
66+
PORTUGUESE_BRAZIL,
67+
PORTUGUESE_PORTUGAL,
68+
RUSSIAN,
69+
SPANISH,
70+
TURKISH,
71+
UKRAINIAN,
72+
)
6973

7074
fun findByCountryCode(countryCode: String): Locale =
71-
SyncAvoid.availableLocales.firstOrNull { countryCode.toCapitalize(Locale.getDefault()) == it.country }
72-
?: SyncAvoid.defaultLocale
75+
availableLocales.firstOrNull { countryCode.uppercase(Locale.ROOT) == it.country }
76+
?: currentLocale
7377

74-
fun allCountries(): List<Locale> = SyncAvoid.countriesLocales.values.toList()
78+
fun allCountries(): List<Locale> = countriesLocales.values.toList()
7579

76-
fun findByLanguageTag(languageTag: String): Locale {
77-
val languageTagPredicate: (Locale) -> Boolean = {
78-
val locale: Locale = fromLanguageTag(languageTag)
79-
it.language == locale.language && it.country == locale.country
80-
}
81-
return SyncAvoid.supportedLocales.firstOrNull(languageTagPredicate) ?: SyncAvoid.defaultLocale
82-
}
80+
fun supportedLanguages(): List<Locale> = (baseSupportedLocales + currentLocale).distinct()
8381

84-
fun supportedLanguages(): List<Locale> = SyncAvoid.supportedLocales
82+
fun supportedLanguageTags(): List<String> = listOf("") + baseSupportedLocales.map { it.toLanguageTag() }
8583

86-
fun defaultCountryCode(): String = SyncAvoid.defaultLocale.country
84+
private fun normalizeLanguageTag(languageTag: String): String = languageTag.replace('_', '-').trim()
8785

88-
fun defaultLanguageTag(): String = toLanguageTag(SyncAvoid.defaultLocale)
86+
private val chineseCountryToLocale: Map<String, Locale> =
87+
mapOf(
88+
"CN" to CHINESE_SIMPLIFIED,
89+
"SG" to CHINESE_SIMPLIFIED,
90+
"TW" to CHINESE_TRADITIONAL,
91+
"HK" to CHINESE_TRADITIONAL,
92+
"MO" to CHINESE_TRADITIONAL,
93+
)
8994

90-
fun toLanguageTag(locale: Locale): String = locale.language + SEPARATOR + locale.country
95+
fun findByLanguageTag(languageTag: String): Locale {
96+
val normalizedLanguageTag = normalizeLanguageTag(languageTag)
97+
if (normalizedLanguageTag.isEmpty()) return currentLocale
98+
return findSupportedLocale(Locale.forLanguageTag(normalizedLanguageTag))
99+
}
91100

92-
private fun fromLanguageTag(languageTag: String): Locale {
93-
val codes: Array<String> = languageTag.split(SEPARATOR).toTypedArray()
94-
return when (codes.size) {
95-
1 -> Locale.forLanguageTag(codes[0])
96-
2 -> Locale.forLanguageTag("${codes[0]}-${codes[1].toCapitalize(Locale.getDefault())}")
97-
else -> SyncAvoid.defaultLocale
101+
fun findSupportedLocale(target: Locale): Locale {
102+
if (target.language.isEmpty()) return currentLocale
103+
104+
if (target.language == "zh" && target.script.isEmpty()) {
105+
if (target.country.isEmpty()) return CHINESE
106+
return chineseCountryToLocale[target.country] ?: CHINESE
98107
}
108+
109+
return baseSupportedLocales.find { it == target }
110+
?: baseSupportedLocales.find { it.language == target.language && it.script == target.script }
111+
?: baseSupportedLocales.find { it.language == target.language && it.country == target.country }
112+
?: baseSupportedLocales.find { it.language == target.language }
113+
?: currentLocale
99114
}
115+
116+
fun currentCountryCode(): String = currentLocale.country
117+
118+
fun currentLanguageTag(): String = currentLocale.toLanguageTag()
119+
120+
fun toLanguageTag(locale: Locale): String = locale.toLanguageTag()
121+
122+
fun Locale.toSupportedLocaleTag(): String = findSupportedLocale(this).toLanguageTag()

app/src/main/kotlin/com/vrem/util/StringUtils.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,5 @@ fun String.Companion.nullToEmpty(value: String?): String = value ?: String.EMPTY
2727
fun String.specialTrim(): String = this.trim { it <= ' ' }.replace(" +".toRegex(), String.SPACE_SEPARATOR)
2828

2929
fun String.toCapitalize(locale: Locale): String = this.replaceFirstChar { word -> word.uppercase(locale) }
30+
31+
fun String.titlecaseFirst(locale: Locale): String = replaceFirstChar { it.titlecase(locale) }

app/src/main/kotlin/com/vrem/wifianalyzer/MainActivity.kt

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
*/
1818
package com.vrem.wifianalyzer
1919

20-
import android.content.Context
2120
import android.content.SharedPreferences
2221
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
2322
import android.content.res.Configuration
@@ -26,18 +25,17 @@ import android.view.Menu
2625
import android.view.MenuItem
2726
import android.view.View
2827
import androidx.appcompat.app.AppCompatActivity
28+
import androidx.appcompat.app.AppCompatDelegate
29+
import androidx.core.os.LocaleListCompat
2930
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
3031
import androidx.core.view.GravityCompat
3132
import androidx.drawerlayout.widget.DrawerLayout
3233
import com.google.android.material.navigation.NavigationView
3334
import com.vrem.annotation.OpenClass
34-
import com.vrem.util.createContext
3535
import com.vrem.wifianalyzer.navigation.NavigationMenu
3636
import com.vrem.wifianalyzer.navigation.NavigationMenuControl
3737
import com.vrem.wifianalyzer.navigation.NavigationMenuController
3838
import com.vrem.wifianalyzer.navigation.options.OptionMenu
39-
import com.vrem.wifianalyzer.settings.Repository
40-
import com.vrem.wifianalyzer.settings.Settings
4139
import com.vrem.wifianalyzer.wifi.accesspoint.ConnectionView
4240
import com.vrem.wifianalyzer.wifi.scanner.ScannerService
4341

@@ -52,15 +50,13 @@ class MainActivity :
5250
internal lateinit var optionMenu: OptionMenu
5351
internal lateinit var connectionView: ConnectionView
5452

55-
override fun attachBaseContext(newBase: Context) =
56-
super.attachBaseContext(newBase.createContext(Settings(Repository(newBase)).languageLocale()))
57-
5853
override fun onCreate(savedInstanceState: Bundle?) {
5954
val mainContext = MainContext.INSTANCE
6055
mainContext.initialize(this, largeScreen)
6156

6257
val settings = mainContext.settings
6358
settings.initializeDefaultValues()
59+
settings.syncLanguage()
6460
settings.themeStyle().setTheme(this)
6561

6662
mainReload = MainReload(settings)
@@ -69,7 +65,6 @@ class MainActivity :
6965
installSplashScreen()
7066
setContentView(R.layout.main_activity)
7167

72-
settings.registerOnSharedPreferenceChangeListener(this)
7368
optionMenu = OptionMenu()
7469

7570
keepScreenOn()
@@ -84,9 +79,16 @@ class MainActivity :
8479

8580
connectionView = ConnectionView(this)
8681

82+
settings.registerOnSharedPreferenceChangeListener(this)
83+
8784
onBackPressedDispatcher.addCallback(this, MainActivityBackPressed(this))
8885
}
8986

87+
override fun onDestroy() {
88+
MainContext.INSTANCE.settings.unregisterOnSharedPreferenceChangeListener(this)
89+
super.onDestroy()
90+
}
91+
9092
public override fun onPostCreate(savedInstanceState: Bundle?) {
9193
super.onPostCreate(savedInstanceState)
9294
drawerNavigation.syncState()
@@ -120,6 +122,18 @@ class MainActivity :
120122
sharedPreferences: SharedPreferences,
121123
key: String?,
122124
) {
125+
val languageKey = getString(R.string.language_key)
126+
if (key == languageKey) {
127+
val languageTag = sharedPreferences.getString(languageKey, "")
128+
val locales =
129+
languageTag
130+
?.takeIf { it.isNotEmpty() }
131+
?.let(LocaleListCompat::forLanguageTags)
132+
?: LocaleListCompat.getEmptyLocaleList()
133+
134+
AppCompatDelegate.setApplicationLocales(locales)
135+
}
136+
123137
val mainContext = MainContext.INSTANCE
124138
if (mainReload.shouldReload(mainContext.settings)) {
125139
MainContext.INSTANCE.scannerService.stop()

app/src/main/kotlin/com/vrem/wifianalyzer/MainReload.kt

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ package com.vrem.wifianalyzer
2020
import com.vrem.wifianalyzer.settings.Settings
2121
import com.vrem.wifianalyzer.settings.ThemeStyle
2222
import com.vrem.wifianalyzer.wifi.accesspoint.ConnectionViewType
23-
import java.util.Locale
2423

2524
class MainReload(
2625
settings: Settings,
@@ -29,11 +28,8 @@ class MainReload(
2928
private set
3029
var connectionViewType: ConnectionViewType
3130
private set
32-
var languageLocale: Locale
33-
private set
3431

35-
fun shouldReload(settings: Settings): Boolean =
36-
themeChanged(settings) || connectionViewTypeChanged(settings) || languageChanged(settings)
32+
fun shouldReload(settings: Settings): Boolean = themeChanged(settings) || connectionViewTypeChanged(settings)
3733

3834
private fun connectionViewTypeChanged(settings: Settings): Boolean {
3935
val currentConnectionViewType = settings.connectionViewType()
@@ -53,18 +49,8 @@ class MainReload(
5349
return themeChanged
5450
}
5551

56-
private fun languageChanged(settings: Settings): Boolean {
57-
val settingLanguageLocale = settings.languageLocale()
58-
val languageLocaleChanged = languageLocale != settingLanguageLocale
59-
if (languageLocaleChanged) {
60-
languageLocale = settingLanguageLocale
61-
}
62-
return languageLocaleChanged
63-
}
64-
6552
init {
6653
themeStyle = settings.themeStyle()
6754
connectionViewType = settings.connectionViewType()
68-
languageLocale = settings.languageLocale()
6955
}
7056
}

app/src/main/kotlin/com/vrem/wifianalyzer/settings/CountryPreference.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ package com.vrem.wifianalyzer.settings
1919

2020
import android.content.Context
2121
import android.util.AttributeSet
22-
import com.vrem.util.defaultCountryCode
22+
import com.vrem.util.currentCountryCode
2323
import com.vrem.wifianalyzer.MainContext
2424
import com.vrem.wifianalyzer.wifi.band.WiFiChannelCountry
2525
import java.util.Locale
2626

2727
private fun data(): List<Data> {
28-
val currentLocale: Locale = MainContext.INSTANCE.settings.languageLocale()
28+
val currentLocale: Locale = MainContext.INSTANCE.settings.appLocale()
2929
return WiFiChannelCountry
3030
.findAll()
3131
.map { Data(it.countryCode, it.countryName(currentLocale)) }
@@ -35,4 +35,4 @@ private fun data(): List<Data> {
3535
class CountryPreference(
3636
context: Context,
3737
attrs: AttributeSet,
38-
) : CustomPreference(context, attrs, data(), defaultCountryCode())
38+
) : CustomPreference(context, attrs, data(), currentCountryCode())

app/src/main/kotlin/com/vrem/wifianalyzer/settings/LanguagePreference.kt

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,25 @@ package com.vrem.wifianalyzer.settings
1919

2020
import android.content.Context
2121
import android.util.AttributeSet
22-
import com.vrem.util.defaultLanguageTag
23-
import com.vrem.util.supportedLanguages
24-
import com.vrem.util.toCapitalize
25-
import com.vrem.util.toLanguageTag
22+
import com.vrem.util.supportedLanguageTags
23+
import com.vrem.util.titlecaseFirst
24+
import com.vrem.wifianalyzer.R
2625
import java.util.Locale
2726

28-
private fun data(): List<Data> =
29-
supportedLanguages()
30-
.map { map(it) }
31-
.sorted()
27+
private fun data(context: Context): List<Data> {
28+
val systemDefault = Data("", context.getString(R.string.system_default))
29+
val languages =
30+
supportedLanguageTags()
31+
.filter { it.isNotEmpty() }
32+
.map { tag ->
33+
val locale = Locale.forLanguageTag(tag)
34+
Data(tag, locale.getDisplayName(locale).titlecaseFirst(locale))
35+
}.sortedBy { it.name }
3236

33-
private fun map(it: Locale): Data = Data(toLanguageTag(it), it.getDisplayName(it).toCapitalize(Locale.getDefault()))
37+
return listOf(systemDefault) + languages
38+
}
3439

3540
class LanguagePreference(
3641
context: Context,
3742
attrs: AttributeSet,
38-
) : CustomPreference(context, attrs, data(), defaultLanguageTag())
43+
) : CustomPreference(context, attrs, data(context), "")

0 commit comments

Comments
 (0)