Add in-app language selector

This commit is contained in:
世界
2026-02-15 18:26:41 +08:00
parent b083930fa6
commit 19488c7e2e
9 changed files with 181 additions and 3 deletions

View File

@@ -144,6 +144,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
androidResources {
generateLocaleConfig = true
}
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
aidl = true aidl = true

View File

@@ -8,10 +8,10 @@ import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
@@ -127,7 +127,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class MainActivity : class MainActivity :
ComponentActivity(), AppCompatActivity(),
ServiceConnection.Callback { ServiceConnection.Callback {
private val connection = ServiceConnection(this, this) private val connection = ServiceConnection(this, this)
private lateinit var dashboardViewModel: DashboardViewModel private lateinit var dashboardViewModel: DashboardViewModel

View File

@@ -1,11 +1,14 @@
package io.nekohasekai.sfa.compose.screen.settings package io.nekohasekai.sfa.compose.screen.settings
import android.app.LocaleConfig
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -26,6 +29,7 @@ import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material.icons.outlined.Autorenew import androidx.compose.material.icons.outlined.Autorenew
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.NewReleases import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Refresh
@@ -63,6 +67,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -82,6 +87,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
import android.provider.Settings as AndroidSettings import android.provider.Settings as AndroidSettings
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -130,6 +137,13 @@ fun AppSettingsScreen(navController: NavController) {
var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) } var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) }
var showDisableNotificationDialog by remember { mutableStateOf(false) } var showDisableNotificationDialog by remember { mutableStateOf(false) }
var showLanguageDialog by remember { mutableStateOf(false) }
val availableLocales = remember { getSupportedLocales(context) }
var currentLocaleTag by remember {
val appLocales = AppCompatDelegate.getApplicationLocales()
mutableStateOf(if (appLocales.isEmpty) "" else appLocales.toLanguageTags())
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
HookStatusClient.refresh() HookStatusClient.refresh()
} }
@@ -328,6 +342,24 @@ fun AppSettingsScreen(navController: NavController) {
) )
} }
if (showLanguageDialog) {
LanguageDialog(
currentTag = currentLocaleTag,
availableLocales = availableLocales,
onLocaleSelected = { tag ->
currentLocaleTag = tag
val localeList = if (tag.isEmpty()) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(tag)
}
AppCompatDelegate.setApplicationLocales(localeList)
showLanguageDialog = false
},
onDismiss = { showLanguageDialog = false },
)
}
Column( Column(
modifier = modifier =
Modifier Modifier
@@ -375,7 +407,40 @@ fun AppSettingsScreen(navController: NavController) {
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(12.dp)), .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
ListItem(
headlineContent = {
Text(
stringResource(R.string.language),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
val displayName = if (currentLocaleTag.isEmpty()) {
stringResource(R.string.system_default)
} else {
val locale = Locale.forLanguageTag(currentLocaleTag)
locale.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) }
}
Text(displayName, style = MaterialTheme.typography.bodyMedium)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Language,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { showLanguageDialog = true },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -979,6 +1044,104 @@ private fun UpdateTrackDialog(
) )
} }
@Composable
private fun LanguageDialog(
currentTag: String,
availableLocales: List<Locale>,
onLocaleSelected: (String) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.language)) },
text = {
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onLocaleSelected("") }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = currentTag.isEmpty(),
onClick = { onLocaleSelected("") },
)
Text(
text = stringResource(R.string.system_default),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
availableLocales.forEach { locale ->
val tag = locale.toLanguageTag()
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onLocaleSelected(tag) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = currentTag == tag,
onClick = { onLocaleSelected(tag) },
)
Text(
text = locale.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) },
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
private fun getSupportedLocales(context: Context): List<Locale> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val localeConfig = LocaleConfig(context)
val localeList = localeConfig.supportedLocales ?: return emptyList()
return (0 until localeList.size()).map { localeList.get(it) }
}
return parseLocalesConfig(context)
}
private fun parseLocalesConfig(context: Context): List<Locale> {
val locales = mutableListOf<Locale>()
try {
val resId = context.resources.getIdentifier(
"_generated_res_locale_config",
"xml",
context.packageName,
)
if (resId == 0) return emptyList()
val parser = context.resources.getXml(resId)
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") {
val name = parser.getAttributeValue(
"http://schemas.android.com/apk/res/android",
"name",
)
if (name != null) {
locales.add(Locale.forLanguageTag(name))
}
}
}
} catch (_: Exception) {
}
return locales
}
@Composable @Composable
private fun InstallMethodDialog( private fun InstallMethodDialog(
currentMethod: String, currentMethod: String,

View File

@@ -0,0 +1 @@
unqualifiedResLocale=en

View File

@@ -3,6 +3,8 @@
<!-- App --> <!-- App -->
<string name="app_name" translatable="false">sing-box</string> <string name="app_name" translatable="false">sing-box</string>
<string name="app_version_title">نسخه برنامه</string> <string name="app_version_title">نسخه برنامه</string>
<string name="language">زبان</string>
<string name="system_default">پیش‌فرض سیستم</string>
<!-- Common Actions --> <!-- Common Actions -->
<string name="ok">تأیید</string> <string name="ok">تأیید</string>

View File

@@ -3,6 +3,8 @@
<!-- App --> <!-- App -->
<string name="app_name" translatable="false">sing-box</string> <string name="app_name" translatable="false">sing-box</string>
<string name="app_version_title">Версия приложения</string> <string name="app_version_title">Версия приложения</string>
<string name="language">Язык</string>
<string name="system_default">Системный по умолчанию</string>
<!-- Common Actions --> <!-- Common Actions -->
<string name="ok">OK</string> <string name="ok">OK</string>

View File

@@ -3,6 +3,8 @@
<!-- App --> <!-- App -->
<string name="app_name" translatable="false">sing-box</string> <string name="app_name" translatable="false">sing-box</string>
<string name="app_version_title">应用版本</string> <string name="app_version_title">应用版本</string>
<string name="language">语言</string>
<string name="system_default">跟随系统</string>
<!-- Common Actions --> <!-- Common Actions -->
<string name="ok"></string> <string name="ok"></string>

View File

@@ -3,6 +3,8 @@
<!-- App --> <!-- App -->
<string name="app_name" translatable="false">sing-box</string> <string name="app_name" translatable="false">sing-box</string>
<string name="app_version_title">應用程式版本</string> <string name="app_version_title">應用程式版本</string>
<string name="language">語言</string>
<string name="system_default">跟隨系統</string>
<!-- Common Actions --> <!-- Common Actions -->
<string name="ok">確定</string> <string name="ok">確定</string>

View File

@@ -3,6 +3,8 @@
<!-- App --> <!-- App -->
<string name="app_name" translatable="false">sing-box</string> <string name="app_name" translatable="false">sing-box</string>
<string name="app_version_title">App version</string> <string name="app_version_title">App version</string>
<string name="language">Language</string>
<string name="system_default">System default</string>
<!-- Common Actions --> <!-- Common Actions -->
<string name="ok">OK</string> <string name="ok">OK</string>