From c9b79c30163ab9a55b66f1f9b0b972efbf416c84 Mon Sep 17 00:00:00 2001 From: Axel Yinjia Huang Date: Fri, 27 Jun 2025 11:28:21 +0600 Subject: [PATCH] manager: add basic language switching functionality --- .../com/rifsxd/ksunext/ui/MainActivity.kt | 5 + .../rifsxd/ksunext/ui/screen/Customization.kt | 169 +++++++++++++++++- .../rifsxd/ksunext/ui/util/LocaleHelper.kt | 149 +++++++++++++++ manager/app/src/main/res/values/strings.xml | 1 + 4 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 manager/app/src/main/java/com/rifsxd/ksunext/ui/util/LocaleHelper.kt diff --git a/manager/app/src/main/java/com/rifsxd/ksunext/ui/MainActivity.kt b/manager/app/src/main/java/com/rifsxd/ksunext/ui/MainActivity.kt index f54029b3..f8e68724 100644 --- a/manager/app/src/main/java/com/rifsxd/ksunext/ui/MainActivity.kt +++ b/manager/app/src/main/java/com/rifsxd/ksunext/ui/MainActivity.kt @@ -58,6 +58,7 @@ import com.rifsxd.ksunext.ui.screen.BottomBarDestination import com.rifsxd.ksunext.ui.theme.KernelSUTheme import com.rifsxd.ksunext.ui.util.* import com.rifsxd.ksunext.ui.util.LocalSnackbarHost +import com.rifsxd.ksunext.ui.util.LocaleHelper import com.rifsxd.ksunext.ui.util.rootAvailable import com.rifsxd.ksunext.ui.util.install import com.rifsxd.ksunext.ui.util.isSuCompatDisabled @@ -67,6 +68,10 @@ import com.rifsxd.ksunext.ui.webui.initPlatform class MainActivity : ComponentActivity() { + override fun attachBaseContext(newBase: Context?) { + super.attachBaseContext(newBase?.let { LocaleHelper.applyLanguage(it) }) + } + override fun onCreate(savedInstanceState: Bundle?) { // Enable edge to edge diff --git a/manager/app/src/main/java/com/rifsxd/ksunext/ui/screen/Customization.kt b/manager/app/src/main/java/com/rifsxd/ksunext/ui/screen/Customization.kt index 3c0be7c2..54af3c90 100644 --- a/manager/app/src/main/java/com/rifsxd/ksunext/ui/screen/Customization.kt +++ b/manager/app/src/main/java/com/rifsxd/ksunext/ui/screen/Customization.kt @@ -3,6 +3,7 @@ package com.rifsxd.ksunext.ui.screen import android.content.Context import android.content.Intent import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -17,6 +18,7 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text @@ -28,7 +30,9 @@ import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -39,6 +43,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.dropUnlessResumed +import com.maxkeppeker.sheets.core.models.base.Header +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import com.maxkeppeler.sheets.list.ListDialog +import com.maxkeppeler.sheets.list.models.ListOption +import com.maxkeppeler.sheets.list.models.ListSelection import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -46,10 +55,12 @@ import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import com.rifsxd.ksunext.Natives import com.rifsxd.ksunext.ksuApp import com.rifsxd.ksunext.R +import com.rifsxd.ksunext.ui.component.rememberCustomDialog import com.rifsxd.ksunext.ui.component.SwitchItem +import com.rifsxd.ksunext.ui.util.LocaleHelper import com.rifsxd.ksunext.ui.util.LocalSnackbarHost import com.rifsxd.ksunext.ui.util.* - +import java.util.Locale /** * @author rifsxd @@ -90,6 +101,162 @@ fun CustomizationScreen(navigator: DestinationsNavigator) { val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + // Track language state with current app locale + var currentAppLocale by remember { mutableStateOf(LocaleHelper.getCurrentAppLocale(context)) } + + // Listen for preference changes + LaunchedEffect(Unit) { + currentAppLocale = LocaleHelper.getCurrentAppLocale(context) + } + + // Language setting with selection dialog + val languageDialog = rememberCustomDialog { dismiss -> + // Check if should use system language settings + if (LocaleHelper.useSystemLanguageSettings) { + // Android 13+ - Jump to system settings + LocaleHelper.launchSystemLanguageSettings(context) + dismiss() + } else { + // Android < 13 - Show app language selector + // Dynamically detect supported locales from resources + val supportedLocales = remember { + val locales = mutableListOf() + + // Add system default first + locales.add(java.util.Locale.ROOT) // This will represent "System Default" + + // Dynamically detect available locales by checking resource directories + val resourceDirs = listOf( + "ar", "bg", "de", "fa", "fr", "hu", "in", "it", + "ja", "ko", "pl", "pt-rBR", "ru", "th", "tr", + "uk", "vi", "zh-rCN", "zh-rTW" + ) + + resourceDirs.forEach { dir -> + try { + val locale = when { + dir.contains("-r") -> { + val parts = dir.split("-r") + java.util.Locale.Builder() + .setLanguage(parts[0]) + .setRegion(parts[1]) + .build() + } + else -> java.util.Locale.Builder() + .setLanguage(dir) + .build() + } + + // Test if this locale has translated resources + val config = android.content.res.Configuration() + config.setLocale(locale) + val localizedContext = context.createConfigurationContext(config) + + // Try to get a translated string to verify the locale is supported + val testString = localizedContext.getString(R.string.settings_language) + val defaultString = context.getString(R.string.settings_language) + + // If the string is different or it's English, it's supported + if (testString != defaultString || locale.language == "en") { + locales.add(locale) + } + } catch (e: Exception) { + // Skip unsupported locales + } + } + + // Sort by display name + val sortedLocales = locales.drop(1).sortedBy { it.getDisplayName(it) } + mutableListOf().apply { + add(locales.first()) // System default first + addAll(sortedLocales) + } + } + + val allOptions = supportedLocales.map { locale -> + val tag = if (locale == java.util.Locale.ROOT) { + "system" + } else if (locale.country.isEmpty()) { + locale.language + } else { + "${locale.language}_${locale.country}" + } + + val displayName = if (locale == java.util.Locale.ROOT) { + context.getString(R.string.system_default) + } else { + locale.getDisplayName(locale) + } + + tag to displayName + } + + val currentLocale = prefs.getString("app_locale", "system") ?: "system" + val options = allOptions.map { (tag, displayName) -> + ListOption( + titleText = displayName, + selected = currentLocale == tag + ) + } + + var selectedIndex by remember { + mutableIntStateOf(allOptions.indexOfFirst { (tag, _) -> currentLocale == tag }) + } + + ListDialog( + state = rememberUseCaseState( + visible = true, + onFinishedRequest = { + if (selectedIndex >= 0 && selectedIndex < allOptions.size) { + val newLocale = allOptions[selectedIndex].first + prefs.edit().putString("app_locale", newLocale).apply() + + // Update local state immediately + currentAppLocale = LocaleHelper.getCurrentAppLocale(context) + + // Apply locale change immediately for Android < 13 + LocaleHelper.restartActivity(context) + } + dismiss() + }, + onCloseRequest = { + dismiss() + } + ), + header = Header.Default( + title = stringResource(R.string.settings_language), + ), + selection = ListSelection.Single( + showRadioButtons = true, + options = options + ) { index, _ -> + selectedIndex = index + } + ) + } + } + + val language = stringResource(id = R.string.settings_language) + + // Compute display name based on current app locale (similar to the reference implementation) + val currentLanguageDisplay = remember(currentAppLocale) { + val locale = currentAppLocale + if (locale != null) { + locale.getDisplayName(locale) + } else { + context.getString(R.string.system_default) + } + } + + ListItem( + leadingContent = { Icon(Icons.Filled.Translate, language) }, + headlineContent = { Text(language) }, + supportingContent = { Text(currentLanguageDisplay) }, + modifier = Modifier.clickable { + languageDialog.show() + } + ) + var useBanner by rememberSaveable { mutableStateOf( prefs.getBoolean("use_banner", true) diff --git a/manager/app/src/main/java/com/rifsxd/ksunext/ui/util/LocaleHelper.kt b/manager/app/src/main/java/com/rifsxd/ksunext/ui/util/LocaleHelper.kt new file mode 100644 index 00000000..5869afba --- /dev/null +++ b/manager/app/src/main/java/com/rifsxd/ksunext/ui/util/LocaleHelper.kt @@ -0,0 +1,149 @@ +package com.rifsxd.ksunext.ui.util + +import android.annotation.TargetApi +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.Build +import android.provider.Settings +import java.util.Locale + +object LocaleHelper { + + /** + * Check if should use system language settings (Android 13+) + */ + val useSystemLanguageSettings: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + /** + * Launch system app locale settings (Android 13+) + */ + fun launchSystemLanguageSettings(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + try { + val intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } catch (e: Exception) { + // Fallback to app language settings if system settings not available + } + } + } + + /** + * Apply saved language setting to context (for Android < 13) + */ + fun applyLanguage(context: Context): Context { + // On Android 13+, language is handled by system + if (useSystemLanguageSettings) { + return context + } + + val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + val localeTag = prefs.getString("app_locale", "system") ?: "system" + + return if (localeTag == "system") { + context + } else { + val locale = parseLocaleTag(localeTag) + setLocale(context, locale) + } + } + + /** + * Set locale for context (Android < 13) + */ + private fun setLocale(context: Context, locale: Locale): Context { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + updateResources(context, locale) + } else { + updateResourcesLegacy(context, locale) + } + } + + @TargetApi(Build.VERSION_CODES.N) + private fun updateResources(context: Context, locale: Locale): Context { + val configuration = Configuration() + configuration.setLocale(locale) + configuration.setLayoutDirection(locale) + return context.createConfigurationContext(configuration) + } + + @SuppressWarnings("deprecation") + private fun updateResourcesLegacy(context: Context, locale: Locale): Context { + Locale.setDefault(locale) + val resources = context.resources + val configuration = resources.configuration + configuration.locale = locale + configuration.setLayoutDirection(locale) + resources.updateConfiguration(configuration, resources.displayMetrics) + return context + } + + /** + * Parse locale tag to Locale object + */ + private fun parseLocaleTag(tag: String): Locale { + return try { + if (tag.contains("_")) { + val parts = tag.split("_") + Locale.Builder() + .setLanguage(parts[0]) + .setRegion(parts.getOrNull(1) ?: "") + .build() + } else { + Locale.Builder() + .setLanguage(tag) + .build() + } + } catch (e: Exception) { + Locale.getDefault() + } + } + + /** + * Restart activity to apply language change (Android < 13) + */ + fun restartActivity(context: Context) { + if (context is Activity && !useSystemLanguageSettings) { + context.recreate() + } + } + + /** + * Get current app locale + */ + fun getCurrentAppLocale(context: Context): Locale? { + return if (useSystemLanguageSettings) { + // Android 13+ - get from system app locale settings + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + try { + val localeManager = context.getSystemService(Context.LOCALE_SERVICE) as? android.app.LocaleManager + val locales = localeManager?.applicationLocales + if (locales != null && !locales.isEmpty) { + locales.get(0) + } else { + null // System default + } + } catch (e: Exception) { + null // System default + } + } else { + null // System default + } + } else { + // Android < 13 - get from SharedPreferences + val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + val localeTag = prefs.getString("app_locale", "system") ?: "system" + if (localeTag == "system") { + null // System default + } else { + parseLocaleTag(localeTag) + } + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index a7550ce6..cdaf95e6 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -211,6 +211,7 @@ Disable su compatibility Temporarily disable the ability of any app to gain root privileges via the ⁠su command (existing root processes won\'t be affected). Language + System default Use Legacy UI Switch to the previous user interface style. Enable banners