From e604942beba24f659cbfd1ace84ca19dbc8b5bb7 Mon Sep 17 00:00:00 2001 From: Phil Oliver <3497406+poliver@users.noreply.github.com> Date: Fri, 19 Sep 2025 08:16:45 -0400 Subject: [PATCH] Launch system language picker for API 33+ (#3145) --- .../java/com/geeksville/mesh/MainActivity.kt | 5 -- .../geeksville/mesh/android/prefs/UiPrefs.kt | 3 - .../mesh/ui/settings/SettingsScreen.kt | 33 +++++++-- .../com/geeksville/mesh/util/LanguageUtils.kt | 68 +++++++++---------- 4 files changed, 61 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index f648ebe6e..73f8a0a41 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -48,7 +48,6 @@ import com.geeksville.mesh.ui.common.theme.AppTheme import com.geeksville.mesh.ui.common.theme.MODE_DYNAMIC import com.geeksville.mesh.ui.intro.AppIntroductionScreen import com.geeksville.mesh.ui.sharing.toSharedContact -import com.geeksville.mesh.util.LanguageUtils import dagger.hilt.android.AndroidEntryPoint import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI import javax.inject.Inject @@ -78,10 +77,6 @@ class MainActivity : super.onCreate(savedInstanceState) if (savedInstanceState == null) { - val lang = uiPrefs.lang - if (lang != LanguageUtils.SYSTEM_MANAGED) LanguageUtils.migrateLanguagePrefs(uiPrefs) - info("in-app language is ${LanguageUtils.getLocale()}") - if (uiPrefs.appIntroCompleted) { (application as GeeksvilleApplication).askToRate(this) } diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt index 122afb5f2..a79941c29 100644 --- a/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt +++ b/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt @@ -21,7 +21,6 @@ import android.content.SharedPreferences import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.edit import com.geeksville.mesh.model.NodeSortOption -import com.geeksville.mesh.util.LanguageUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -29,7 +28,6 @@ import kotlinx.coroutines.flow.update import java.util.concurrent.ConcurrentHashMap interface UiPrefs { - var lang: String var theme: Int val themeFlow: StateFlow var appIntroCompleted: Boolean @@ -84,7 +82,6 @@ class UiPrefsImpl(private val prefs: SharedPreferences) : UiPrefs { prefs.registerOnSharedPreferenceChangeListener(sharedPreferencesListener) } - override var lang: String by PrefDelegate(prefs, "lang", LanguageUtils.SYSTEM_DEFAULT) override var hasShownNotPairedWarning: Boolean by PrefDelegate(prefs, "has_shown_not_paired_warning", false) override var nodeSortOption: Int by PrefDelegate(prefs, "node-sort-option", NodeSortOption.VIA_FAVORITE.ordinal) override var includeUnknown: Boolean by PrefDelegate(prefs, "include-unknown", false) diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt index 77f047e8b..1e6180f7d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt @@ -20,6 +20,8 @@ package com.geeksville.mesh.ui.settings import android.Manifest import android.app.Activity import android.content.Intent +import android.os.Build +import android.provider.Settings.ACTION_APP_LOCALE_SETTINGS import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -30,6 +32,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.rounded.FormatPaint import androidx.compose.material.icons.rounded.Language @@ -49,6 +52,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.BuildConfig @@ -69,6 +73,7 @@ import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel import com.geeksville.mesh.ui.settings.radio.components.EditDeviceProfileDialog import com.geeksville.mesh.ui.settings.radio.components.PacketResponseStateDialog import com.geeksville.mesh.util.LanguageUtils +import com.geeksville.mesh.util.LanguageUtils.getLanguageMap import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import kotlinx.coroutines.delay @@ -271,12 +276,27 @@ fun SettingsScreen( settingsViewModel.setProvideLocation(!provideLocation) } + val settingsLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {} + // On Android 12 and below, system app settings for language are not available. Use the in-app language + // picker for these devices. + val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU SettingsItem( text = stringResource(R.string.preferences_language), leadingIcon = Icons.Rounded.Language, - trailingIcon = null, + trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight, ) { - showLanguagePickerDialog = true + if (useInAppLangPicker) { + showLanguagePickerDialog = true + } else { + val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri()) + if (intent.resolveActivity(context.packageManager) != null) { + settingsLauncher.launch(intent) + } else { + // Fall back to the in-app picker + showLanguagePickerDialog = true + } + } } SettingsItem( @@ -383,14 +403,17 @@ private fun AppVersionButton(excludedModulesUnlocked: Boolean, onUnlockExcludedM @Composable private fun LanguagePickerDialog(onDismiss: () -> Unit) { val context = LocalContext.current - val languages = remember { - LanguageUtils.getLanguageTags(context).mapValues { (_, value) -> { LanguageUtils.setLocale(value) } } + val choices = remember { + context + .getLanguageMap() + .map { (languageTag, languageName) -> languageName to { LanguageUtils.setAppLocale(languageTag) } } + .toMap() } MultipleChoiceAlertDialog( title = stringResource(R.string.preferences_language), message = "", - choices = languages, + choices = choices, onDismissRequest = onDismiss, ) } diff --git a/app/src/main/java/com/geeksville/mesh/util/LanguageUtils.kt b/app/src/main/java/com/geeksville/mesh/util/LanguageUtils.kt index bdd9ddb7d..206ea67ec 100644 --- a/app/src/main/java/com/geeksville/mesh/util/LanguageUtils.kt +++ b/app/src/main/java/com/geeksville/mesh/util/LanguageUtils.kt @@ -22,61 +22,59 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging -import com.geeksville.mesh.android.prefs.UiPrefs import org.xmlpull.v1.XmlPullParser import java.util.Locale object LanguageUtils : Logging { const val SYSTEM_DEFAULT = "zz" - const val SYSTEM_MANAGED = "appcompat" - fun getLocale(): String = AppCompatDelegate.getApplicationLocales().toLanguageTags().ifEmpty { SYSTEM_DEFAULT } - - fun setLocale(lang: String) { + fun setAppLocale(languageTag: String) { AppCompatDelegate.setApplicationLocales( - if (lang == SYSTEM_DEFAULT) { + if (languageTag == SYSTEM_DEFAULT) { LocaleListCompat.getEmptyLocaleList() } else { - LocaleListCompat.forLanguageTags(lang) + LocaleListCompat.forLanguageTags(languageTag) }, ) } - fun migrateLanguagePrefs(uiPrefs: UiPrefs) { - val currentLang = uiPrefs.lang - debug("Migrating in-app language prefs: $currentLang") - uiPrefs.lang = SYSTEM_MANAGED - setLocale(currentLang) - } + /** Using locales_config.xml, maps language tags to their localized language names (e.g.: "en" -> "English") */ + @Suppress("CyclomaticComplexMethod") + fun Context.getLanguageMap(): Map { + val languageTags = buildList { + add(SYSTEM_DEFAULT) - /** Build a list from locales_config.xml of native language names paired to its Locale tag (ex: "English", "en") */ - fun getLanguageTags(context: Context): Map { - val languageTags = mutableListOf(SYSTEM_DEFAULT) - try { - context.resources.getXml(R.xml.locales_config).use { - while (it.eventType != XmlPullParser.END_DOCUMENT) { - if (it.eventType == XmlPullParser.START_TAG && it.name == "locale") { - it.getAttributeValue(0)?.let { tag -> languageTags += tag } + try { + resources.getXml(R.xml.locales_config).use { parser -> + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") { + val languageTag = + parser.getAttributeValue("http://schemas.android.com/apk/res/android", "name") + languageTag?.let { add(it) } + } + parser.next() } - it.next() } + } catch (e: Exception) { + errormsg("Error parsing locale_config.xml: ${e.message}") } - } catch (e: Exception) { - errormsg("Error parsing locale_config.xml ${e.message}") } - return languageTags.associateBy { tag -> - val loc = Locale.forLanguageTag(tag) - when (tag) { - SYSTEM_DEFAULT -> context.getString(R.string.preferences_system_default) - "fr-HT" -> context.getString(R.string.fr_HT) - "pt-BR" -> context.getString(R.string.pt_BR) - "zh-CN" -> context.getString(R.string.zh_CN) - "zh-TW" -> context.getString(R.string.zh_TW) - else -> - loc.getDisplayLanguage(loc).replaceFirstChar { - if (it.isLowerCase()) it.titlecase(loc) else it.toString() + + return languageTags.associateWith { languageTag -> + when (languageTag) { + SYSTEM_DEFAULT -> getString(R.string.preferences_system_default) + "fr-HT" -> getString(R.string.fr_HT) + "pt-BR" -> getString(R.string.pt_BR) + "zh-CN" -> getString(R.string.zh_CN) + "zh-TW" -> getString(R.string.zh_TW) + else -> { + Locale.forLanguageTag(languageTag).let { locale -> + locale.getDisplayLanguage(locale).replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase(locale) else char.toString() + } } + } } } }