mirror of
https://github.com/bitwarden/android.git
synced 2026-03-21 13:52:07 -05:00
PM-18877 Respect system app specific language selection on Android 13 and up. (#4849)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user