PM-18877 Respect system app specific language selection on Android 13 and up. (#4849)

This commit is contained in:
Dave Severns
2025-03-13 09:14:41 -04:00
committed by GitHub
parent da63c9e36b
commit ca64ce2176
7 changed files with 161 additions and 28 deletions

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
@@ -28,12 +29,14 @@ import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunch
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.util.appLanguage
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* Primary entry point for the application.
*/
@Suppress("TooManyFunctions")
@OmitFromCoverage
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@@ -69,13 +72,9 @@ class MainActivity : AppCompatActivity() {
)
}
// Within the app the language and theme will change dynamically and will be managed by the
// Within the app the theme will change dynamically and will be managed by the
// OS, but we need to ensure we properly set the values when upgrading from older versions
// that handle this differently or when the activity restarts.
settingsRepository.appLanguage.localeName?.let { localeName ->
val localeList = LocaleListCompat.forLanguageTags(localeName)
AppCompatDelegate.setApplicationLocales(localeList)
}
AppCompatDelegate.setDefaultNightMode(settingsRepository.appTheme.osValue)
setContent {
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
@@ -140,6 +139,31 @@ class MainActivity : AppCompatActivity() {
)
}
override fun onResume() {
super.onResume()
// When the app resumes check for any app specific language which may have been
// set via the device settings. Similar to the theme setting in onCreate this
// ensures we properly set the values when upgrading from older versions
// that handle this differently or when the activity restarts.
val appSpecificLanguage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val locales: LocaleListCompat = AppCompatDelegate.getApplicationLocales()
if (locales.isEmpty) {
// App is using the system language
null
} else {
// App has specific language settings
locales.get(0)?.appLanguage
}
} else {
// For older versions, use what ever language is available from the repository.
settingsRepository.appLanguage
}
appSpecificLanguage?.let {
mainViewModel.trySendAction(MainAction.AppSpecificLanguageUpdate(it))
}
}
override fun onStop() {
super.onStop()
// In some scenarios on an emulator the Activity can leak when recreated

View File

@@ -32,6 +32,7 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
@@ -68,7 +69,7 @@ class MainViewModel @Inject constructor(
private val garbageCollectionManager: GarbageCollectionManager,
private val fido2CredentialManager: Fido2CredentialManager,
private val intentManager: IntentManager,
settingsRepository: SettingsRepository,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
@@ -189,9 +190,14 @@ class MainViewModel @Inject constructor(
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
is MainAction.ResumeScreenDataReceived -> handleAppResumeDataUpdated(action)
is MainAction.AppSpecificLanguageUpdate -> handleAppSpecificLanguageUpdate(action)
}
}
private fun handleAppSpecificLanguageUpdate(action: MainAction.AppSpecificLanguageUpdate) {
settingsRepository.appLanguage = action.appLanguage
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
@@ -471,6 +477,12 @@ sealed class MainAction {
*/
data class ResumeScreenDataReceived(val screenResumeData: AppResumeScreenData?) : MainAction()
/**
* Receive if there is an app specific locale selection made by user
* in the device's settings.
*/
data class AppSpecificLanguageUpdate(val appLanguage: AppLanguage) : MainAction()
/**
* Actions for internal use by the ViewModel.
*/

View File

@@ -2,11 +2,15 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@@ -28,11 +32,31 @@ class AppearanceViewModel @Inject constructor(
theme = settingsRepository.appTheme,
),
) {
init {
settingsRepository
.appLanguageStateFlow
.map { AppearanceAction.Internal.AppLanguageStateUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: AppearanceAction): Unit = when (action) {
AppearanceAction.BackClick -> handleBackClicked()
is AppearanceAction.LanguageChange -> handleLanguageChanged(action)
is AppearanceAction.ShowWebsiteIconsToggle -> handleShowWebsiteIconsToggled(action)
is AppearanceAction.ThemeChange -> handleThemeChanged(action)
is AppearanceAction.Internal.AppLanguageStateUpdateReceive -> {
handleLanguageStateChange(action)
}
}
private fun handleLanguageStateChange(
action: AppearanceAction.Internal.AppLanguageStateUpdateReceive,
) {
mutableStateFlow.update {
it.copy(language = action.language)
}
}
private fun handleBackClicked() {
@@ -40,7 +64,6 @@ class AppearanceViewModel @Inject constructor(
}
private fun handleLanguageChanged(action: AppearanceAction.LanguageChange) {
mutableStateFlow.update { it.copy(language = action.language) }
settingsRepository.appLanguage = action.language
}
@@ -108,4 +131,15 @@ sealed class AppearanceAction {
data class ThemeChange(
val theme: AppTheme,
) : AppearanceAction()
/**
* Internal actions not sent through the UI.
*/
sealed class Internal : AppearanceAction() {
/**
* The AppLanguageState value has updated.
*/
data class AppLanguageStateUpdateReceive(val language: AppLanguage) : Internal()
}
}

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.ui.platform.util
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import java.util.Locale
/**
* If returns an associated [AppLanguage] with the [Locale]. If there is
* none that are mapped to the locale's language then the value is null.
*/
val Locale.appLanguage: AppLanguage?
get() = AppLanguage
.entries
.find { it.localeName?.lowercase(this) == this.language.lowercase(this) }

View File

@@ -97,6 +97,7 @@ class MainViewModelTest : BaseViewModelTest() {
every { isScreenCaptureAllowed } returns true
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedFlow
every { storeUserHasLoggedInValue(any()) } just runs
every { appLanguage = any() } just runs
}
private val authRepository = mockk<AuthRepository> {
every { activeUserId } returns DEFAULT_USER_STATE.activeUserId
@@ -1090,6 +1091,15 @@ class MainViewModelTest : BaseViewModelTest() {
verify { appResumeManager.setResumeScreen(AppResumeScreenData.GeneratorScreen) }
}
@Suppress("MaxLineLength")
@Test
fun `on AppSpecificLanguageUpdate, the repository value should be updated with the specified value`() {
val viewModel = createViewModel()
viewModel.trySendAction(MainAction.AppSpecificLanguageUpdate(AppLanguage.SPANISH))
verify { settingsRepository.appLanguage = AppLanguage.SPANISH }
}
private fun createViewModel(
initialSpecialCircumstance: SpecialCircumstance? = null,
) = MainViewModel(

View File

@@ -11,11 +11,14 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class AppearanceViewModelTest : BaseViewModelTest() {
private val mutableAppLanguageStateFlow = MutableStateFlow(AppLanguage.DEFAULT)
private val mockSettingsRepository = mockk<SettingsRepository> {
every { appLanguage } returns AppLanguage.DEFAULT
every { appTheme } returns AppTheme.DEFAULT
@@ -23,6 +26,7 @@ class AppearanceViewModelTest : BaseViewModelTest() {
every { isIconLoadingDisabled } returns false
every { isIconLoadingDisabled = true } just runs
every { appTheme = AppTheme.DARK } just runs
every { appLanguageStateFlow } returns mutableAppLanguageStateFlow
}
@Test
@@ -48,30 +52,32 @@ class AppearanceViewModelTest : BaseViewModelTest() {
}
@Test
fun `on LanguageChange should update state and store language`() = runTest {
val viewModel = createViewModel(
settingsRepository = mockSettingsRepository,
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(AppearanceAction.LanguageChange(AppLanguage.ENGLISH))
assertEquals(
DEFAULT_STATE.copy(
language = AppLanguage.ENGLISH,
),
awaitItem(),
)
}
fun `on LanguageChange should store updated language in repository`() {
val viewModel = createViewModel()
viewModel.trySendAction(AppearanceAction.LanguageChange(AppLanguage.ENGLISH))
verify {
mockSettingsRepository.appLanguage
mockSettingsRepository.appLanguage = AppLanguage.ENGLISH
}
verify { mockSettingsRepository.appLanguage = AppLanguage.ENGLISH }
}
@Test
fun `on AppLanguageStateFlow value updated, view model language state should change`() =
runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
mutableAppLanguageStateFlow.update { AppLanguage.AFRIKAANS }
assertEquals(
DEFAULT_STATE.copy(
language = AppLanguage.AFRIKAANS,
),
awaitItem(),
)
}
}
@Test
fun `on ShowWebsiteIconsToggle should update state and store the value`() = runTest {
val viewModel = createViewModel()

View File

@@ -0,0 +1,34 @@
package com.x8bit.bitwarden.ui.platform.util
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import java.util.Locale
class LocaleExtensionsTest {
@Test
fun `locale with Espanol language returns AppLanguage SPANISH`() {
val locale = Locale("es")
assertEquals(
AppLanguage.SPANISH,
locale.appLanguage,
)
}
@Test
fun `locale with GB english returns AppLanguage ENGLISH_BRITISH`() {
val locale = Locale("en-GB")
assertEquals(
AppLanguage.ENGLISH_BRITISH,
locale.appLanguage,
)
}
@Test
fun `locale with non existent app language returns null`() {
val locale = Locale("😅😅😅")
assertNull(locale.appLanguage)
}
}