From df31b39cc8c1fbf00bc3301468e8e7e4b283caf2 Mon Sep 17 00:00:00 2001 From: Ushie Date: Wed, 7 Jan 2026 22:54:48 +0300 Subject: [PATCH] feat: Add language settings (#2913) --- .github/workflows/pull_strings.yml | 43 +++++++++ .github/workflows/push_strings.yml | 26 +++++ app/build.gradle.kts | 44 ++++++++- .../java/app/revanced/manager/MainActivity.kt | 4 +- .../screen/settings/GeneralSettingsScreen.kt | 94 +++++++++++++++++++ .../ui/viewmodel/GeneralSettingsViewModel.kt | 9 ++ .../revanced/manager/util/SupportedLocales.kt | 32 +++++++ app/src/main/res/values/strings.xml | 5 +- crowdin.yml | 8 ++ 9 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/pull_strings.yml create mode 100644 .github/workflows/push_strings.yml create mode 100644 app/src/main/java/app/revanced/manager/util/SupportedLocales.kt create mode 100644 crowdin.yml diff --git a/.github/workflows/pull_strings.yml b/.github/workflows/pull_strings.yml new file mode 100644 index 00000000..2ef91045 --- /dev/null +++ b/.github/workflows/pull_strings.yml @@ -0,0 +1,43 @@ +name: Pull strings + +on: + schedule: + - cron: "0 0 * * 0" + workflow_dispatch: + +jobs: + pull: + name: Pull strings + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: dev + clean: true + + - name: Pull strings + uses: crowdin/github-action@v2 + with: + config: crowdin.yml + upload_sources: false + download_translations: true + skip_ref_checkout: true + localization_branch_name: feat/translations + create_pull_request: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + - name: Open pull request + if: github.event_name == 'workflow_dispatch' + uses: repo-sync/pull-request@v2 + with: + source_branch: feat/translations + destination_branch: dev + pr_title: "chore: Sync translations" + pr_body: "Sync translations from [crowdin.com/project/revanced](https://crowdin.com/project/revanced)" diff --git a/.github/workflows/push_strings.yml b/.github/workflows/push_strings.yml new file mode 100644 index 00000000..263aa8ea --- /dev/null +++ b/.github/workflows/push_strings.yml @@ -0,0 +1,26 @@ +name: Push strings + +on: + workflow_dispatch: + push: + branches: + - dev + paths: + - app/src/main/res/values/strings.xml + +jobs: + push: + name: Push strings + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Push strings + uses: crowdin/github-action@v2 + with: + config: crowdin.yml + upload_sources: true + env: + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index de9dc96b..f6f60c03 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -149,7 +149,6 @@ android { debug { applicationIdSuffix = ".debug" resValue("string", "app_name", "ReVanced Manager (Debug)") - isPseudoLocalesEnabled = true buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L") } @@ -243,6 +242,8 @@ android { version = "3.22.1" } } + + sourceSets["main"].kotlin.srcDir(layout.buildDirectory.dir("generated/source/locales")) } kotlin { @@ -250,6 +251,47 @@ kotlin { } tasks { + val generateSupportedLocales by registering { + description = "Generate list of supported locales from resource directories" + + val resDir = file("src/main/res") + val outputDir = layout.buildDirectory.dir("generated/source/locales") + + inputs.dir(resDir) + outputs.dir(outputDir) + + doLast { + val locales = resDir.listFiles() + .orEmpty() + .filter { it.isDirectory && it.name.matches(Regex("values-[a-z]{2}(-r[A-Z]{2})?")) } + .map { it.name.removePrefix("values-").replace("-r", "-") } + .sorted() + .joinToString(",\n ") { "Locale.forLanguageTag(\"$it\")" } + + val output = outputDir.get().asFile.resolve("app/revanced/manager/util/GeneratedLocales.kt") + output.parentFile.mkdirs() + output.writeText( + """ + |package app.revanced.manager.util + | + |import java.util.Locale + | + |object GeneratedLocales { + | val SUPPORTED_LOCALES = listOf( + | Locale.ENGLISH, + | $locales, + | ) + |} + """.trimMargin() + ) + } + } + + preBuild { + dependsOn(generateSupportedLocales) + } + + // Needed by gradle-semantic-release-plugin. // Tracking: https://github.com/KengoTODA/gradle-semantic-release-plugin/issues/435. val publish by registering { diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 7b6dbd2a..8cd65806 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -3,11 +3,11 @@ package app.revanced.manager import android.content.ActivityNotFoundException import android.os.Bundle import android.os.Parcelable -import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally @@ -63,7 +63,7 @@ import org.koin.androidx.compose.navigation.koinNavViewModel import org.koin.core.parameter.parametersOf import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel -class MainActivity : ComponentActivity() { +class MainActivity : AppCompatActivity() { @ExperimentalAnimationApi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt index 8e0b7baf..f0a0bb15 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton @@ -19,6 +21,7 @@ import androidx.compose.material3.rememberTopAppBarState 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.Alignment @@ -38,6 +41,7 @@ import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.viewmodel.GeneralSettingsViewModel import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -48,6 +52,7 @@ fun GeneralSettingsScreen( val prefs = viewModel.prefs val coroutineScope = viewModel.viewModelScope var showThemePicker by rememberSaveable { mutableStateOf(false) } + var showLanguagePicker by rememberSaveable { mutableStateOf(false) } if (showThemePicker) { ThemePicker( @@ -55,6 +60,17 @@ fun GeneralSettingsScreen( onConfirm = { viewModel.setTheme(it) } ) } + + if (showLanguagePicker) { + LanguagePicker( + supportedLocales = viewModel.getSupportedLocales(), + currentLocale = viewModel.getCurrentLocale(), + onDismiss = { showLanguagePicker = false }, + onConfirm = { viewModel.setLocale(it) }, + getDisplayName = { viewModel.getLocaleDisplayName(it) } + ) + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) Scaffold( @@ -74,6 +90,24 @@ fun GeneralSettingsScreen( ) { GroupHeader(stringResource(R.string.appearance)) + val currentLocale = viewModel.getCurrentLocale() + val currentLanguageDisplay = remember(currentLocale) { + currentLocale?.let { viewModel.getLocaleDisplayName(it) } + } + SettingsListItem( + modifier = Modifier.clickable { showLanguagePicker = true }, + headlineContent = stringResource(R.string.language), + supportingContent = stringResource(R.string.language_description), + trailingContent = { + FilledTonalButton(onClick = { showLanguagePicker = true }) { + Text( + currentLanguageDisplay + ?: stringResource(R.string.language_system_default) + ) + } + } + ) + val theme by prefs.theme.getAsState() SettingsListItem( modifier = Modifier.clickable { showThemePicker = true }, @@ -156,4 +190,64 @@ private fun ThemePicker( } } ) +} + +@Composable +private fun LanguagePicker( + supportedLocales: List, + currentLocale: Locale?, + onDismiss: () -> Unit, + onConfirm: (Locale?) -> Unit, + getDisplayName: (Locale) -> String +) { + var selectedLocale by remember { mutableStateOf(currentLocale) } + val systemDefaultString = stringResource(R.string.language_system_default) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.language)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { selectedLocale = null }, + verticalAlignment = Alignment.CenterVertically + ) { + HapticRadioButton( + selected = selectedLocale == null, + onClick = { selectedLocale = null } + ) + Text(systemDefaultString) + } + + supportedLocales.forEach { locale -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { selectedLocale = locale }, + verticalAlignment = Alignment.CenterVertically + ) { + HapticRadioButton( + selected = selectedLocale == locale, + onClick = { selectedLocale = locale } + ) + Text(getDisplayName(locale)) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirm(selectedLocale) + onDismiss() + } + ) { + Text(stringResource(R.string.apply)) + } + } + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/GeneralSettingsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/GeneralSettingsViewModel.kt index 31036184..65af7654 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/GeneralSettingsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/GeneralSettingsViewModel.kt @@ -1,17 +1,26 @@ package app.revanced.manager.ui.viewmodel +import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.ui.theme.Theme +import app.revanced.manager.util.SupportedLocales import app.revanced.manager.util.resetListItemColorsCached import kotlinx.coroutines.launch +import java.util.Locale class GeneralSettingsViewModel( + private val app: Application, val prefs: PreferencesManager ) : ViewModel() { fun setTheme(theme: Theme) = viewModelScope.launch { prefs.theme.update(theme) resetListItemColorsCached() } + + fun getSupportedLocales() = SupportedLocales.getSupportedLocales(app) + fun getCurrentLocale() = SupportedLocales.getCurrentLocale() + fun setLocale(locale: Locale?) = SupportedLocales.setLocale(locale) + fun getLocaleDisplayName(locale: Locale) = SupportedLocales.getDisplayName(locale) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/SupportedLocales.kt b/app/src/main/java/app/revanced/manager/util/SupportedLocales.kt new file mode 100644 index 00000000..f4feeba1 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/SupportedLocales.kt @@ -0,0 +1,32 @@ +package app.revanced.manager.util + +import android.content.Context +import android.os.Build +import android.os.LocaleList +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import java.util.Locale + +object SupportedLocales { + fun getSupportedLocales(context: Context): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + runCatching { + android.app.LocaleConfig(context).supportedLocales?.toList() + }.getOrNull() ?: GeneratedLocales.SUPPORTED_LOCALES + } else { + GeneratedLocales.SUPPORTED_LOCALES + } + } + + fun getCurrentLocale(): Locale? = + AppCompatDelegate.getApplicationLocales().takeIf { !it.isEmpty }?.get(0) + + fun setLocale(locale: Locale?) = AppCompatDelegate.setApplicationLocales( + locale?.let { LocaleListCompat.create(it) } ?: LocaleListCompat.getEmptyLocaleList() + ) + + fun getDisplayName(locale: Locale) = + locale.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) } + + private fun LocaleList.toList() = (0 until size()).map { get(it) } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eeb52799..52b7aa01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,7 +83,7 @@ Second \"item\" text" These settings can be changed later. General - Theme, dynamic color + Language, theme, dynamic color Updates Check for updates and view changelogs Downloads @@ -104,6 +104,9 @@ Second \"item\" text" Use pure black backgrounds for dark theme Theme Choose between light or dark theme + Language + Choose the app display language + System default Safeguards Disable version compatibility check Do not restrict patches to compatible app versions diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 00000000..2f5f6e1c --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,8 @@ +project_id_env: "CROWDIN_PROJECT_ID" +api_token_env: "CROWDIN_PERSONAL_TOKEN" + +preserve_hierarchy: false +files: + - source: app/src/main/res/values/strings.xml + translation: app/src/main/res/values-%android_code%/strings.xml + skip_untranslated_strings: true