mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-1328: Add support for changing language with picker (#577)
This commit is contained in:
parent
f95e5cc3cb
commit
0d171e91b9
74 changed files with 520 additions and 50 deletions
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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).
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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(),
|
||||||
|
),
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
43
app/src/main/res/xml/locales_config.xml
Normal file
43
app/src/main/res/xml/locales_config.xml
Normal 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>
|
|
@ -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,22 +84,23 @@ class SettingsDiskSourceTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getVaultTimeoutInMinutesFlow should react to changes in getOrganizations`() = runTest {
|
fun `getVaultTimeoutInMinutesFlow should react to changes in getVaultTimeoutInMinutes`() =
|
||||||
val mockUserId = "mockUserId"
|
runTest {
|
||||||
val vaultTimeoutInMinutes = 360
|
val mockUserId = "mockUserId"
|
||||||
settingsDiskSource.getVaultTimeoutInMinutesFlow(userId = mockUserId).test {
|
val vaultTimeoutInMinutes = 360
|
||||||
// The initial values of the Flow and the property are in sync
|
settingsDiskSource.getVaultTimeoutInMinutesFlow(userId = mockUserId).test {
|
||||||
assertNull(settingsDiskSource.getVaultTimeoutInMinutes(userId = mockUserId))
|
// The initial values of the Flow and the property are in sync
|
||||||
assertNull(awaitItem())
|
assertNull(settingsDiskSource.getVaultTimeoutInMinutes(userId = mockUserId))
|
||||||
|
assertNull(awaitItem())
|
||||||
|
|
||||||
// Updating the repository updates shared preferences
|
// Updating the repository updates shared preferences
|
||||||
settingsDiskSource.storeVaultTimeoutInMinutes(
|
settingsDiskSource.storeVaultTimeoutInMinutes(
|
||||||
userId = mockUserId,
|
userId = mockUserId,
|
||||||
vaultTimeoutInMinutes = vaultTimeoutInMinutes,
|
vaultTimeoutInMinutes = vaultTimeoutInMinutes,
|
||||||
)
|
)
|
||||||
assertEquals(vaultTimeoutInMinutes, awaitItem())
|
assertEquals(vaultTimeoutInMinutes, awaitItem())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `storeVaultTimeoutInMinutes for non-null values should update SharedPreferences`() {
|
fun `storeVaultTimeoutInMinutes for non-null values should update SharedPreferences`() {
|
||||||
|
@ -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 {
|
||||||
|
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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" }
|
||||||
|
|
Loading…
Reference in a new issue