Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -127,6 +127,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
130 changes: 75 additions & 55 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,100 @@ 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> =
availableLocales
.associateBy { it.country.toCapitalize(Locale.getDefault()) }
.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()
}
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> =
availableLocales
Comment on lines +26 to +27
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 initialization of countriesLocales on line 28 uses currentLocale which calls Locale.getDefault() at initialization time. This means the map keys will be capitalized using the default locale at app startup. If the user later changes the app's language preference, the map keys will still be in the original locale's capitalization, which could cause inconsistencies in country name display. Consider making countriesLocales a function that computes the map dynamically, or ensure this is the intended behavior and document it.

Suggested change
private val countriesLocales: SortedMap<String, Locale> =
availableLocales
private val countriesLocales: SortedMap<String, Locale>
get() = availableLocales

Copilot uses AI. Check for mistakes.
.associateBy { it.country.toCapitalize(currentLocale) }
.toSortedMap()

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

val target = Locale.forLanguageTag(normalizedLanguageTag)
if (target.language.isEmpty()) return currentLocale

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
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 = findByLanguageTag(this.toLanguageTag()).toLanguageTag()
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 toSupportedLocaleTag() extension function performs a round-trip conversion: it converts the locale to a language tag, calls findByLanguageTag which parses it back to a Locale, and then converts that back to a language tag. This is inefficient and could be simplified. Consider creating a direct method that maps a Locale to its supported equivalent without the intermediate string conversions, or at least add a comment explaining why this approach is necessary if there's a specific reason for it.

Copilot uses AI. Check for mistakes.
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()
setTheme(settings.themeStyle().themeNoActionBar)

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()

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 language preference change handling logic could benefit from a comment explaining the flow. Specifically, it would be helpful to document that when the language preference is changed, AppCompatDelegate.setApplicationLocales() is called, which triggers a configuration change and activity recreation, so the syncLanguage() call in onCreate will sync the preference value back to shared preferences after the recreation.

Suggested change
// When the language preference changes, update the application locales.
// This call triggers a configuration change and causes the activity to be recreated.
// After recreation, onCreate() is invoked again and settings.syncLanguage() is called,
// which syncs the effective language value back to shared preferences.

Copilot uses AI. Check for mistakes.
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