Fix per-app locale handling across contexts (#6047)

Use AndroidX and framework per-app locale APIs, migrate the existing language preference, and expose the generated locale config to Android 13+ settings.

Resolve resources through the selected app locale in application, ViewModel, tile, and service contexts. Replace the framework OK and Cancel labels directly implicated in the wrong-language report, and refresh retained localized UI state after locale changes.
This commit is contained in:
eliotcougar authored and GitHub committed 2026-08-09 21:09:41 +08:00
1 parent f63f649477
commit 739e303fc0
38 files changed
+327 -120

No files matched your search

+16
View File
@@ -128,6 +128,21 @@ android {
compose = true
}
androidResources {
generateLocaleConfig = true
localeFilters += listOf(
"en",
"zh-rCN",
"zh-rTW",
"vi",
"ru",
"fa",
"ar",
"bn",
"bqi-rIR"
)
}
packaging {
jniLibs {
useLegacyPackaging = true
@@ -142,6 +157,7 @@ dependencies {
// AndroidX Core Libraries
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
// Compose Libraries
implementation(platform(libs.androidx.compose.bom))
+9
View File
@@ -52,6 +52,15 @@
android:theme="@style/AppThemeDayNight.NoActionBar"
android:usesCleartextTraffic="true">
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<activity
android:name=".ui.main.MainActivity"
android:exported="true"
@@ -2,10 +2,12 @@ package com.v2ray.ang
import android.app.Application
import android.content.Context
import androidx.core.content.ContextCompat
import androidx.work.Configuration
import androidx.work.WorkManager
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.ui.compose.ThemeManager
@@ -19,7 +21,7 @@ class AngApplication : Application() {
* @param base The base context.
*/
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
super.attachBaseContext(base?.let(ContextCompat::getContextForLanguage))
application = this
}
@@ -35,6 +37,8 @@ class AngApplication : Application() {
MMKV.initialize(this)
AppLocaleManager.initialize(this)
// Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration)
@@ -56,6 +56,7 @@ object AppConfig {
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
const val PREF_LANGUAGE = "pref_language"
const val PREF_APP_LOCALE_MIGRATED = "pref_app_locale_migrated"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
@@ -1,20 +1,56 @@
package com.v2ray.ang.enums
enum class Language(val code: String) {
AUTO("auto"),
ENGLISH("en"),
CHINA("zh-rCN"),
TRADITIONAL_CHINESE("zh-rTW"),
VIETNAMESE("vi"),
RUSSIAN("ru"),
PERSIAN("fa"),
ARABIC("ar"),
BANGLA("bn"),
BAKHTIARI("bqi-rIR");
import androidx.core.os.LocaleListCompat
import java.util.Locale
enum class Language(val code: String, val languageTag: String) {
AUTO("auto", ""),
ENGLISH("en", "en"),
CHINA("zh-rCN", "zh-CN"),
TRADITIONAL_CHINESE("zh-rTW", "zh-TW"),
VIETNAMESE("vi", "vi"),
RUSSIAN("ru", "ru"),
PERSIAN("fa", "fa"),
ARABIC("ar", "ar"),
BANGLA("bn", "bn"),
BAKHTIARI("bqi-rIR", "bqi-IR");
val locale: Locale?
get() = languageTag.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag)
fun toLocaleList(): LocaleListCompat =
if (languageTag.isEmpty()) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(languageTag)
}
companion object {
fun fromCode(code: String): Language {
return entries.find { it.code == code } ?: AUTO
}
fun fromLanguageTag(languageTag: String?): Language {
if (languageTag.isNullOrBlank()) return AUTO
val locale = Locale.forLanguageTag(languageTag)
return when (locale.language) {
"en" -> ENGLISH
"zh" -> if (
locale.script == "Hant" || locale.country in setOf("TW", "HK", "MO")
) {
TRADITIONAL_CHINESE
} else {
CHINA
}
"vi" -> VIETNAMESE
"ru" -> RUSSIAN
"fa" -> PERSIAN
"ar" -> ARABIC
"bn" -> BANGLA
"bqi" -> BAKHTIARI
else -> AUTO
}
}
}
}
@@ -0,0 +1,140 @@
package com.v2ray.ang.handler
import android.app.LocaleManager
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.LocaleManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.os.ConfigurationCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.Language
/**
* Keeps the legacy in-app language preference synchronized with Android's per-app locale APIs.
*/
object AppLocaleManager {
/**
* Migrates the existing MMKV language preference and restores it before the first activity.
*/
fun initialize(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Api33.prepareMigration(context)
} else {
initializeCompat(context)
}
}
/**
* Completes the one-time handoff after AppCompat has attached the activity context.
*/
fun onActivityCreated(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Api33.completeMigration(context)
} else {
syncLegacyPreference(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag())
}
}
/**
* Applies a language selected in the app and lets AppCompat recreate the current activity.
*/
fun setApplicationLanguage(languageCode: String) {
val language = Language.fromCode(languageCode)
persistLegacyPreference(language)
AppCompatDelegate.setApplicationLocales(language.toLocaleList())
}
/**
* Returns a context suitable for resource access outside AppCompatActivity.
*/
fun localizedContext(context: Context): Context {
val localizedContext = ContextCompat.getContextForLanguage(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || localizedContext !== context) {
return localizedContext
}
// During the one-time upgrade from the old picker, AppCompat has not written its locale
// record yet. Use the still-current MMKV value for non-activity contexts on that launch.
val language = storedLanguage()
language.locale ?: return context
val configuration = Configuration(context.resources.configuration)
ConfigurationCompat.setLocales(configuration, language.toLocaleList())
return context.createConfigurationContext(configuration)
}
private fun initializeCompat(context: Context) {
val storedLocales = LocaleManagerCompat.getApplicationLocales(context)
if (storedLocales.isEmpty) {
val legacyLanguage = storedLanguage()
if (legacyLanguage != Language.AUTO) {
AppCompatDelegate.setApplicationLocales(legacyLanguage.toLocaleList())
}
} else {
syncLegacyPreference(storedLocales.get(0)?.toLanguageTag())
}
}
private fun storedLanguage(): Language = Language.fromCode(
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
)
private fun syncLegacyPreference(languageTag: String?) {
persistLegacyPreference(Language.fromLanguageTag(languageTag))
}
private fun persistLegacyPreference(language: Language) {
val current = MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE)
if (current != language.code) {
MmkvManager.encodeSettings(AppConfig.PREF_LANGUAGE, language.code)
SettingsChangeManager.notifySettingChanged(AppConfig.PREF_LANGUAGE)
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private object Api33 {
fun prepareMigration(context: Context) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_APP_LOCALE_MIGRATED, false)) {
return
}
val localeManager = context.getSystemService(LocaleManager::class.java)
val applicationLocales = localeManager.applicationLocales
if (!applicationLocales.isEmpty) {
syncLegacyPreference(applicationLocales.get(0)?.toLanguageTag())
return
}
val legacyLanguage = storedLanguage()
if (legacyLanguage != Language.AUTO) {
// Make application and service contexts correct even before the first activity.
localeManager.applicationLocales = android.os.LocaleList(
requireNotNull(legacyLanguage.locale)
)
}
}
fun completeMigration(context: Context) {
val localeManager = context.getSystemService(LocaleManager::class.java)
if (!MmkvManager.decodeSettingsBool(AppConfig.PREF_APP_LOCALE_MIGRATED, false)) {
val frameworkLanguage = Language.fromLanguageTag(
localeManager.applicationLocales.get(0)?.toLanguageTag()
)
val language = frameworkLanguage.takeUnless { it == Language.AUTO }
?: storedLanguage()
// Android's migration guidance requires this one-time AppCompat handoff after
// Activity.onCreate(), including on Android 13 and newer.
AppCompatDelegate.setApplicationLocales(language.toLocaleList())
persistLegacyPreference(language)
MmkvManager.encodeSettings(AppConfig.PREF_APP_LOCALE_MIGRATED, true)
return
}
syncLegacyPreference(localeManager.applicationLocales.get(0)?.toLanguageTag())
}
}
}
@@ -16,7 +16,6 @@ import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.extension.moveItem
@@ -31,7 +30,6 @@ import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.io.File
import java.io.FileOutputStream
import java.util.Locale
import kotlin.random.Random
object SettingsManager {
@@ -388,29 +386,6 @@ object SettingsManager {
return value.coerceIn(1, 128)
}
/**
* Get the locale.
* @return The locale.
*/
fun getLocale(): Locale {
val langCode =
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
val language = Language.fromCode(langCode)
return when (language) {
Language.AUTO -> Utils.getSysLocale()
Language.ENGLISH -> Locale.ENGLISH
Language.CHINA -> Locale.CHINA
Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
Language.VIETNAMESE -> Locale.forLanguageTag("vi")
Language.RUSSIAN -> Locale.forLanguageTag("ru")
Language.PERSIAN -> Locale.forLanguageTag("fa")
Language.ARABIC -> Locale.forLanguageTag("ar")
Language.BANGLA -> Locale.forLanguageTag("bn")
Language.BAKHTIARI -> Locale.forLanguageTag("bqi-IR")
}
}
/**
* Retrieves the currently selected VPN interface address configuration.
* This method reads the user's preference for VPN interface addressing and returns
@@ -7,10 +7,10 @@ import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import java.lang.ref.SoftReference
class CoreProxyOnlyService : Service(), ServiceControl {
@@ -96,9 +96,7 @@ class CoreProxyOnlyService : Service(), ServiceControl {
* @param newBase The new base context.
*/
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
}
val context = newBase?.let(AppLocaleManager::localizedContext)
super.attachBaseContext(context)
}
}
@@ -7,11 +7,11 @@ import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.root.RootProxyManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -90,9 +90,7 @@ class CoreRootService : Service(), ServiceControl {
override fun onBind(intent: Intent?): IBinder? = null
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
}
val context = newBase?.let(AppLocaleManager::localizedContext)
super.attachBaseContext(context)
}
}
@@ -2,6 +2,7 @@ package com.v2ray.ang.service
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
@@ -13,6 +14,7 @@ import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.helper.NotificationHelper
@@ -21,6 +23,10 @@ import java.util.Collections
class CoreTestService : Service() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(newBase?.let(AppLocaleManager::localizedContext))
}
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
private val cancelAction by lazy {
@@ -36,7 +42,7 @@ class CoreTestService : Service() {
)
NotificationCompat.Action.Builder(
R.drawable.ic_stop_24dp,
getString(android.R.string.cancel),
getString(R.string.action_cancel),
pendingIntent
).build()
}
@@ -17,12 +17,12 @@ import com.v2ray.ang.BuildConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.root.RootLanSharing
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
import java.util.concurrent.atomic.AtomicBoolean
@@ -129,9 +129,7 @@ class CoreVpnService : VpnService(), ServiceControl {
}
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
}
val context = newBase?.let(AppLocaleManager::localizedContext)
super.attachBaseContext(context)
}
@@ -12,6 +12,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.core.LauncherManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
@@ -19,6 +20,10 @@ import java.lang.ref.SoftReference
class QSTileService : TileService() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(newBase?.let(AppLocaleManager::localizedContext))
}
/**
* Sets the state of the tile.
* @param state The state to set.
@@ -13,6 +13,7 @@ import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.util.LogUtil
@@ -28,6 +29,10 @@ import java.util.concurrent.atomic.AtomicInteger
class SubscriptionUpdateService : Service() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(newBase?.let(AppLocaleManager::localizedContext))
}
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
@@ -122,7 +122,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
},
confirmButton = {
TextButton(onClick = { showOssDialog = false }) {
Text(stringResource(android.R.string.ok))
Text(stringResource(R.string.action_ok))
}
},
containerColor = MaterialTheme.colorScheme.surface,
@@ -41,16 +41,30 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
}
fun loadApps(context: Context) {
if (allApps != null || isAppListLoading) return
allApps?.let { apps ->
val localizedUnknownApp = getString(R.string.app_picker_unknown_app)
val updatedApps = apps.map { appInfo ->
if (appInfo.packageName == AppConfig.UNIDENTIFIED_PACKAGE) {
appInfo.copy(appName = localizedUnknownApp)
} else {
appInfo
}
}
allApps = sortApps(updatedApps)
_displayedApps.value = applyFilter(currentQuery)
return
}
if (isAppListLoading) return
val applicationContext = context.applicationContext
val localizedUnknownApp = getString(R.string.app_picker_unknown_app)
isAppListLoading = true
launchLoading {
try {
selectedSnapshot = _selectedPackages.value
val apps = withContext(Dispatchers.IO) {
val list = AppManagerUtil.loadNetworkAppList(applicationContext)
val special = createSpecialItemUnidentified(applicationContext)
val special = createSpecialItemUnidentified(localizedUnknownApp)
sortApps(list + special)
}
allApps = apps
@@ -122,9 +136,9 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
}
}
private fun createSpecialItemUnidentified(context: Context): AppInfo {
private fun createSpecialItemUnidentified(appName: String): AppInfo {
return AppInfo(
appName = context.getString(R.string.app_picker_unknown_app),
appName = appName,
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
isSystemApp = false,
isSelected = 0
@@ -317,7 +317,7 @@ private fun WebDavInputDialog(
}
},
confirmText = stringResource(R.string.menu_item_save_config),
dismissText = stringResource(android.R.string.cancel),
dismissText = stringResource(R.string.action_cancel),
onConfirm = {
onSave(
WebDavConfig(
@@ -1,23 +1,18 @@
package com.v2ray.ang.ui.base
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.ui.compose.AppTheme
import com.v2ray.ang.util.MyContextWrapper
abstract class BaseComponentActivity : ComponentActivity() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
}
abstract class BaseComponentActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AppLocaleManager.onActivityCreated(this)
enableEdgeToEdge()
setContent {
AppTheme {
@@ -1,12 +1,14 @@
package com.v2ray.ang.ui.base
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AngApplication
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AppLocaleManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,6 +29,9 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
application as AngApplication
}
protected val localizedContext: Context
get() = AppLocaleManager.localizedContext(app)
@Suppress("PropertyName")
protected val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
@@ -39,7 +44,7 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
* Send neutral toast event (Resource ID).
*/
fun toast(resId: Int) {
app.toast(resId)
localizedContext.toast(resId)
}
/**
@@ -53,7 +58,7 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
* Send success toast event (Resource ID).
*/
fun toastSuccess(resId: Int) {
app.toastSuccess(resId)
localizedContext.toastSuccess(resId)
}
/**
@@ -67,7 +72,7 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
* Send error toast event (Resource ID).
*/
fun toastError(resId: Int) {
app.toastError(resId)
localizedContext.toastError(resId)
}
/**
@@ -81,14 +86,14 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
* Get string from resource ID.
*/
fun getString(resId: Int): String {
return app.getString(resId)
return localizedContext.getString(resId)
}
/**
* Get formatted string from resource ID.
*/
fun getString(resId: Int, vararg formatArgs: Any?): String {
return app.getString(resId, *formatArgs)
return localizedContext.getString(resId, *formatArgs)
}
/**
@@ -113,4 +118,4 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
}
}
}
}
}
@@ -109,7 +109,7 @@ fun CheckUpdateScreen(
},
dismissButton = {
TextButton(onClick = { viewModel.dismissUpdateDialog() }) {
Text(stringResource(android.R.string.cancel))
Text(stringResource(R.string.action_cancel))
}
},
containerColor = MaterialTheme.colorScheme.surface
@@ -44,8 +44,8 @@ import com.v2ray.ang.R
fun ConfirmDialog(
title: String? = null,
message: String,
confirmText: String = stringResource(android.R.string.ok),
dismissText: String? = stringResource(android.R.string.cancel),
confirmText: String = stringResource(R.string.action_ok),
dismissText: String? = stringResource(R.string.action_cancel),
confirmIcon: @Composable (() -> Unit)? = null,
onConfirm: () -> Unit,
onDismiss: () -> Unit
@@ -242,7 +242,7 @@ fun <T> SelectListDialog(
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
Text(stringResource(R.string.action_cancel))
}
},
containerColor = MaterialTheme.colorScheme.surface
@@ -164,8 +164,8 @@ fun SettingsEditItem(
)
),
onFieldChange = { _, v -> text = v },
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
confirmText = stringResource(R.string.action_ok),
dismissText = stringResource(R.string.action_cancel),
onConfirm = { showDialog = false; onValueChanged(text) },
onDismiss = { showDialog = false }
)
@@ -15,6 +15,7 @@ import com.v2ray.ang.dto.entities.ServerAffiliationInfo
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SubscriptionUpdater
@@ -31,6 +32,9 @@ class MainRepository(
private val app: AngApplication
) : MainDataSource {
private val localizedContext: Context
get() = AppLocaleManager.localizedContext(app)
private val closed = AtomicBoolean(false)
private val _mainServiceEvent = MutableSharedFlow<MainServiceEvent>(
@@ -116,9 +120,10 @@ class MainRepository(
override fun isGroupAllDisplayEnabled(): Boolean =
MmkvManager.decodeSettingsBool(AppConfig.PREF_GROUP_ALL_DISPLAY)
override fun getString(resId: Int): String = app.getString(resId)
override fun getString(resId: Int): String = localizedContext.getString(resId)
override fun getString(resId: Int, vararg formatArgs: Any): String = app.getString(resId, *formatArgs)
override fun getString(resId: Int, vararg formatArgs: Any): String =
localizedContext.getString(resId, *formatArgs)
override fun getSubscriptions(): List<SubscriptionCache> {
val result = mutableListOf<SubscriptionCache>()
@@ -126,7 +131,7 @@ class MainRepository(
result += SubscriptionCache(
guid = "",
subscription = SubscriptionItem().apply {
remarks = app.getString(R.string.filter_config_all)
remarks = localizedContext.getString(R.string.filter_config_all)
}
)
}
@@ -15,6 +15,7 @@ import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -25,6 +26,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
import com.v2ray.ang.handler.MmkvManager.rememberMmkvString
import com.v2ray.ang.handler.SettingsChangeManager
@@ -120,7 +123,11 @@ fun SettingsScreen(
var confirmRemove by rememberMmkvBool(AppConfig.PREF_CONFIRM_REMOVE, false)
var doubleColumnDisplay by rememberMmkvBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
var groupAllDisplay by rememberMmkvBool(AppConfig.PREF_GROUP_ALL_DISPLAY, false)
var language by rememberMmkvString(AppConfig.PREF_LANGUAGE, "auto")
var language by remember {
mutableStateOf(
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE, "auto") ?: "auto"
)
}
var uiModeNight by rememberMmkvString(AppConfig.PREF_UI_MODE_NIGHT, "0")
var ipv6Enabled by rememberMmkvBool(AppConfig.PREF_IPV6_ENABLED, false)
@@ -225,7 +232,10 @@ fun SettingsScreen(
entries = languageEntries,
values = languageValues,
selectedValue = language,
onSelected = { language = it }
onSelected = {
language = it
AppLocaleManager.setApplicationLanguage(it)
}
)
SettingsListItem(
title = stringResource(R.string.title_pref_ui_mode_night),
@@ -323,12 +323,12 @@ fun SubSettingScreen(
showUpdateDialog = false
onSubUpdate()
}) {
Text(text = stringResource(android.R.string.ok))
Text(text = stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = { showUpdateDialog = false }) {
Text(text = stringResource(android.R.string.cancel))
Text(text = stringResource(R.string.action_cancel))
}
}
)
@@ -1,34 +0,0 @@
package com.v2ray.ang.util
import android.content.Context
import android.content.ContextWrapper
import android.content.res.Configuration
import android.content.res.Resources
import android.os.LocaleList
import java.util.Locale
open class MyContextWrapper(base: Context?) : ContextWrapper(base) {
companion object {
/**
* Wraps the context with a new locale.
*
* @param context The original context.
* @param newLocale The new locale to set.
* @return A ContextWrapper with the new locale.
*/
fun wrap(context: Context, newLocale: Locale?): ContextWrapper {
var mContext = context
val res: Resources = mContext.resources
val configuration: Configuration = res.configuration
val locale = newLocale ?: Locale.getDefault()
configuration.setLocale(locale)
val localeList = LocaleList(locale)
LocaleList.setDefault(localeList)
configuration.setLocales(localeList)
mContext = mContext.createConfigurationContext(configuration)
return ContextWrapper(mContext)
}
}
}
@@ -0,0 +1 @@
unqualifiedResLocale=en
@@ -8,6 +8,8 @@
<string name="navigation_drawer_close">إغلاق درج التنقل</string>
<string name="migration_success">نجحت عملية ترحيل البيانات!</string>
<string name="action_stop_service">إيقاف الخدمة</string>
<string name="action_ok">موافق</string>
<string name="action_cancel">إلغاء</string>
<string name="migration_fail">فشلت عملية ترحيل البيانات!</string>
<string name="pull_down_to_refresh">اسحب لأسفل للتحديث!</string>
@@ -8,6 +8,8 @@
<string name="navigation_drawer_close">নেভিগেশন ড্রয়ার বন্ধ করুন</string>
<string name="migration_success">ডেটা স্থানান্তর সফল!</string>
<string name="action_stop_service">সেবা বন্ধ করুন</string>
<string name="action_ok">ঠিক আছে</string>
<string name="action_cancel">বাতিল</string>
<string name="migration_fail">ডেটা স্থানান্তর ব্যর্থ!</string>
<string name="pull_down_to_refresh">রিফ্রেশ করতে নিচে টানুন!</string>
@@ -8,6 +8,8 @@
<string name="navigation_drawer_close">بستن نومگه کشاری</string>
<string name="migration_success">جاگورویی داڌه یل وا مووفقیت ٱنجوم وابی!</string>
<string name="action_stop_service">واڌاشتن سرویس</string>
<string name="action_ok">قوۊل</string>
<string name="action_cancel">لغو</string>
<string name="migration_fail">جاگورویی داڌه یل شکست خرد!</string>
<string name="pull_down_to_refresh">سی وانۊ کردن، بلگه ن بلم بکشین!</string>
@@ -8,6 +8,8 @@
<string name="navigation_drawer_close">بستن منو کشویی</string>
<string name="migration_success">انتقال داده‌ها با موفقیت انجام شد!</string>
<string name="action_stop_service">توقف سرویس</string>
<string name="action_ok">تأیید</string>
<string name="action_cancel">لغو</string>
<string name="migration_fail">انتقال داده‌ها با شکست مواجه شد!</string>
<string name="pull_down_to_refresh">برای بروزرسانی، صفحه را به پایین بکشید!</string>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Night theme: override window background and status bar flags for dark mode -->
<style name="AppThemeBase" parent="@android:style/Theme.Material.NoActionBar">
<style name="AppThemeBase" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
@@ -8,6 +8,8 @@
<string name="navigation_drawer_close">Закрыть панель навигации</string>
<string name="migration_success">Успешный перенос данных!</string>
<string name="action_stop_service">Остановить службу</string>
<string name="action_ok">ОК</string>
<string name="action_cancel">Отмена</string>
<string name="migration_fail">Перенос данных не выполнен!</string>
<string name="pull_down_to_refresh">Потяните вниз для обновления!</string>
@@ -8,6 +8,8 @@
<string name="navigation_drawer_close">Đóng Menu ứng dụng</string>
<string name="migration_success">Đã chuyển dữ liệu!</string>
<string name="action_stop_service">Dừng dịch vụ</string>
<string name="action_ok">OK</string>
<string name="action_cancel">Hủy</string>
<string name="migration_fail">Không thể chuyển dữ liệu!</string>
<string name="pull_down_to_refresh">Kéo xuống để làm mới!</string>
@@ -8,6 +8,8 @@
<string name="navigation_drawer_close">关闭导航栏</string>
<string name="migration_success">数据迁移成功!</string>
<string name="action_stop_service">停止服务</string>
<string name="action_ok">确定</string>
<string name="action_cancel">取消</string>
<string name="migration_fail">数据迁移失败啦!</string>
<string name="pull_down_to_refresh">请下拉刷新!</string>
@@ -8,6 +8,8 @@
<string name="navigation_drawer_close">關閉導覽匣</string>
<string name="migration_success">資料遷移成功!</string>
<string name="action_stop_service">停止服務</string>
<string name="action_ok">確定</string>
<string name="action_cancel">取消</string>
<string name="migration_fail">資料遷移失敗!</string>
<string name="pull_down_to_refresh">請下拉刷新!</string>
@@ -8,6 +8,8 @@
<string name="navigation_drawer_close">Close navigation drawer</string>
<string name="migration_success">Data migration success!</string>
<string name="action_stop_service">Stop service</string>
<string name="action_ok">OK</string>
<string name="action_cancel">Cancel</string>
<string name="migration_fail">Data migration failed!</string>
<string name="pull_down_to_refresh">Please pull down to refresh!</string>
+1 -1
View File
@@ -2,7 +2,7 @@
<color name="window_background_light">#FFFFFFFF</color>
<color name="window_background_dark">#FF1C1B1F</color>
<style name="AppThemeBase" parent="@android:style/Theme.Material.Light.NoActionBar">
<style name="AppThemeBase" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
+2
View File
@@ -8,6 +8,7 @@ junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
activity = "1.13.0"
appcompat = "1.7.1"
mmkvStatic = "1.3.16"
gson = "2.14.0"
okhttp = "5.4.0"
@@ -48,6 +49,7 @@ camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref =
camerax-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }