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. - Purpose: Supplementary Android Compose features.
- License: Apache 2.0 - 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** - **AndroidX Browser**
- https://developer.android.com/jetpack/androidx/releases/browser - https://developer.android.com/jetpack/androidx/releases/browser
- Purpose: Displays webpages with the user's default browser. - Purpose: Displays webpages with the user's default browser.

View file

@ -29,6 +29,52 @@ android {
versionCode = 1 versionCode = 1
versionName = "1.0.0" 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" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@ -102,6 +148,7 @@ dependencies {
} }
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.lifecycle)

View file

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

View file

@ -2,26 +2,40 @@ package com.x8bit.bitwarden
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels 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 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.feature.rootnav.RootNavScreen
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/** /**
* Primary entry point for the application. * Primary entry point for the application.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModels() private val mainViewModel: MainViewModel by viewModels()
@Inject
lateinit var settingsRepository: SettingsRepository
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
var shouldShowSplashScreen = true var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen } installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState) 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 { setContent {
BitwardenTheme { BitwardenTheme {
RootNavScreen( RootNavScreen(

View file

@ -1,12 +1,18 @@
package com.x8bit.bitwarden.data.platform.datasource.disk package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction 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 import kotlinx.coroutines.flow.Flow
/** /**
* Primary access point for general settings-related disk information. * Primary access point for general settings-related disk information.
*/ */
interface SettingsDiskSource { 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 * Gets the current vault timeout (in minutes) for the given [userId] (or `null` if the vault
* should never time out). * 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.datasource.disk.BaseDiskSource.Companion.BASE_KEY
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow 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.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription 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_TIMEOUT_ACTION_KEY = "$BASE_KEY:vaultTimeoutAction"
private const val VAULT_TIME_IN_MINUTES_KEY = "$BASE_KEY:vaultTimeout" private const val VAULT_TIME_IN_MINUTES_KEY = "$BASE_KEY:vaultTimeout"
/** /**
* Primary implementation of [SettingsDiskSource]. * Primary implementation of [SettingsDiskSource].
*/ */
@Suppress("TooManyFunctions")
class SettingsDiskSourceImpl( class SettingsDiskSourceImpl(
val sharedPreferences: SharedPreferences, val sharedPreferences: SharedPreferences,
) : BaseDiskSource(sharedPreferences = sharedPreferences), ) : BaseDiskSource(sharedPreferences = sharedPreferences),
@ -24,6 +27,18 @@ class SettingsDiskSourceImpl(
private val mutableVaultTimeoutInMinutesFlowMap = private val mutableVaultTimeoutInMinutesFlowMap =
mutableMapOf<String, MutableSharedFlow<Int?>>() 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? = override fun getVaultTimeoutInMinutes(userId: String): Int? =
getInt(key = "${VAULT_TIME_IN_MINUTES_KEY}_$userId") 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.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction 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 import kotlinx.coroutines.flow.StateFlow
/** /**
* Provides an API for observing and modifying settings state. * Provides an API for observing and modifying settings state.
*/ */
interface SettingsRepository { interface SettingsRepository {
/**
* The [AppLanguage] for the current user.
*/
var appLanguage: AppLanguage
/** /**
* The [VaultTimeout] for the current user. * 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.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction 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.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -23,6 +24,12 @@ class SettingsRepositoryImpl(
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
override var appLanguage: AppLanguage
get() = settingsDiskSource.appLanguage ?: AppLanguage.DEFAULT
set(value) {
settingsDiskSource.appLanguage = value
}
override var vaultTimeout: VaultTimeout override var vaultTimeout: VaultTimeout
get() = activeUserId get() = activeUserId
?.let { ?.let {

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -25,12 +26,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect 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.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
/** /**
* Displays the appearance screen. * Displays the appearance screen.
@ -74,7 +79,7 @@ fun AppearanceScreen(
) { ) {
LanguageSelectionRow( LanguageSelectionRow(
currentSelection = state.language, currentSelection = state.language,
onThemeSelection = remember(viewModel) { onLanguageSelection = remember(viewModel) {
{ viewModel.trySendAction(AppearanceAction.LanguageChange(it)) } { viewModel.trySendAction(AppearanceAction.LanguageChange(it)) }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -105,15 +110,22 @@ fun AppearanceScreen(
@Composable @Composable
private fun LanguageSelectionRow( private fun LanguageSelectionRow(
currentSelection: AppearanceState.Language, currentSelection: AppLanguage,
onThemeSelection: (AppearanceState.Language) -> Unit, onLanguageSelection: (AppLanguage) -> Unit,
modifier: Modifier = Modifier, 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( BitwardenTextRow(
text = stringResource(id = R.string.language), text = stringResource(id = R.string.language),
description = stringResource(id = R.string.language_change_requires_app_restart),
onClick = { shouldShowLanguageSelectionDialog = true }, onClick = { shouldShowLanguageSelectionDialog = true },
modifier = modifier, modifier = modifier,
) { ) {
@ -129,14 +141,16 @@ private fun LanguageSelectionRow(
title = stringResource(id = R.string.language), title = stringResource(id = R.string.language),
onDismissRequest = { shouldShowLanguageSelectionDialog = false }, onDismissRequest = { shouldShowLanguageSelectionDialog = false },
) { ) {
AppearanceState.Language.entries.forEach { option -> AppLanguage.entries.forEach { option ->
BitwardenSelectionRow( BitwardenSelectionRow(
text = option.text, text = option.text,
isSelected = option == currentSelection, isSelected = option == currentSelection,
onClick = { onClick = {
shouldShowLanguageSelectionDialog = false shouldShowLanguageSelectionDialog = false
onThemeSelection( onLanguageSelection(option)
AppearanceState.Language.entries.first { it == 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 package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import android.os.Parcelable import android.os.Parcelable
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.R 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.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -18,11 +22,12 @@ private const val KEY_STATE = "state"
*/ */
@HiltViewModel @HiltViewModel
class AppearanceViewModel @Inject constructor( class AppearanceViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : BaseViewModel<AppearanceState, AppearanceEvent, AppearanceAction>( ) : BaseViewModel<AppearanceState, AppearanceEvent, AppearanceAction>(
initialState = savedStateHandle[KEY_STATE] initialState = savedStateHandle[KEY_STATE]
?: AppearanceState( ?: AppearanceState(
language = AppearanceState.Language.DEFAULT, language = settingsRepository.appLanguage,
showWebsiteIcons = false, showWebsiteIcons = false,
theme = AppearanceState.Theme.DEFAULT, theme = AppearanceState.Theme.DEFAULT,
), ),
@ -39,8 +44,13 @@ class AppearanceViewModel @Inject constructor(
} }
private fun handleLanguageChanged(action: AppearanceAction.LanguageChange) { private fun handleLanguageChanged(action: AppearanceAction.LanguageChange) {
// TODO: BIT-1328 implement language selection support
mutableStateFlow.update { it.copy(language = action.language) } 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) { private fun handleShowWebsiteIconsToggled(action: AppearanceAction.ShowWebsiteIconsToggle) {
@ -59,20 +69,10 @@ class AppearanceViewModel @Inject constructor(
*/ */
@Parcelize @Parcelize
data class AppearanceState( data class AppearanceState(
val language: Language, val language: AppLanguage,
val showWebsiteIcons: Boolean, val showWebsiteIcons: Boolean,
val theme: Theme, val theme: Theme,
) : Parcelable { ) : 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. * Represents the theme options the user can set.
*/ */
@ -106,7 +106,7 @@ sealed class AppearanceAction {
* Indicates that the user changed the Language. * Indicates that the user changed the Language.
*/ */
data class LanguageChange( data class LanguageChange(
val language: AppearanceState.Language, val language: AppLanguage,
) : AppearanceAction() ) : 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:textCursorDrawable">@null</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 app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction 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 kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
@ -18,6 +19,45 @@ class SettingsDiskSourceTest {
sharedPreferences = fakeSharedPreferences, 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 @Test
fun `getVaultTimeoutInMinutes when values are present should pull from SharedPreferences`() { fun `getVaultTimeoutInMinutes when values are present should pull from SharedPreferences`() {
val vaultTimeoutBaseKey = "bwPreferencesStorage:vaultTimeout" val vaultTimeoutBaseKey = "bwPreferencesStorage:vaultTimeout"
@ -44,7 +84,8 @@ class SettingsDiskSourceTest {
} }
@Test @Test
fun `getVaultTimeoutInMinutesFlow should react to changes in getOrganizations`() = runTest { fun `getVaultTimeoutInMinutesFlow should react to changes in getVaultTimeoutInMinutes`() =
runTest {
val mockUserId = "mockUserId" val mockUserId = "mockUserId"
val vaultTimeoutInMinutes = 360 val vaultTimeoutInMinutes = 360
settingsDiskSource.getVaultTimeoutInMinutesFlow(userId = mockUserId).test { settingsDiskSource.getVaultTimeoutInMinutesFlow(userId = mockUserId).test {
@ -123,7 +164,7 @@ class SettingsDiskSourceTest {
} }
@Test @Test
fun `getVaultTimeoutActionFlow should react to changes in getOrganizations`() = runTest { fun `getVaultTimeoutActionFlow should react to changes in getVaultTimeoutAction`() = runTest {
val mockUserId = "mockUserId" val mockUserId = "mockUserId"
val vaultTimeoutAction = VaultTimeoutAction.LOCK val vaultTimeoutAction = VaultTimeoutAction.LOCK
settingsDiskSource.getVaultTimeoutActionFlow(userId = mockUserId).test { 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.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow 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.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.flow.onSubscription
@ -21,6 +22,8 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val storedVaultTimeoutActions = mutableMapOf<String, VaultTimeoutAction?>() private val storedVaultTimeoutActions = mutableMapOf<String, VaultTimeoutAction?>()
private val storedVaultTimeoutInMinutes = mutableMapOf<String, Int?>() private val storedVaultTimeoutInMinutes = mutableMapOf<String, Int?>()
override var appLanguage: AppLanguage? = null
override fun getVaultTimeoutInMinutes(userId: String): Int? = override fun getVaultTimeoutInMinutes(userId: String): Int? =
storedVaultTimeoutInMinutes[userId] 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.datasource.disk.util.FakeSettingsDiskSource
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction 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.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest 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 @Test
fun `vaultTimeout should pull from and update SettingsDiskSource for the current user`() { fun `vaultTimeout should pull from and update SettingsDiskSource for the current user`() {
every { authDiskSource.userState?.activeUserId } returns null every { authDiskSource.userState?.activeUserId } returns null

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.appearance package com.x8bit.bitwarden.ui.platform.feature.settings.appearance
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDialog
@ -10,6 +11,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest 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 com.x8bit.bitwarden.ui.util.assertNoDialogExists
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -55,10 +57,26 @@ class AppearanceScreenTest : BaseComposeTest() {
} }
@Test @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() composeTestRule.onNodeWithText("Language").performClick()
// Selecting a language dismisses this dialog and displays the confirmation
composeTestRule 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())) .filterToOne(hasAnyAncestor(isDialog()))
.performClick() .performClick()
composeTestRule.assertNoDialogExists() composeTestRule.assertNoDialogExists()
@ -66,7 +84,7 @@ class AppearanceScreenTest : BaseComposeTest() {
verify { verify {
viewModel.trySendAction( viewModel.trySendAction(
AppearanceAction.LanguageChange( AppearanceAction.LanguageChange(
language = AppearanceState.Language.ENGLISH, language = AppLanguage.AFRIKAANS,
), ),
) )
} }
@ -133,7 +151,7 @@ class AppearanceScreenTest : BaseComposeTest() {
} }
private val DEFAULT_STATE = AppearanceState( private val DEFAULT_STATE = AppearanceState(
language = AppearanceState.Language.DEFAULT, language = AppLanguage.DEFAULT,
showWebsiteIcons = false, showWebsiteIcons = false,
theme = AppearanceState.Theme.DEFAULT, theme = AppearanceState.Theme.DEFAULT,
) )

View file

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

View file

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