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:
1 parent
f63f649477
commit
739e303fc0
38 files changed
+327
-120
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in new issue
Block a user