Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
99f1b5a
enable locale config generation
314systems Dec 26, 2025
943a4dd
add AppLocalesMetadataHolderService to manifest
314systems Dec 26, 2025
83771c1
refactor LocaleUtils and update settings references
314systems Dec 27, 2025
96ec463
add system_default string to translations
314systems Dec 27, 2025
6d5cc0a
update locale utils and language preference
314systems Dec 27, 2025
4758f5d
refactor locale handling to use AppCompatDelegate for application loc…
314systems Dec 27, 2025
730dc4f
update LocaleUtils and refactor language preference tests
314systems Dec 28, 2025
402dcca
reformat
314systems Dec 29, 2025
dc91a1e
Merge branch 'main' into lang
VREMSoftwareDevelopment Dec 29, 2025
a411b65
Merge branch 'main' into lang
VREMSoftwareDevelopment Dec 31, 2025
c3f3466
reorder and update supported locales in LocaleUtils and LocaleUtilsTest
314systems Jan 1, 2026
8ce2941
rename variable
314systems Jan 1, 2026
feaa2c8
remove createContext from CompatUtils and CompatUtilsTest
314systems Jan 1, 2026
286c209
add support for Chinese language tags and update tests
314systems Jan 1, 2026
e6bd8aa
Merge branch 'main' into lang
VREMSoftwareDevelopment Jan 2, 2026
903780a
Merge branch 'main' into lang
314systems Feb 24, 2026
90b5684
update SettingsTest with appLocale and syncLanguage tests
314systems Feb 24, 2026
82fdb5e
refactor LocaleUtils to use a getter for countriesLocales
314systems Feb 24, 2026
3736705
refactor LocaleUtils to extract findSupportedLocale method
314systems Feb 24, 2026
bd4a529
fix generateLocaleConfig syntax in build.gradle
314systems Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ android {
lint {
lintConfig = file("lint.xml")
}

androidResources {
generateLocaleConfig = true
}
}

