diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index 98b98466..09819301 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol +import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity @@ -16,11 +17,24 @@ import androidx.compose.ui.Modifier import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.notification.NotificationReplyReceiver import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.util.LocaleContextWrapper class MainActivity : ComponentActivity() { // Observable state so both onCreate and onNewIntent updates flow to Compose private var notificationSessionId by mutableStateOf(null) + /** + * Apply the user-selected display language before any view is inflated. + * Reads the persisted code from [AuthManager]; an uninitialized store + * (shouldn't happen here, but guarded) falls back to the device locale. + */ + override fun attachBaseContext(base: Context) { + val code = + runCatching { AuthManager.getAppLanguage() } + .getOrDefault(LocaleContextWrapper.SYSTEM_LANGUAGE) + super.attachBaseContext(LocaleContextWrapper.wrapWithCode(base, code)) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 5de21fa2..d3383282 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -23,4 +23,7 @@ data class ServerStoreState( val wsAuthParam: String = "token", val typingEffectEnabled: Boolean = true, val typingEffectDelayMs: Int = 30, + // App display language. "system" = follow device locale; otherwise a BCP-47 + // language code such as "en" or "ko". Applied via ContextWrapper in MainActivity. + val appLanguage: String = "system", ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 5cc55022..b21d67eb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -498,6 +498,15 @@ object AuthManager { serverStore.update { it.copy(typingEffectEnabled = enabled) } } + // ── App display language ─────────────────────────────────────────── + // "system" follows the device locale; any other value is a BCP-47 code. + + fun getAppLanguage(): String = serverStore.getLatestState().appLanguage + + fun setAppLanguage(code: String) { + serverStore.update { it.copy(appLanguage = code) } + } + fun getTypingEffectDelayMs(): Int = serverStore.getLatestState().typingEffectDelayMs fun setTypingEffectDelayMs(delayMs: Int) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index db8dd33e..c2eccbb0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -163,6 +163,8 @@ fun SettingsScreen( onUseDynamicColorsChange = viewModel::onUseDynamicColorsChange, themePreset = state.themePreset, onThemePresetChange = viewModel::onThemePresetChange, + appLanguage = state.appLanguage, + onAppLanguageChange = viewModel::onAppLanguageChange, ) ChatSection( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 9d6ed09d..b95cdf28 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -39,6 +39,7 @@ data class SettingsUiState( val selectedProfileId: String? = null, val renameProfileName: String = "", val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT, + val appLanguage: String = "system", // Profile add/edit dialog val showProfileDialog: Boolean = false, val editingProfileId: String? = null, @@ -79,6 +80,7 @@ class SettingsViewModel( val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs() val profiles = AuthManager.getConnectionProfiles() val bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode() + val appLanguage = AuthManager.getAppLanguage() val renameProfileName = profiles.firstOrNull { p -> p.id == selectedId }?.name ?: "" _uiState.update { @@ -97,6 +99,7 @@ class SettingsViewModel( selectedProfileId = selectedId, renameProfileName = renameProfileName, bottomNavDisplayMode = bottomNavDisplayMode, + appLanguage = appLanguage, ) } } @@ -296,6 +299,11 @@ class SettingsViewModel( AuthManager.setThemePreset(preset) } + fun onAppLanguageChange(code: String) { + _uiState.update { it.copy(appLanguage = code, isSaved = false) } + AuthManager.setAppLanguage(code) + } + fun onBottomNavDisplayModeChange(mode: BottomNavDisplayMode) { _uiState.update { it.copy(bottomNavDisplayMode = mode, isSaved = false) } AuthManager.setBottomNavDisplayMode(mode) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index c3c9738d..7925b0e3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.settings.components +import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -43,6 +44,7 @@ import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.ui.settings.SectionCard +import com.m57.hermescontrol.util.LocaleContextWrapper @Composable internal fun AppearanceSection( @@ -52,6 +54,8 @@ internal fun AppearanceSection( onUseDynamicColorsChange: (Boolean) -> Unit, themePreset: ThemePreset, onThemePresetChange: (ThemePreset) -> Unit, + appLanguage: String, + onAppLanguageChange: (String) -> Unit, ) { SectionCard(title = stringResource(R.string.settings_sec_appearance)) { Text( @@ -193,6 +197,42 @@ internal fun AppearanceSection( } } } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.settings_item_language), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + val languageOptions = + listOf( + LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system), + "en" to stringResource(R.string.language_english), + "ko" to stringResource(R.string.language_korean), + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + val activity = LocalActivity.current + languageOptions.forEachIndexed { index, (code, label) -> + SegmentedButton( + selected = appLanguage == code, + onClick = { + onAppLanguageChange(code) + // MainActivity is a plain ComponentActivity (not + // AppCompatActivity), so the locale only takes effect + // after the activity is recreated. + activity?.recreate() + }, + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = languageOptions.size, + ), + ) { + Text(label) + } + } + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt b/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt new file mode 100644 index 00000000..24d7f891 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt @@ -0,0 +1,73 @@ +package com.m57.hermescontrol.util + +import android.content.Context +import android.content.ContextWrapper +import android.os.Build +import java.util.Locale + +/** + * Static helpers for applying a user-selected in-app display language. + * + * Hermes Mobile's [com.m57.hermescontrol.MainActivity] extends the plain + * `ComponentActivity` (not `AppCompatActivity`), so we apply the locale through + * `attachBaseContext` + [ContextWrapper.wrap] rather than relying on + * AppCompatDelegate. Works on minSdk 26. + */ +object LocaleContextWrapper { + const val SYSTEM_LANGUAGE = "system" + + /** + * Resolve a language code ("system", "en", "ko", …) into a [Locale]. + * + * "system" returns the device's default locale (so passing it through + * [wrap] is effectively a no-op). Codes containing a region separator + * ("zh-rCN", "pt-BR") are split into language + country. + */ + fun localeForCode(code: String): Locale = + when { + code.isEmpty() || code == SYSTEM_LANGUAGE -> Locale.getDefault() + code.contains("-r", ignoreCase = true) -> { + val parts = code.split("-r", ignoreCase = true) + Locale(parts[0], parts.getOrElse(1) { "" }) + } + code.contains("-") -> { + val parts = code.split("-") + Locale(parts[0], parts.getOrElse(1) { "" }) + } + code.contains("_") -> { + val parts = code.split("_") + Locale(parts[0], parts.getOrElse(1) { "" }) + } + else -> Locale(code) + } + + /** + * Wrap [base] so the configuration carries [locale]. + * + * On API 33+ we use [Context.createConfigurationContext] which is the only + * reliable path once the deprecated `Configuration.locale` / `setLocale` + * APIs were removed from the public surface. + */ + fun wrap( + base: Context, + locale: Locale, + ): Context { + val config = base.resources.configuration + val newConfig = + android.content.res.Configuration(config).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setLocales(android.os.LocaleList(locale)) + } else { + @Suppress("DEPRECATION") + setLocale(locale) + } + } + return base.createConfigurationContext(newConfig) + } + + /** Convenience: wrap [base] using a stored language code. */ + fun wrapWithCode( + base: Context, + code: String, + ): Context = wrap(base, localeForCode(code)) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d8c1025..6d414fd9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -207,6 +207,12 @@ AMOLED Black (Dark only) Neon Noir + + Language + System default + English + 한국어 + Profile: %1$s Select Saved Profile diff --git a/app/src/test/java/com/m57/hermescontrol/util/LocaleContextWrapperTest.kt b/app/src/test/java/com/m57/hermescontrol/util/LocaleContextWrapperTest.kt new file mode 100644 index 00000000..0c5f75f8 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/util/LocaleContextWrapperTest.kt @@ -0,0 +1,45 @@ +package com.m57.hermescontrol.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Locale + +class LocaleContextWrapperTest { + @Test + fun systemReturnsDeviceDefault() { + assertEquals(Locale.getDefault(), LocaleContextWrapper.localeForCode("system")) + } + + @Test + fun emptyReturnsDeviceDefault() { + assertEquals(Locale.getDefault(), LocaleContextWrapper.localeForCode("")) + } + + @Test + fun bareLanguageCode() { + val locale = LocaleContextWrapper.localeForCode("ko") + assertEquals("ko", locale.language) + assertEquals("", locale.country) + } + + @Test + fun regionSeparator_r() { + val locale = LocaleContextWrapper.localeForCode("zh-rCN") + assertEquals("zh", locale.language) + assertEquals("CN", locale.country) + } + + @Test + fun regionSeparator_hyphen() { + val locale = LocaleContextWrapper.localeForCode("pt-BR") + assertEquals("pt", locale.language) + assertEquals("BR", locale.country) + } + + @Test + fun regionSeparator_underscore() { + val locale = LocaleContextWrapper.localeForCode("en_US") + assertEquals("en", locale.language) + assertEquals("US", locale.country) + } +}