BIT-1328: Add support for changing language with picker (#577)

This commit is contained in:
Caleb Derosier 2024-01-12 14:43:18 -07:00 committed by Álison Fernandes
parent f95e5cc3cb
commit 0d171e91b9
74 changed files with 520 additions and 50 deletions

View file

@ -62,6 +62,11 @@ The following is a list of all third-party dependencies included as part of the
- Purpose: Supplementary Android Compose features.
- License: Apache 2.0
- **Appcompat**
- https://developer.android.com/jetpack/androidx/releases/appcompat
- Purpose: Allows access to new APIs on older API versions.
- License: Apache 2.0
- **AndroidX Browser**
- https://developer.android.com/jetpack/androidx/releases/browser
- Purpose: Displays webpages with the user's default browser.

View file

@ -29,6 +29,52 @@ android {
versionCode = 1
versionName = "1.0.0"
// This is so the build system only includes language resources in the APK for these
// languages, preventing translated strings from being included from other libraries that
// might support languages this app does not.
resourceConfigurations += arrayOf(
"af",
"be",
"bg",
"ca",
"cs",
"da",
"de",
"el",
"en",
"en-rGB",
"es",
"et",
"fa",
"fi",
"fr",
"hi",
"hr",
"hu",
"in",
"it",
"iw",
"ja",
"ko",
"lv",
"ml",
"nb",
"nl",
"pl",
"pt-rBR",
"pt-rPT",
"ro",
"ru",
"sk",
"sv",
"th",
"tr",
"uk",
"vi",
"zh-rCN",
"zh-rTW"
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -102,6 +148,7 @@ dependencies {
}
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.browser)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)

View file

@ -16,10 +16,11 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/LaunchTheme"
tools:targetApi="31">
tools:targetApi="33">
<activity
android:name=".MainActivity"
android:exported="true"
@ -52,6 +53,15 @@
<action android:name="android.service.autofill.AutofillService" />
</intent-filter>
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
</manifest>

View file

@ -2,26 +2,40 @@ package com.x8bit.bitwarden
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* Primary entry point for the application.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModels()
@Inject
lateinit var settingsRepository: SettingsRepository
override fun onCreate(savedInstanceState: Bundle?) {
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState)
// Within the app the language will change dynamically and will be managed
// by the OS, but we need to ensure we properly set the language when
// upgrading from older versions that handle this differently.
settingsRepository.appLanguage.localeName?.let { localeName ->
val localeList = LocaleListCompat.forLanguageTags(localeName)
AppCompatDelegate.setApplicationLocales(localeList)
}
setContent {
BitwardenTheme {
RootNavScreen(

View file

@ -1,12 +1,18 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import kotlinx.coroutines.flow.Flow
/**
* Primary access point for general settings-related disk information.
*/
interface SettingsDiskSource {
/**
* The currently persisted app language (or `null` if not set).
*/
var appLanguage: AppLanguage?
/**
* Gets the current vault timeout (in minutes) for the given [userId] (or `null` if the vault
* should never time out).

View file

@ -4,16 +4,19 @@ import android.content.SharedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
private const val APP_LANGUAGE_KEY = "$BASE_KEY:appLocale"
private const val VAULT_TIMEOUT_ACTION_KEY = "$BASE_KEY:vaultTimeoutAction"
private const val VAULT_TIME_IN_MINUTES_KEY = "$BASE_KEY:vaultTimeout"
/**
* Primary implementation of [SettingsDiskSource].
*/
@Suppress("TooManyFunctions")
class SettingsDiskSourceImpl(
val sharedPreferences: SharedPreferences,
) : BaseDiskSource(sharedPreferences = sharedPreferences),
@ -24,6 +27,18 @@ class SettingsDiskSourceImpl(
private val mutableVaultTimeoutInMinutesFlowMap =
mutableMapOf<String, MutableSharedFlow<Int?>>()
override var appLanguage: AppLanguage?
get() = getString(key = APP_LANGUAGE_KEY)
?.let { storedValue ->
AppLanguage.entries.firstOrNull { storedValue == it.localeName }
}
set(value) {
putString(
key = APP_LANGUAGE_KEY,
value = value?.localeName,
)
}
override fun getVaultTimeoutInMinutes(userId: String): Int? =
getInt(key = "${VAULT_TIME_IN_MINUTES_KEY}_$userId")

View file

@ -2,12 +2,18 @@ package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for observing and modifying settings state.
*/
interface SettingsRepository {
/**
* The [AppLanguage] for the current user.
*/
var appLanguage: AppLanguage
/**
* The [VaultTimeout] for the current user.
*/

View file

@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -23,6 +24,12 @@ class SettingsRepositoryImpl(
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
override var appLanguage: AppLanguage
get() = settingsDiskSource.appLanguage ?: AppLanguage.DEFAULT
set(value) {
settingsDiskSource.appLanguage = value
}
override var vaultTimeout: VaultTimeout
get() = activeUserId
?.let {

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -25,12 +26,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
/**
* Displays the appearance screen.
@ -74,7 +79,7 @@ fun AppearanceScreen(
) {
LanguageSelectionRow(
currentSelection = state.language,
onThemeSelection = remember(viewModel) {
onLanguageSelection = remember(viewModel) {
{ viewModel.trySendAction(AppearanceAction.LanguageChange(it)) }
},
modifier = Modifier.fillMaxWidth(),
@ -105,15 +110,22 @@ fun AppearanceScreen(
@Composable
private fun LanguageSelectionRow(
currentSelection: AppearanceState.Language,
onThemeSelection: (AppearanceState.Language) -> Unit,
currentSelection: AppLanguage,
onLanguageSelection: (AppLanguage) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowLanguageSelectionDialog by remember { mutableStateOf(false) }
var languageChangedDialogState: BasicDialogState by rememberSaveable {
mutableStateOf(BasicDialogState.Hidden)
}
var shouldShowLanguageSelectionDialog by rememberSaveable { mutableStateOf(false) }
BitwardenBasicDialog(
visibilityState = languageChangedDialogState,
onDismissRequest = { languageChangedDialogState = BasicDialogState.Hidden },
)
BitwardenTextRow(
text = stringResource(id = R.string.language),
description = stringResource(id = R.string.language_change_requires_app_restart),
onClick = { shouldShowLanguageSelectionDialog = true },
modifier = modifier,
) {
@ -129,14 +141,16 @@ private fun LanguageSelectionRow(
title = stringResource(id = R.string.language),
onDismissRequest = { shouldShowLanguageSelectionDialog = false },
) {
AppearanceState.Language.entries.forEach { option ->
AppLanguage.entries.forEach { option ->
BitwardenSelectionRow(
text = option.text,
isSelected = option == currentSelection,
onClick = {
shouldShowLanguageSelectionDialog = false
onThemeSelection(
AppearanceState.Language.entries.first { it == option },
onLanguageSelection(option)
languageChangedDialogState = BasicDialogState.Shown(
title = R.string.language.asText(),
message = R.string.language_change_x_description.asText(option.text),
)
},
)

View file

@ -1,11 +1,15 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import android.os.Parcelable
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
@ -18,11 +22,12 @@ private const val KEY_STATE = "state"
*/
@HiltViewModel
class AppearanceViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<AppearanceState, AppearanceEvent, AppearanceAction>(
initialState = savedStateHandle[KEY_STATE]
?: AppearanceState(
language = AppearanceState.Language.DEFAULT,
language = settingsRepository.appLanguage,
showWebsiteIcons = false,
theme = AppearanceState.Theme.DEFAULT,
),
@ -39,8 +44,13 @@ class AppearanceViewModel @Inject constructor(
}
private fun handleLanguageChanged(action: AppearanceAction.LanguageChange) {
// TODO: BIT-1328 implement language selection support
mutableStateFlow.update { it.copy(language = action.language) }
settingsRepository.appLanguage = action.language
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(
action.language.localeName,
)
AppCompatDelegate.setApplicationLocales(appLocale)
}
private fun handleShowWebsiteIconsToggled(action: AppearanceAction.ShowWebsiteIconsToggle) {
@ -59,20 +69,10 @@ class AppearanceViewModel @Inject constructor(
*/
@Parcelize
data class AppearanceState(
val language: Language,
val language: AppLanguage,
val showWebsiteIcons: Boolean,
val theme: Theme,
) : Parcelable {
/**
* Represents the languages supported by the app.
*
* TODO BIT-1328 populate values
*/
enum class Language(val text: Text) {
DEFAULT(text = R.string.default_system.asText()),
ENGLISH(text = "English".asText()),
}
/**
* Represents the theme options the user can set.
*/
@ -106,7 +106,7 @@ sealed class AppearanceAction {
* Indicates that the user changed the Language.
*/
data class LanguageChange(
val language: AppearanceState.Language,
val language: AppLanguage,
) : AppearanceAction()
/**

View file

@ -0,0 +1,178 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
/**
* Represents the languages supported by the app.
*/
enum class AppLanguage(
val localeName: String?,
val text: Text,
) {
DEFAULT(
localeName = null,
text = R.string.default_system.asText(),
),
AFRIKAANS(
localeName = "af",
text = "Afrikaans".asText(),
),
BELARUSIAN(
localeName = "be",
text = "Беларуская".asText(),
),
BULGARIAN(
localeName = "bg",
text = "български".asText(),
),
CATALAN(
localeName = "ca",
text = "català".asText(),
),
CZECH(
localeName = "cs",
text = "čeština".asText(),
),
DANISH(
localeName = "da",
text = "Dansk".asText(),
),
GERMAN(
localeName = "de",
text = "Deutsch".asText(),
),
GREEK(
localeName = "el",
text = "Ελληνικά".asText(),
),
ENGLISH(
localeName = "en",
text = "English".asText(),
),
ENGLISH_BRITISH(
localeName = "en-GB",
text = "English (British)".asText(),
),
SPANISH(
localeName = "es",
text = "Español".asText(),
),
ESTONIAN(
localeName = "et",
text = "eesti".asText(),
),
PERSIAN(
localeName = "fa",
text = "فارسی".asText(),
),
FINNISH(
localeName = "fi",
text = "suomi".asText(),
),
FRENCH(
localeName = "fr",
text = "Français".asText(),
),
HINDI(
localeName = "hi",
text = "हिन्दी".asText(),
),
CROATIAN(
localeName = "hr",
text = "hrvatski".asText(),
),
HUNGARIAN(
localeName = "hu",
text = "magyar".asText(),
),
INDONESIAN(
localeName = "in",
text = "Bahasa Indonesia".asText(),
),
ITALIAN(
localeName = "it",
text = "Italiano".asText(),
),
HEBREW(
localeName = "iw",
text = "עברית".asText(),
),
JAPANESE(
localeName = "ja",
text = "日本語".asText(),
),
KOREAN(
localeName = "ko",
text = "한국어".asText(),
),
LATVIAN(
localeName = "lv",
text = "Latvietis".asText(),
),
MALAYALAM(
localeName = "ml",
text = "മലയാളം".asText(),
),
NORWEGIAN(
localeName = "nb",
text = "norsk (bokmål)".asText(),
),
DUTCH(
localeName = "nl",
text = "Nederlands".asText(),
),
POLISH(
localeName = "pl",
text = "Polski".asText(),
),
PORTUGUESE_BRAZILIAN(
localeName = "pt-BR",
text = "Português do Brasil".asText(),
),
PORTUGUESE(
localeName = "pt-PT",
text = "Português".asText(),
),
ROMANIAN(
localeName = "ro",
text = "română".asText(),
),
RUSSIAN(
localeName = "ru",
text = "русский".asText(),
),
SLOVAK(
localeName = "sk",
text = "slovenčina".asText(),
),
SWEDISH(
localeName = "sv",
text = "svenska".asText(),
),
THAI(
localeName = "th",
text = "ไทย".asText(),
),
TURKISH(
localeName = "tr",
text = "Türkçe".asText(),
),
UKRAINIAN(
localeName = "uk",
text = "українська".asText(),
),
VIETNAMESE(
localeName = "vi",
text = "Tiếng Việt".asText(),
),
CHINESE_SIMPLIFIED(
localeName = "zh-CN",
text = "中文(中国大陆)".asText(),
),
CHINESE_TRADITIONAL(
localeName = "zh-TW",
text = "中文(台灣)".asText(),
),
}

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="BaseTheme" parent="android:Theme.Material.Light.DarkActionBar">
<style name="BaseTheme" parent="Theme.Design.Light.NoActionBar">
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:textCursorDrawable">@null</item>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="af"/> <!-- Afrikaans -->
<locale android:name="be"/> <!-- Belarusian -->
<locale android:name="bg"/> <!-- Bulgarian -->
<locale android:name="ca"/> <!-- Catalan -->
<locale android:name="cs"/> <!-- Czech -->
<locale android:name="da"/> <!-- Danish -->
<locale android:name="de"/> <!-- German -->
<locale android:name="el"/> <!-- Greek -->
<locale android:name="en"/> <!-- English (US) -->
<locale android:name="en-GB"/> <!-- English (Great Britain) -->
<locale android:name="es"/> <!-- Spanish -->
<locale android:name="et"/> <!-- Estonian -->
<locale android:name="fa"/> <!-- Persian -->
<locale android:name="fi"/> <!-- Finnish -->
<locale android:name="fr"/> <!-- French -->
<locale android:name="hi"/> <!-- Hindi -->
<locale android:name="hr"/> <!-- Croatian -->
<locale android:name="hu"/> <!-- Hungarian -->
<locale android:name="in"/> <!-- Indonesian -->
<locale android:name="it"/> <!-- Italian -->
<locale android:name="iw"/> <!-- Hebrew -->
<locale android:name="ja"/> <!-- Japanese -->
<locale android:name="ko"/> <!-- Korean -->
<locale android:name="lv"/> <!-- Latvian -->
<locale android:name="ml"/> <!-- Malayalam -->
<locale android:name="nb"/> <!-- Norwegian -->
<locale android:name="nl"/> <!-- Dutch -->
<locale android:name="pl"/> <!-- Polish -->
<locale android:name="pt-BR"/> <!-- Portuguese (Brazilian) -->
<locale android:name="pt-PT"/> <!-- Portuguese (Portugal) -->
<locale android:name="ro"/> <!-- Romanian -->
<locale android:name="ru"/> <!-- Russian -->
<locale android:name="sk"/> <!-- Slovak -->
<locale android:name="sv"/> <!-- Swedish -->
<locale android:name="th"/> <!-- Thai -->
<locale android:name="tr"/> <!-- Turkish -->
<locale android:name="uk"/> <!-- Ukrainian -->
<locale android:name="vi"/> <!-- Vietnamese -->
<locale android:name="zh-CN"/> <!-- Chinese (Simplified) -->
<locale android:name="zh-TW"/> <!-- Chinese (Traditional) -->
</locale-config>

View file

@ -4,6 +4,7 @@ import androidx.core.content.edit
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@ -18,6 +19,45 @@ class SettingsDiskSourceTest {
sharedPreferences = fakeSharedPreferences,
)
@Test
fun `appLanguage should pull from SharedPreferences`() {
val appLanguageKey = "bwPreferencesStorage:appLocale"
val expected = AppLanguage.AFRIKAANS
// Verify initial value is null and disk source matches shared preferences.
assertNull(fakeSharedPreferences.getString(appLanguageKey, null))
assertNull(settingsDiskSource.appLanguage)
// Updating the shared preferences should update disk source.
fakeSharedPreferences
.edit()
.putString(
appLanguageKey,
expected.localeName,
)
.apply()
val actual = settingsDiskSource.appLanguage
assertEquals(
expected,
actual,
)
}
@Test
fun `setting appLanguage should update SharedPreferences`() {
val appLanguageKey = "bwPreferencesStorage:appLocale"
val appLanguage = AppLanguage.ENGLISH
settingsDiskSource.appLanguage = appLanguage
val actual = fakeSharedPreferences.getString(
appLanguageKey,
AppLanguage.DEFAULT.localeName,
)
assertEquals(
appLanguage.localeName,
actual,
)
}
@Test
fun `getVaultTimeoutInMinutes when values are present should pull from SharedPreferences`() {
val vaultTimeoutBaseKey = "bwPreferencesStorage:vaultTimeout"
@ -44,7 +84,8 @@ class SettingsDiskSourceTest {
}
@Test
fun `getVaultTimeoutInMinutesFlow should react to changes in getOrganizations`() = runTest {
fun `getVaultTimeoutInMinutesFlow should react to changes in getVaultTimeoutInMinutes`() =
runTest {
val mockUserId = "mockUserId"
val vaultTimeoutInMinutes = 360
settingsDiskSource.getVaultTimeoutInMinutesFlow(userId = mockUserId).test {
@ -123,7 +164,7 @@ class SettingsDiskSourceTest {
}
@Test
fun `getVaultTimeoutActionFlow should react to changes in getOrganizations`() = runTest {
fun `getVaultTimeoutActionFlow should react to changes in getVaultTimeoutAction`() = runTest {
val mockUserId = "mockUserId"
val vaultTimeoutAction = VaultTimeoutAction.LOCK
settingsDiskSource.getVaultTimeoutActionFlow(userId = mockUserId).test {

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.datasource.disk.util
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
@ -21,6 +22,8 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val storedVaultTimeoutActions = mutableMapOf<String, VaultTimeoutAction?>()
private val storedVaultTimeoutInMinutes = mutableMapOf<String, Int?>()
override var appLanguage: AppLanguage? = null
override fun getVaultTimeoutInMinutes(userId: String): Int? =
storedVaultTimeoutInMinutes[userId]

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@ -87,6 +88,28 @@ class SettingsRepositoryTest {
)
}
@Test
fun `appLanguage should pull from and update SettingsDiskSource`() {
assertEquals(
AppLanguage.DEFAULT,
settingsRepository.appLanguage,
)
// Updates to the disk source change the repository value.
fakeSettingsDiskSource.appLanguage = AppLanguage.ENGLISH
assertEquals(
AppLanguage.ENGLISH,
settingsRepository.appLanguage,
)
// Updates to the repository value change the disk source.
settingsRepository.appLanguage = AppLanguage.DUTCH
assertEquals(
AppLanguage.DUTCH,
fakeSettingsDiskSource.appLanguage,
)
}
@Test
fun `vaultTimeout should pull from and update SettingsDiskSource for the current user`() {
every { authDiskSource.userState?.activeUserId } returns null

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
@ -10,6 +11,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import io.mockk.every
import io.mockk.mockk
@ -55,10 +57,26 @@ class AppearanceScreenTest : BaseComposeTest() {
}
@Test
fun `on language selection dialog item click should send LanguageChange`() {
fun `on language selection dialog item click should send LanguageChange and show dialog`() {
// Clicking the Language row shows the language selection dialog
composeTestRule.onNodeWithText("Language").performClick()
// Selecting a language dismisses this dialog and displays the confirmation
composeTestRule
.onAllNodesWithText("English")
.onAllNodesWithText("Afrikaans")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText("Afrikaans")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsNotDisplayed()
// Should show confirmation dialog
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
// Clicking "Ok" should dismiss confirmation dialog
composeTestRule.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
@ -66,7 +84,7 @@ class AppearanceScreenTest : BaseComposeTest() {
verify {
viewModel.trySendAction(
AppearanceAction.LanguageChange(
language = AppearanceState.Language.ENGLISH,
language = AppLanguage.AFRIKAANS,
),
)
}
@ -133,7 +151,7 @@ class AppearanceScreenTest : BaseComposeTest() {
}
private val DEFAULT_STATE = AppearanceState(
language = AppearanceState.Language.DEFAULT,
language = AppLanguage.DEFAULT,
showWebsiteIcons = false,
theme = AppearanceState.Theme.DEFAULT,
)

View file

@ -1,13 +1,40 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AppearanceViewModelTest : BaseViewModelTest() {
private val mockSettingsRepository = mockk<SettingsRepository> {
every { appLanguage } returns AppLanguage.DEFAULT
every { appLanguage = AppLanguage.ENGLISH } just runs
}
@BeforeEach
fun setup() {
mockkStatic(AppCompatDelegate::setApplicationLocales)
}
@AfterEach
fun teardown() {
unmockkStatic(AppCompatDelegate::setApplicationLocales)
}
@Test
fun `initial state should be correct when not set`() {
val viewModel = createViewModel(state = null)
@ -31,21 +58,30 @@ class AppearanceViewModelTest : BaseViewModelTest() {
}
@Test
fun `on LanguageChange should update state`() = runTest {
val viewModel = createViewModel()
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(AppearanceState.Language.ENGLISH),
AppearanceAction.LanguageChange(AppLanguage.ENGLISH),
)
assertEquals(
DEFAULT_STATE.copy(language = AppearanceState.Language.ENGLISH),
DEFAULT_STATE.copy(
language = AppLanguage.ENGLISH,
),
awaitItem(),
)
}
verify {
AppCompatDelegate.setApplicationLocales(any())
mockSettingsRepository.appLanguage
mockSettingsRepository.appLanguage = AppLanguage.ENGLISH
}
}
@Test
@ -82,15 +118,17 @@ class AppearanceViewModelTest : BaseViewModelTest() {
private fun createViewModel(
state: AppearanceState? = null,
settingsRepository: SettingsRepository = mockSettingsRepository,
) = AppearanceViewModel(
savedStateHandle = SavedStateHandle().apply {
set("state", state)
},
settingsRepository = settingsRepository,
)
companion object {
private val DEFAULT_STATE = AppearanceState(
language = AppearanceState.Language.DEFAULT,
language = AppLanguage.DEFAULT,
showWebsiteIcons = false,
theme = AppearanceState.Theme.DEFAULT,
)

View file

@ -23,6 +23,7 @@ androidxNavigation = "2.7.6"
androidxRoom = "2.6.1"
androidXSecurityCrypto = "1.1.0-alpha06"
androidxSplash = "1.1.0-alpha02"
androidXAppCompat = "1.6.1"
# Once the app and SDK reach a critical point of completeness we should begin fixing the version
# here (BIT-311).
bitwardenSdk = "0.4.0-20240111.141006-33"
@ -53,6 +54,7 @@ zxing = "3.5.2"
[libraries]
# Format: <maintainer>-<artifact-name>
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidXAppCompat" }
androidx-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidxCamera" }