allOpen {
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 @@ -55,5 +55,14 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
</manifest>
10 changes: 0 additions & 10 deletions app/src/main/kotlin/com/vrem/util/CompatUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,9 @@ package com.vrem.util
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager.PackageInfoFlags
import android.content.res.Configuration
import android.content.res.Resources
import android.net.wifi.ScanResult
import android.os.Build
import androidx.annotation.RequiresApi
import java.util.Locale

fun Context.createContext(newLocale: Locale): Context {
val resources: Resources = resources
val configuration: Configuration = resources.configuration
configuration.setLocale(newLocale)
return createConfigurationContext(configuration)
}

fun Context.packageInfo(): PackageInfo =
if (buildMinVersionT()) {
Expand Down
129 changes: 76 additions & 53 deletions app/src/main/kotlin/com/vrem/util/LocaleUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,80 +20,103 @@ package com.vrem.util
import java.util.Locale
import java.util.SortedMap

private object SyncAvoid {
val defaultLocale: Locale = Locale.getDefault()
val countryCodes: Set<String> = Locale.getISOCountries().toSet()
val availableLocales: List<Locale> = Locale.getAvailableLocales().filter { countryCodes.contains(it.country) }

val countriesLocales: SortedMap<String, Locale> =
private val currentLocale: Locale get() = Locale.getDefault()
private val countryCodes: Set<String> = Locale.getISOCountries().toSet()
private val availableLocales: List<Locale> = Locale.getAvailableLocales().filter { countryCodes.contains(it.country) }
private val countriesLocales: SortedMap<String, Locale>
get() =
availableLocales
.associateBy { it.country.toCapitalize(Locale.getDefault()) }
.associateBy { it.country.toCapitalize(currentLocale) }
.toSortedMap()
val supportedLocales: List<Locale> =
setOf(
BULGARIAN,
DUTCH,
GREEK,
HUNGARIAN,
Locale.SIMPLIFIED_CHINESE,
Locale.TRADITIONAL_CHINESE,
Locale.ENGLISH,
Locale.FRENCH,
Locale.GERMAN,
Locale.ITALIAN,
Locale.JAPANESE,
POLISH,
PORTUGUESE_BRAZIL,
PORTUGUESE_PORTUGAL,
SPANISH,
RUSSIAN,
TURKISH,
UKRAINIAN,
defaultLocale,
).toList()
}

val BULGARIAN: Locale = Locale.forLanguageTag("bg")
val CHINESE: Locale = Locale.forLanguageTag("zh")
val CHINESE_SIMPLIFIED: Locale = Locale.forLanguageTag("zh-Hans")
val CHINESE_TRADITIONAL: Locale = Locale.forLanguageTag("zh-Hant")
val DUTCH: Locale = Locale.forLanguageTag("nl")
val ENGLISH: Locale = Locale.forLanguageTag("en")
val FRENCH: Locale = Locale.forLanguageTag("fr")
val GERMAN: Locale = Locale.forLanguageTag("de")
val GREEK: Locale = Locale.forLanguageTag("el")
val HUNGARIAN: Locale = Locale.forLanguageTag("hu")
val ITALIAN: Locale = Locale.forLanguageTag("it")
val JAPANESE: Locale = Locale.forLanguageTag("ja")
val POLISH: Locale = Locale.forLanguageTag("pl")
val PORTUGUESE_PORTUGAL: Locale = Locale.forLanguageTag("pt-PT")
val PORTUGUESE_BRAZIL: Locale = Locale.forLanguageTag("pt-BR")
val SPANISH: Locale = Locale.forLanguageTag("es")
val PORTUGUESE_PORTUGAL: Locale = Locale.forLanguageTag("pt-PT")
val RUSSIAN: Locale = Locale.forLanguageTag("ru")
val SPANISH: Locale = Locale.forLanguageTag("es")
val TURKISH: Locale = Locale.forLanguageTag("tr")
val UKRAINIAN: Locale = Locale.forLanguageTag("uk")

private const val SEPARATOR: String = "_"
val baseSupportedLocales: List<Locale> =
listOf(
BULGARIAN,
CHINESE_SIMPLIFIED,
CHINESE_TRADITIONAL,
DUTCH,
ENGLISH,
FRENCH,
GERMAN,
GREEK,
HUNGARIAN,
ITALIAN,
JAPANESE,
POLISH,
PORTUGUESE_BRAZIL,
PORTUGUESE_PORTUGAL,
RUSSIAN,
SPANISH,
TURKISH,
UKRAINIAN,
)

fun findByCountryCode(countryCode: String): Locale =
SyncAvoid.availableLocales.firstOrNull { countryCode.toCapitalize(Locale.getDefault()) == it.country }
?: SyncAvoid.defaultLocale
availableLocales.firstOrNull { countryCode.uppercase(Locale.ROOT) == it.country }
?: currentLocale

fun allCountries(): List<Locale> = SyncAvoid.countriesLocales.values.toList()
fun allCountries(): List<Locale> = countriesLocales.values.toList()

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

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

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

fun defaultLanguageTag(): String = toLanguageTag(SyncAvoid.defaultLocale)
private val chineseCountryToLocale: Map<String, Locale> =
mapOf(
"CN" to CHINESE_SIMPLIFIED,
"SG" to CHINESE_SIMPLIFIED,
"TW" to CHINESE_TRADITIONAL,
"HK" to CHINESE_TRADITIONAL,
"MO" to CHINESE_TRADITIONAL,
)

fun toLanguageTag(locale: Locale): String = locale.language + SEPARATOR + locale.country
fun findByLanguageTag(languageTag: String): Locale {
val normalizedLanguageTag = normalizeLanguageTag(languageTag)
if (normalizedLanguageTag.isEmpty()) return currentLocale
return findSupportedLocale(Locale.forLanguageTag(normalizedLanguageTag))
}

private fun fromLanguageTag(languageTag: String): Locale {
val codes: Array<String> = languageTag.split(SEPARATOR).toTypedArray()
return when (codes.size) {
1 -> Locale.forLanguageTag(codes[0])
2 -> Locale.forLanguageTag("${codes[0]}-${codes[1].toCapitalize(Locale.getDefault())}")
else -> SyncAvoid.defaultLocale
fun findSupportedLocale(target: Locale): Locale {
if (target.language.isEmpty()) return currentLocale

if (target.language == "zh" && target.script.isEmpty()) {
if (target.country.isEmpty()) return CHINESE
return chineseCountryToLocale[target.country] ?: CHINESE
}

return baseSupportedLocales.find { it == target }
?: baseSupportedLocales.find { it.language == target.language && it.script == target.script }
?: baseSupportedLocales.find { it.language == target.language && it.country == target.country }
?: baseSupportedLocales.find { it.language == target.language }
?: currentLocale
}

fun currentCountryCode(): String = currentLocale.country

fun currentLanguageTag(): String = currentLocale.toLanguageTag()

fun toLanguageTag(locale: Locale): String = locale.toLanguageTag()

fun Locale.toSupportedLocaleTag(): String = findSupportedLocale(this).toLanguageTag()
2 changes: 2 additions & 0 deletions app/src/main/kotlin/com/vrem/util/StringUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ fun String.Companion.nullToEmpty(value: String?): String = value ?: String.EMPTY
fun String.specialTrim(): String = this.trim { it <= ' ' }.replace(" +".toRegex(), String.SPACE_SEPARATOR)

fun String.toCapitalize(locale: Locale): String = this.replaceFirstChar { word -> word.uppercase(locale) }

fun String.titlecaseFirst(locale: Locale): String = replaceFirstChar { it.titlecase(locale) }
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new titlecaseFirst() extension function in StringUtils.kt lacks test coverage. This function is used to capitalize language display names in the language preference list. Consider adding test cases to verify its behavior with various inputs, including edge cases like empty strings, strings with different Unicode characters, and strings in different locales to ensure proper title casing.

Copilot uses AI. Check for mistakes.
22 changes: 15 additions & 7 deletions app/src/main/kotlin/com/vrem/wifianalyzer/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
*/
package com.vrem.wifianalyzer

import android.content.Context
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.content.res.Configuration
Expand All @@ -26,18 +25,17 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import com.google.android.material.navigation.NavigationView
import com.vrem.annotation.OpenClass
import com.vrem.util.createContext
import com.vrem.wifianalyzer.navigation.NavigationMenu
import com.vrem.wifianalyzer.navigation.NavigationMenuControl
import com.vrem.wifianalyzer.navigation.NavigationMenuController
import com.vrem.wifianalyzer.navigation.options.OptionMenu
import com.vrem.wifianalyzer.settings.Repository
import com.vrem.wifianalyzer.settings.Settings
import com.vrem.wifianalyzer.wifi.accesspoint.ConnectionView
import com.vrem.wifianalyzer.wifi.scanner.ScannerService

Expand All @@ -52,15 +50,13 @@ class MainActivity :
internal lateinit var optionMenu: OptionMenu
internal lateinit var connectionView: ConnectionView

override fun attachBaseContext(newBase: Context) =
super.attachBaseContext(newBase.createContext(Settings(Repository(newBase)).languageLocale()))

override fun onCreate(savedInstanceState: Bundle?) {
val mainContext = MainContext.INSTANCE
mainContext.initialize(this, largeScreen)

val settings = mainContext.settings
settings.initializeDefaultValues()
settings.syncLanguage()
settings.themeStyle().setTheme(this)

mainReload = MainReload(settings)
Expand Down Expand Up @@ -120,6 +116,18 @@ class MainActivity :
sharedPreferences: SharedPreferences,
key: String?,
) {
val languageKey = getString(R.string.language_key)
if (key == languageKey) {
val languageTag = sharedPreferences.getString(languageKey, "")
val locales =
languageTag
?.takeIf { it.isNotEmpty() }
?.let(LocaleListCompat::forLanguageTags)
?: LocaleListCompat.getEmptyLocaleList()

AppCompatDelegate.setApplicationLocales(locales)
}
Comment on lines +119 to +129
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new language preference change handling logic in onSharedPreferenceChanged (lines 119-129) lacks test coverage. This is critical functionality that calls AppCompatDelegate.setApplicationLocales() when the language preference changes. Consider adding test cases that verify the locale is correctly set when the language_key preference changes, including edge cases like empty strings and null values.

Copilot uses AI. Check for mistakes.

val mainContext = MainContext.INSTANCE
if (mainReload.shouldReload(mainContext.settings)) {
MainContext.INSTANCE.scannerService.stop()
Expand Down
16 changes: 1 addition & 15 deletions app/src/main/kotlin/com/vrem/wifianalyzer/MainReload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ package com.vrem.wifianalyzer
import com.vrem.wifianalyzer.settings.Settings
import com.vrem.wifianalyzer.settings.ThemeStyle
import com.vrem.wifianalyzer.wifi.accesspoint.ConnectionViewType
import java.util.Locale

class MainReload(
settings: Settings,
Expand All @@ -29,11 +28,8 @@ class MainReload(
private set
var connectionViewType: ConnectionViewType
private set
var languageLocale: Locale
private set

fun shouldReload(settings: Settings): Boolean =
themeChanged(settings) || connectionViewTypeChanged(settings) || languageChanged(settings)
fun shouldReload(settings: Settings): Boolean = themeChanged(settings) || connectionViewTypeChanged(settings)

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

private fun languageChanged(settings: Settings): Boolean {
val settingLanguageLocale = settings.languageLocale()
val languageLocaleChanged = languageLocale != settingLanguageLocale
if (languageLocaleChanged) {
languageLocale = settingLanguageLocale
}
return languageLocaleChanged
}

init {
themeStyle = settings.themeStyle()
connectionViewType = settings.connectionViewType()
languageLocale = settings.languageLocale()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ package com.vrem.wifianalyzer.settings

import android.content.Context
import android.util.AttributeSet
import com.vrem.util.defaultCountryCode
import com.vrem.util.currentCountryCode
import com.vrem.wifianalyzer.MainContext
import com.vrem.wifianalyzer.wifi.band.WiFiChannelCountry
import java.util.Locale

private fun data(): List<Data> {
val currentLocale: Locale = MainContext.INSTANCE.settings.languageLocale()
val currentLocale: Locale = MainContext.INSTANCE.settings.appLocale()
return WiFiChannelCountry
.findAll()
.map { Data(it.countryCode, it.countryName(currentLocale)) }
Expand All @@ -35,4 +35,4 @@ private fun data(): List<Data> {
class CountryPreference(
context: Context,
attrs: AttributeSet,
) : CustomPreference(context, attrs, data(), defaultCountryCode())
) : CustomPreference(context, attrs, data(), currentCountryCode())
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,22 @@ package com.vrem.wifianalyzer.settings

import android.content.Context
import android.util.AttributeSet
import com.vrem.util.defaultLanguageTag
import com.vrem.util.supportedLanguages
import com.vrem.util.toCapitalize
import com.vrem.util.toLanguageTag
import com.vrem.util.supportedLanguageTags
import com.vrem.util.titlecaseFirst
import com.vrem.wifianalyzer.R
import java.util.Locale

private fun data(): List<Data> =
supportedLanguages()
.map { map(it) }
.sorted()

private fun map(it: Locale): Data = Data(toLanguageTag(it), it.getDisplayName(it).toCapitalize(Locale.getDefault()))
private fun data(context: Context): List<Data> =
supportedLanguageTags().map { tag ->
if (tag.isEmpty()) {
Data("", context.getString(R.string.system_default))
} else {
val locale = Locale.forLanguageTag(tag)
Data(tag, locale.getDisplayName(locale).titlecaseFirst(locale))
}
}

class LanguagePreference(
context: Context,
attrs: AttributeSet,
) : CustomPreference(context, attrs, data(), defaultLanguageTag())
) : CustomPreference(context, attrs, data(context), "")
Loading
Loading