From 739e303fc08537a356d431eb55ae8bd809d66f24 Mon Sep 17 00:00:00 2001 From: eliotcougar Date: Sun, 9 Aug 2026 16:09:41 +0300 Subject: [PATCH] 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. --- V2rayNG/app/build.gradle.kts | 16 ++ V2rayNG/app/src/main/AndroidManifest.xml | 9 ++ .../main/java/com/v2ray/ang/AngApplication.kt | 6 +- .../src/main/java/com/v2ray/ang/AppConfig.kt | 1 + .../main/java/com/v2ray/ang/enums/Language.kt | 58 ++++++-- .../com/v2ray/ang/handler/AppLocaleManager.kt | 140 ++++++++++++++++++ .../com/v2ray/ang/handler/SettingsManager.kt | 25 ---- .../v2ray/ang/service/CoreProxyOnlyService.kt | 6 +- .../com/v2ray/ang/service/CoreRootService.kt | 6 +- .../com/v2ray/ang/service/CoreTestService.kt | 8 +- .../com/v2ray/ang/service/CoreVpnService.kt | 6 +- .../com/v2ray/ang/service/QSTileService.kt | 5 + .../ang/service/SubscriptionUpdateService.kt | 5 + .../java/com/v2ray/ang/ui/AboutActivity.kt | 2 +- .../ang/ui/apppicker/AppPickerViewModel.kt | 22 ++- .../com/v2ray/ang/ui/backup/BackupActivity.kt | 2 +- .../ang/ui/base/BaseComponentActivity.kt | 13 +- .../com/v2ray/ang/ui/base/BaseViewModel.kt | 17 ++- .../ang/ui/checkupdate/CheckUpdateActivity.kt | 2 +- .../java/com/v2ray/ang/ui/compose/Dialog.kt | 6 +- .../com/v2ray/ang/ui/compose/SettingsItem.kt | 4 +- .../com/v2ray/ang/ui/main/MainRepository.kt | 11 +- .../v2ray/ang/ui/settings/SettingsActivity.kt | 14 +- .../ang/ui/subscription/SubSettingActivity.kt | 4 +- .../com/v2ray/ang/util/MyContextWrapper.kt | 34 ----- V2rayNG/app/src/main/res/resources.properties | 1 + .../app/src/main/res/values-ar/strings.xml | 2 + .../app/src/main/res/values-bn/strings.xml | 2 + .../src/main/res/values-bqi-rIR/strings.xml | 2 + .../app/src/main/res/values-fa/strings.xml | 2 + .../app/src/main/res/values-night/themes.xml | 2 +- .../app/src/main/res/values-ru/strings.xml | 2 + .../app/src/main/res/values-vi/strings.xml | 2 + .../src/main/res/values-zh-rCN/strings.xml | 2 + .../src/main/res/values-zh-rTW/strings.xml | 2 + V2rayNG/app/src/main/res/values/strings.xml | 2 + V2rayNG/app/src/main/res/values/themes.xml | 2 +- V2rayNG/gradle/libs.versions.toml | 2 + 38 files changed, 327 insertions(+), 120 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/handler/AppLocaleManager.kt delete mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/util/MyContextWrapper.kt create mode 100644 V2rayNG/app/src/main/res/resources.properties diff --git a/V2rayNG/app/build.gradle.kts b/V2rayNG/app/build.gradle.kts index eeb3016e..8e80b0cc 100644 --- a/V2rayNG/app/build.gradle.kts +++ b/V2rayNG/app/build.gradle.kts @@ -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)) diff --git a/V2rayNG/app/src/main/AndroidManifest.xml b/V2rayNG/app/src/main/AndroidManifest.xml index 7fbb2513..0238a586 100644 --- a/V2rayNG/app/src/main/AndroidManifest.xml +++ b/V2rayNG/app/src/main/AndroidManifest.xml @@ -52,6 +52,15 @@ android:theme="@style/AppThemeDayNight.NoActionBar" android:usesCleartextTraffic="true"> + + + + 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 + } + } } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AppLocaleManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AppLocaleManager.kt new file mode 100644 index 00000000..0edb99c0 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AppLocaleManager.kt @@ -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()) + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt index c09d0573..f4cd34a6 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt @@ -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 diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreProxyOnlyService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreProxyOnlyService.kt index 44de0720..7e564019 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreProxyOnlyService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreProxyOnlyService.kt @@ -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) } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt index ab59915a..d5265fb8 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt @@ -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) } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt index ddcd8117..1ef04d8a 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt @@ -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()) 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() } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt index 59653d2a..9d0d8461 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt @@ -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) } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt index 70bfdcfe..fb26101a 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt @@ -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. diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt index 5263f13c..88604bf2 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt @@ -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) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt index b698c292..7e11965a 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt @@ -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, diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerViewModel.kt index 9df05f0e..3d6c624d 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerViewModel.kt @@ -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 diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupActivity.kt index c2e6dbb8..1466c28c 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupActivity.kt @@ -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( diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/base/BaseComponentActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/base/BaseComponentActivity.kt index fc0325ee..12e8c507 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/base/BaseComponentActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/base/BaseComponentActivity.kt @@ -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 { diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/base/BaseViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/base/BaseViewModel.kt index d4c9952a..254564ef 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/base/BaseViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/base/BaseViewModel.kt @@ -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 = _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 } } } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/checkupdate/CheckUpdateActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/checkupdate/CheckUpdateActivity.kt index e6f8dcec..43f1f8e3 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/checkupdate/CheckUpdateActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/checkupdate/CheckUpdateActivity.kt @@ -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 diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Dialog.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Dialog.kt index 5d3bd085..d389817f 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Dialog.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/Dialog.kt @@ -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 SelectListDialog( confirmButton = {}, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) + Text(stringResource(R.string.action_cancel)) } }, containerColor = MaterialTheme.colorScheme.surface diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/SettingsItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/SettingsItem.kt index 71945d4b..cb77d283 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/SettingsItem.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/compose/SettingsItem.kt @@ -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 } ) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainRepository.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainRepository.kt index 11359a2d..d1e0e0fb 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainRepository.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/main/MainRepository.kt @@ -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( @@ -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 { val result = mutableListOf() @@ -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) } ) } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/settings/SettingsActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/settings/SettingsActivity.kt index de3ed0a9..04b120a5 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/settings/SettingsActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/settings/SettingsActivity.kt @@ -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), diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubSettingActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubSettingActivity.kt index c217b379..82d9065d 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubSettingActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubSettingActivity.kt @@ -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)) } } ) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/MyContextWrapper.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MyContextWrapper.kt deleted file mode 100644 index 34352f05..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/util/MyContextWrapper.kt +++ /dev/null @@ -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) - } - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/resources.properties b/V2rayNG/app/src/main/res/resources.properties new file mode 100644 index 00000000..92481bb0 --- /dev/null +++ b/V2rayNG/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en diff --git a/V2rayNG/app/src/main/res/values-ar/strings.xml b/V2rayNG/app/src/main/res/values-ar/strings.xml index 65f6ab61..94f55057 100644 --- a/V2rayNG/app/src/main/res/values-ar/strings.xml +++ b/V2rayNG/app/src/main/res/values-ar/strings.xml @@ -8,6 +8,8 @@ إغلاق درج التنقل نجحت عملية ترحيل البيانات! إيقاف الخدمة + موافق + إلغاء فشلت عملية ترحيل البيانات! اسحب لأسفل للتحديث! diff --git a/V2rayNG/app/src/main/res/values-bn/strings.xml b/V2rayNG/app/src/main/res/values-bn/strings.xml index 052e6aac..dcefb789 100644 --- a/V2rayNG/app/src/main/res/values-bn/strings.xml +++ b/V2rayNG/app/src/main/res/values-bn/strings.xml @@ -8,6 +8,8 @@ নেভিগেশন ড্রয়ার বন্ধ করুন ডেটা স্থানান্তর সফল! সেবা বন্ধ করুন + ঠিক আছে + বাতিল ডেটা স্থানান্তর ব্যর্থ! রিফ্রেশ করতে নিচে টানুন! diff --git a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml index 2b9bcb20..af83fbad 100644 --- a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml +++ b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml @@ -8,6 +8,8 @@ بستن نومگه کشاری جاگورویی داڌه یل وا مووفقیت ٱنجوم وابی! واڌاشتن سرویس + قوۊل + لغو جاگورویی داڌه یل شکست خرد! سی وانۊ کردن، بلگه ن بلم بکشین! diff --git a/V2rayNG/app/src/main/res/values-fa/strings.xml b/V2rayNG/app/src/main/res/values-fa/strings.xml index 9944a53c..6bef4307 100644 --- a/V2rayNG/app/src/main/res/values-fa/strings.xml +++ b/V2rayNG/app/src/main/res/values-fa/strings.xml @@ -8,6 +8,8 @@ بستن منو کشویی انتقال داده‌ها با موفقیت انجام شد! توقف سرویس + تأیید + لغو انتقال داده‌ها با شکست مواجه شد! برای بروزرسانی، صفحه را به پایین بکشید! diff --git a/V2rayNG/app/src/main/res/values-night/themes.xml b/V2rayNG/app/src/main/res/values-night/themes.xml index 5fe5bbee..672c89ce 100644 --- a/V2rayNG/app/src/main/res/values-night/themes.xml +++ b/V2rayNG/app/src/main/res/values-night/themes.xml @@ -1,7 +1,7 @@ -