diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index 5130f9f73e0..6061cdc9100 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index 5c47d229b37..5fa612506d4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt index 08e538eb705..3f7e3859168 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt @@ -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() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/LocaleExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/LocaleExtensions.kt new file mode 100644 index 00000000000..c4165782b8c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/LocaleExtensions.kt @@ -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) } diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index e496a0e6bed..88a296c2019 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -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 { 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( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt index f65c1107480..1291eb414e7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt @@ -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 { 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() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/LocaleExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/LocaleExtensionsTest.kt new file mode 100644 index 00000000000..d69f22bb4f2 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/LocaleExtensionsTest.kt @@ -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) + } +}