Skip to content

Commit ca64ce2

Browse files
PM-18877 Respect system app specific language selection on Android 13 and up. (#4849)
1 parent da63c9e commit ca64ce2

File tree

7 files changed

+161
-28
lines changed

7 files changed

+161
-28
lines changed

Diff for: app/src/main/java/com/x8bit/bitwarden/MainActivity.kt

+29-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.x8bit.bitwarden
22

33
import android.content.Intent
4+
import android.os.Build
45
import android.os.Bundle
56
import android.view.KeyEvent
67
import android.view.MotionEvent
@@ -28,12 +29,14 @@ import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunch
2829
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
2930
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen
3031
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
32+
import com.x8bit.bitwarden.ui.platform.util.appLanguage
3133
import dagger.hilt.android.AndroidEntryPoint
3234
import javax.inject.Inject
3335

3436
/**
3537
* Primary entry point for the application.
3638
*/
39+
@Suppress("TooManyFunctions")
3740
@OmitFromCoverage
3841
@AndroidEntryPoint
3942
class MainActivity : AppCompatActivity() {
@@ -69,13 +72,9 @@ class MainActivity : AppCompatActivity() {
6972
)
7073
}
7174

72-
// Within the app the language and theme will change dynamically and will be managed by the
75+
// Within the app the theme will change dynamically and will be managed by the
7376
// OS, but we need to ensure we properly set the values when upgrading from older versions
7477
// that handle this differently or when the activity restarts.
75-
settingsRepository.appLanguage.localeName?.let { localeName ->
76-
val localeList = LocaleListCompat.forLanguageTags(localeName)
77-
AppCompatDelegate.setApplicationLocales(localeList)
78-
}
7978
AppCompatDelegate.setDefaultNightMode(settingsRepository.appTheme.osValue)
8079
setContent {
8180
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
@@ -140,6 +139,31 @@ class MainActivity : AppCompatActivity() {
140139
)
141140
}
142141

142+
override fun onResume() {
143+
super.onResume()
144+
// When the app resumes check for any app specific language which may have been
145+
// set via the device settings. Similar to the theme setting in onCreate this
146+
// ensures we properly set the values when upgrading from older versions
147+
// that handle this differently or when the activity restarts.
148+
val appSpecificLanguage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
149+
val locales: LocaleListCompat = AppCompatDelegate.getApplicationLocales()
150+
if (locales.isEmpty) {
151+
// App is using the system language
152+
null
153+
} else {
154+
// App has specific language settings
155+
locales.get(0)?.appLanguage
156+
}
157+
} else {
158+
// For older versions, use what ever language is available from the repository.
159+
settingsRepository.appLanguage
160+
}
161+
162+
appSpecificLanguage?.let {
163+
mainViewModel.trySendAction(MainAction.AppSpecificLanguageUpdate(it))
164+
}
165+
}
166+
143167
override fun onStop() {
144168
super.onStop()
145169
// In some scenarios on an emulator the Activity can leak when recreated

Diff for: app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt

+13-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
3232
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
3333
import com.x8bit.bitwarden.ui.platform.base.util.Text
3434
import com.x8bit.bitwarden.ui.platform.base.util.asText
35+
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
3536
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
3637
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
3738
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
@@ -68,7 +69,7 @@ class MainViewModel @Inject constructor(
6869
private val garbageCollectionManager: GarbageCollectionManager,
6970
private val fido2CredentialManager: Fido2CredentialManager,
7071
private val intentManager: IntentManager,
71-
settingsRepository: SettingsRepository,
72+
private val settingsRepository: SettingsRepository,
7273
private val vaultRepository: VaultRepository,
7374
private val authRepository: AuthRepository,
7475
private val environmentRepository: EnvironmentRepository,
@@ -189,9 +190,14 @@ class MainViewModel @Inject constructor(
189190
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
190191
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
191192
is MainAction.ResumeScreenDataReceived -> handleAppResumeDataUpdated(action)
193+
is MainAction.AppSpecificLanguageUpdate -> handleAppSpecificLanguageUpdate(action)
192194
}
193195
}
194196

197+
private fun handleAppSpecificLanguageUpdate(action: MainAction.AppSpecificLanguageUpdate) {
198+
settingsRepository.appLanguage = action.appLanguage
199+
}
200+
195201
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
196202
when (val data = action.screenResumeData) {
197203
null -> appResumeManager.clearResumeScreen()
@@ -471,6 +477,12 @@ sealed class MainAction {
471477
*/
472478
data class ResumeScreenDataReceived(val screenResumeData: AppResumeScreenData?) : MainAction()
473479

480+
/**
481+
* Receive if there is an app specific locale selection made by user
482+
* in the device's settings.
483+
*/
484+
data class AppSpecificLanguageUpdate(val appLanguage: AppLanguage) : MainAction()
485+
474486
/**
475487
* Actions for internal use by the ViewModel.
476488
*/

Diff for: app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt

+35-1
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
22

33
import android.os.Parcelable
44
import androidx.lifecycle.SavedStateHandle
5+
import androidx.lifecycle.viewModelScope
56
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
67
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
78
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
89
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
910
import dagger.hilt.android.lifecycle.HiltViewModel
11+
import kotlinx.coroutines.flow.launchIn
12+
import kotlinx.coroutines.flow.map
13+
import kotlinx.coroutines.flow.onEach
1014
import kotlinx.coroutines.flow.update
1115
import kotlinx.parcelize.Parcelize
1216
import javax.inject.Inject
@@ -28,19 +32,38 @@ class AppearanceViewModel @Inject constructor(
2832
theme = settingsRepository.appTheme,
2933
),
3034
) {
35+
36+
init {
37+
settingsRepository
38+
.appLanguageStateFlow
39+
.map { AppearanceAction.Internal.AppLanguageStateUpdateReceive(it) }
40+
.onEach(::sendAction)
41+
.launchIn(viewModelScope)
42+
}
43+
3144
override fun handleAction(action: AppearanceAction): Unit = when (action) {
3245
AppearanceAction.BackClick -> handleBackClicked()
3346
is AppearanceAction.LanguageChange -> handleLanguageChanged(action)
3447
is AppearanceAction.ShowWebsiteIconsToggle -> handleShowWebsiteIconsToggled(action)
3548
is AppearanceAction.ThemeChange -> handleThemeChanged(action)
49+
is AppearanceAction.Internal.AppLanguageStateUpdateReceive -> {
50+
handleLanguageStateChange(action)
51+
}
52+
}
53+
54+
private fun handleLanguageStateChange(
55+
action: AppearanceAction.Internal.AppLanguageStateUpdateReceive,
56+
) {
57+
mutableStateFlow.update {
58+
it.copy(language = action.language)
59+
}
3660
}
3761

3862
private fun handleBackClicked() {
3963
sendEvent(AppearanceEvent.NavigateBack)
4064
}
4165

4266
private fun handleLanguageChanged(action: AppearanceAction.LanguageChange) {
43-
mutableStateFlow.update { it.copy(language = action.language) }
4467
settingsRepository.appLanguage = action.language
4568
}
4669

@@ -108,4 +131,15 @@ sealed class AppearanceAction {
108131
data class ThemeChange(
109132
val theme: AppTheme,
110133
) : AppearanceAction()
134+
135+
/**
136+
* Internal actions not sent through the UI.
137+
*/
138+
sealed class Internal : AppearanceAction() {
139+
140+
/**
141+
* The AppLanguageState value has updated.
142+
*/
143+
data class AppLanguageStateUpdateReceive(val language: AppLanguage) : Internal()
144+
}
111145
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.x8bit.bitwarden.ui.platform.util
2+
3+
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
4+
import java.util.Locale
5+
6+
/**
7+
* If returns an associated [AppLanguage] with the [Locale]. If there is
8+
* none that are mapped to the locale's language then the value is null.
9+
*/
10+
val Locale.appLanguage: AppLanguage?
11+
get() = AppLanguage
12+
.entries
13+
.find { it.localeName?.lowercase(this) == this.language.lowercase(this) }

Diff for: app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt

+10
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ class MainViewModelTest : BaseViewModelTest() {
9797
every { isScreenCaptureAllowed } returns true
9898
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedFlow
9999
every { storeUserHasLoggedInValue(any()) } just runs
100+
every { appLanguage = any() } just runs
100101
}
101102
private val authRepository = mockk<AuthRepository> {
102103
every { activeUserId } returns DEFAULT_USER_STATE.activeUserId
@@ -1090,6 +1091,15 @@ class MainViewModelTest : BaseViewModelTest() {
10901091
verify { appResumeManager.setResumeScreen(AppResumeScreenData.GeneratorScreen) }
10911092
}
10921093

1094+
@Suppress("MaxLineLength")
1095+
@Test
1096+
fun `on AppSpecificLanguageUpdate, the repository value should be updated with the specified value`() {
1097+
val viewModel = createViewModel()
1098+
viewModel.trySendAction(MainAction.AppSpecificLanguageUpdate(AppLanguage.SPANISH))
1099+
1100+
verify { settingsRepository.appLanguage = AppLanguage.SPANISH }
1101+
}
1102+
10931103
private fun createViewModel(
10941104
initialSpecialCircumstance: SpecialCircumstance? = null,
10951105
) = MainViewModel(

Diff for: app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt

+27-21
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,22 @@ import io.mockk.just
1111
import io.mockk.mockk
1212
import io.mockk.runs
1313
import io.mockk.verify
14+
import kotlinx.coroutines.flow.MutableStateFlow
15+
import kotlinx.coroutines.flow.update
1416
import kotlinx.coroutines.test.runTest
1517
import org.junit.jupiter.api.Assertions.assertEquals
1618
import org.junit.jupiter.api.Test
1719

1820
class AppearanceViewModelTest : BaseViewModelTest() {
21+
private val mutableAppLanguageStateFlow = MutableStateFlow(AppLanguage.DEFAULT)
1922
private val mockSettingsRepository = mockk<SettingsRepository> {
2023
every { appLanguage } returns AppLanguage.DEFAULT
2124
every { appTheme } returns AppTheme.DEFAULT
2225
every { appLanguage = AppLanguage.ENGLISH } just runs
2326
every { isIconLoadingDisabled } returns false
2427
every { isIconLoadingDisabled = true } just runs
2528
every { appTheme = AppTheme.DARK } just runs
29+
every { appLanguageStateFlow } returns mutableAppLanguageStateFlow
2630
}
2731

2832
@Test
@@ -48,30 +52,32 @@ class AppearanceViewModelTest : BaseViewModelTest() {
4852
}
4953

5054
@Test
51-
fun `on LanguageChange should update state and store language`() = runTest {
52-
val viewModel = createViewModel(
53-
settingsRepository = mockSettingsRepository,
54-
)
55-
viewModel.stateFlow.test {
56-
assertEquals(
57-
DEFAULT_STATE,
58-
awaitItem(),
59-
)
60-
viewModel.trySendAction(AppearanceAction.LanguageChange(AppLanguage.ENGLISH))
61-
assertEquals(
62-
DEFAULT_STATE.copy(
63-
language = AppLanguage.ENGLISH,
64-
),
65-
awaitItem(),
66-
)
67-
}
55+
fun `on LanguageChange should store updated language in repository`() {
56+
val viewModel = createViewModel()
57+
viewModel.trySendAction(AppearanceAction.LanguageChange(AppLanguage.ENGLISH))
6858

69-
verify {
70-
mockSettingsRepository.appLanguage
71-
mockSettingsRepository.appLanguage = AppLanguage.ENGLISH
72-
}
59+
verify { mockSettingsRepository.appLanguage = AppLanguage.ENGLISH }
7360
}
7461

62+
@Test
63+
fun `on AppLanguageStateFlow value updated, view model language state should change`() =
64+
runTest {
65+
val viewModel = createViewModel()
66+
viewModel.stateFlow.test {
67+
assertEquals(
68+
DEFAULT_STATE,
69+
awaitItem(),
70+
)
71+
mutableAppLanguageStateFlow.update { AppLanguage.AFRIKAANS }
72+
assertEquals(
73+
DEFAULT_STATE.copy(
74+
language = AppLanguage.AFRIKAANS,
75+
),
76+
awaitItem(),
77+
)
78+
}
79+
}
80+
7581
@Test
7682
fun `on ShowWebsiteIconsToggle should update state and store the value`() = runTest {
7783
val viewModel = createViewModel()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.x8bit.bitwarden.ui.platform.util
2+
3+
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
4+
import org.junit.Test
5+
import org.junit.jupiter.api.Assertions.assertEquals
6+
import org.junit.jupiter.api.Assertions.assertNull
7+
import java.util.Locale
8+
9+
class LocaleExtensionsTest {
10+
11+
@Test
12+
fun `locale with Espanol language returns AppLanguage SPANISH`() {
13+
val locale = Locale("es")
14+
assertEquals(
15+
AppLanguage.SPANISH,
16+
locale.appLanguage,
17+
)
18+
}
19+
20+
@Test
21+
fun `locale with GB english returns AppLanguage ENGLISH_BRITISH`() {
22+
val locale = Locale("en-GB")
23+
assertEquals(
24+
AppLanguage.ENGLISH_BRITISH,
25+
locale.appLanguage,
26+
)
27+
}
28+
29+
@Test
30+
fun `locale with non existent app language returns null`() {
31+
val locale = Locale("😅😅😅")
32+
assertNull(locale.appLanguage)
33+
}
34+
}

0 commit comments

Comments
 (0)