Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions app/src/main/java/com/x8bit/bitwarden/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,19 +32,38 @@ 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() {
sendEvent(AppearanceEvent.NavigateBack)
}

private fun handleLanguageChanged(action: AppearanceAction.LanguageChange) {
mutableStateFlow.update { it.copy(language = action.language) }
settingsRepository.appLanguage = action.language
}

Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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) }
10 changes: 10 additions & 0 deletions app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,22 @@ 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
every { appLanguage = AppLanguage.ENGLISH } just runs
every { isIconLoadingDisabled } returns false
every { isIconLoadingDisabled = true } just runs
every { appTheme = AppTheme.DARK } just runs
every { appLanguageStateFlow } returns mutableAppLanguageStateFlow
}

@Test
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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("๐Ÿ˜…๐Ÿ˜…๐Ÿ˜…")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ˜…

assertNull(locale.appLanguage)
}
}