PM-27210: Add dynamic color support to Authenticator (#6063)

This commit is contained in:
David Perez 2025-10-22 09:42:18 -05:00 committed by GitHub
parent e610a7541d
commit 4597337500
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 297 additions and 39 deletions

View File

@ -67,6 +67,7 @@ class MainActivity : AppCompatActivity() {
LocalManagerProvider {
BitwardenTheme(
theme = state.theme,
dynamicColor = state.isDynamicColorsEnabled,
) {
RootNavScreen(
navController = navController,

View File

@ -9,6 +9,7 @@ import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -25,20 +26,25 @@ class MainViewModel @Inject constructor(
) : BaseViewModel<MainState, MainEvent, MainAction>(
MainState(
theme = settingsRepository.appTheme,
isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled,
),
) {
init {
settingsRepository
.appThemeStateFlow
.onEach { trySendAction(MainAction.Internal.ThemeUpdate(it)) }
.map { MainAction.Internal.ThemeUpdate(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.isDynamicColorsEnabledFlow
.map { MainAction.Internal.DynamicColorUpdate(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.isScreenCaptureAllowedStateFlow
.onEach { isAllowed ->
sendEvent(MainEvent.ScreenCaptureSettingChange(isAllowed))
}
.map { MainEvent.ScreenCaptureSettingChange(it) }
.onEach(::sendEvent)
.launchIn(viewModelScope)
viewModelScope.launch {
configRepository.getServerConfig(forceRefresh = false)
@ -47,10 +53,17 @@ class MainViewModel @Inject constructor(
override fun handleAction(action: MainAction) {
when (action) {
is MainAction.Internal.ThemeUpdate -> handleThemeUpdated(action)
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
is MainAction.Internal -> handleInternalAction(action)
}
}
private fun handleInternalAction(action: MainAction.Internal) {
when (action) {
is MainAction.Internal.DynamicColorUpdate -> handleDynamicColorUpdate(action)
is MainAction.Internal.ThemeUpdate -> handleThemeUpdated(action)
}
}
@ -58,6 +71,10 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.NavigateToDebugMenu)
}
private fun handleDynamicColorUpdate(action: MainAction.Internal.DynamicColorUpdate) {
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isEnabled) }
}
private fun handleThemeUpdated(action: MainAction.Internal.ThemeUpdate) {
mutableStateFlow.update { it.copy(theme = action.theme) }
sendEvent(MainEvent.UpdateAppTheme(osTheme = action.theme.osValue))
@ -91,6 +108,7 @@ class MainViewModel @Inject constructor(
@Parcelize
data class MainState(
val theme: AppTheme,
val isDynamicColorsEnabled: Boolean,
) : Parcelable
/**
@ -116,6 +134,12 @@ sealed class MainAction {
* Actions for internal use by the ViewModel.
*/
sealed class Internal : MainAction() {
/**
* Indicates that dynamic colors have been enabled or disabled.
*/
data class DynamicColorUpdate(
val isEnabled: Boolean,
) : Internal()
/**
* Indicates that the app theme has changed.

View File

@ -35,6 +35,16 @@ interface SettingsDiskSource {
*/
val defaultSaveOptionFlow: Flow<DefaultSaveOption>
/**
* The currently persisted dynamic colors setting (or `null` if not set).
*/
var isDynamicColorsEnabled: Boolean?
/**
* Emits updates that track [isDynamicColorsEnabled].
*/
val isDynamicColorsEnabledFlow: Flow<Boolean?>
/**
* The currently persisted biometric integrity source for the system.
*/

View File

@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.onSubscription
private const val APP_THEME_KEY = "theme"
private const val APP_LANGUAGE_KEY = "appLocale"
private const val DEFAULT_SAVE_OPTION_KEY = "defaultSaveOption"
private const val DYNAMIC_COLORS_KEY = "dynamicColors"
private const val SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY = "biometricIntegritySource"
private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "accountBiometricIntegrityValid"
private const val ALERT_THRESHOLD_SECONDS_KEY = "alertThresholdSeconds"
@ -48,6 +49,8 @@ class SettingsDiskSourceImpl(
private val mutableDefaultSaveOptionFlow =
bufferedMutableSharedFlow<DefaultSaveOption>()
private val mutableDynamicColorsFlow = bufferedMutableSharedFlow<Boolean?>()
override var appLanguage: AppLanguage?
get() = getString(key = APP_LANGUAGE_KEY)
?.let { storedValue ->
@ -98,6 +101,16 @@ class SettingsDiskSourceImpl(
get() = mutableDefaultSaveOptionFlow
.onSubscription { emit(defaultSaveOption) }
override var isDynamicColorsEnabled: Boolean?
get() = getBoolean(key = DYNAMIC_COLORS_KEY)
set(newValue) {
putBoolean(key = DYNAMIC_COLORS_KEY, value = newValue)
mutableDynamicColorsFlow.tryEmit(newValue)
}
override val isDynamicColorsEnabledFlow: Flow<Boolean?>
get() = mutableDynamicColorsFlow.onSubscription { emit(isDynamicColorsEnabled) }
override var systemBiometricIntegritySource: String?
get() = getString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY)
set(value) {

View File

@ -37,6 +37,16 @@ interface SettingsRepository {
*/
var defaultSaveOption: DefaultSaveOption
/**
* The current setting for enabling dynamic colors.
*/
var isDynamicColorsEnabled: Boolean
/**
* Tracks changes to the [isDynamicColorsEnabled] value.
*/
val isDynamicColorsEnabledFlow: StateFlow<Boolean>
/**
* Flow that emits changes to [defaultSaveOption]
*/

View File

@ -47,6 +47,22 @@ class SettingsRepositoryImpl(
override val defaultSaveOptionFlow: Flow<DefaultSaveOption>
by settingsDiskSource::defaultSaveOptionFlow
override var isDynamicColorsEnabled: Boolean
get() = settingsDiskSource.isDynamicColorsEnabled ?: false
set(value) {
settingsDiskSource.isDynamicColorsEnabled = value
}
override val isDynamicColorsEnabledFlow: StateFlow<Boolean>
get() = settingsDiskSource
.isDynamicColorsEnabledFlow
.map { it ?: false }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = isDynamicColorsEnabled,
)
override val isUnlockWithBiometricsEnabled: Boolean
get() = authDiskSource.getUserBiometricUnlockKey() != null

View File

@ -204,12 +204,19 @@ fun SettingsScreen(
)
Spacer(modifier = Modifier.height(16.dp))
AppearanceSettings(
state = state,
state = state.appearance,
onThemeSelection = remember(viewModel) {
{
viewModel.trySendAction(SettingsAction.AppearanceChange.ThemeChange(it))
}
},
onDynamicColorChange = remember(viewModel) {
{
viewModel.trySendAction(
SettingsAction.AppearanceChange.DynamicColorChange(it),
)
}
},
)
Spacer(Modifier.height(16.dp))
HelpSettings(
@ -518,8 +525,9 @@ private fun ScreenCaptureRow(
@Composable
private fun ColumnScope.AppearanceSettings(
state: SettingsState,
state: SettingsState.Appearance,
onThemeSelection: (theme: AppTheme) -> Unit,
onDynamicColorChange: (isEnabled: Boolean) -> Unit,
) {
BitwardenListHeaderText(
modifier = Modifier
@ -529,19 +537,33 @@ private fun ColumnScope.AppearanceSettings(
)
Spacer(modifier = Modifier.height(height = 8.dp))
ThemeSelectionRow(
currentSelection = state.appearance.theme,
currentSelection = state.theme,
onThemeSelection = onThemeSelection,
cardStyle = if (state.isDynamicColorsSupported) CardStyle.Top() else CardStyle.Full,
modifier = Modifier
.testTag("ThemeChooser")
.standardHorizontalMargin()
.fillMaxWidth(),
)
if (state.isDynamicColorsSupported) {
BitwardenSwitch(
label = stringResource(id = BitwardenString.use_dynamic_colors),
isChecked = state.isDynamicColorsEnabled,
onCheckedChange = onDynamicColorChange,
cardStyle = CardStyle.Bottom,
modifier = Modifier
.testTag(tag = "DynamicColorsSwitch")
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
}
@Composable
private fun ThemeSelectionRow(
currentSelection: AppTheme,
onThemeSelection: (AppTheme) -> Unit,
cardStyle: CardStyle,
modifier: Modifier = Modifier,
resources: Resources = LocalResources.current,
) {
@ -555,7 +577,7 @@ private fun ThemeSelectionRow(
.first { it.displayLabel(resources) == selectedOptionLabel }
onThemeSelection(selectedOption)
},
cardStyle = CardStyle.Full,
cardStyle = cardStyle,
modifier = modifier,
)
}

View File

@ -1,5 +1,6 @@
package com.bitwarden.authenticator.ui.platform.feature.settings
import android.os.Build
import android.os.Parcelable
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
@ -16,6 +17,7 @@ import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.resource.BitwardenString
@ -43,7 +45,7 @@ private const val KEY_STATE = "state"
class SettingsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
clock: Clock,
private val authenticatorRepository: AuthenticatorRepository,
authenticatorRepository: AuthenticatorRepository,
private val authenticatorBridgeManager: AuthenticatorBridgeManager,
private val settingsRepository: SettingsRepository,
private val clipboardManager: BitwardenClipboardManager,
@ -59,6 +61,7 @@ class SettingsViewModel @Inject constructor(
defaultSaveOption = settingsRepository.defaultSaveOption,
sharedAccountsState = authenticatorRepository.sharedCodesStateFlow.value,
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled,
),
) {
@ -66,13 +69,17 @@ class SettingsViewModel @Inject constructor(
authenticatorRepository
.sharedCodesStateFlow
.map { SettingsAction.Internal.SharedAccountsStateUpdated(it) }
.onEach(::handleAction)
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.isDynamicColorsEnabledFlow
.map { SettingsAction.Internal.DynamicColorsUpdated(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.defaultSaveOptionFlow
.map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) }
.onEach(::handleAction)
.onEach(::sendAction)
.launchIn(viewModelScope)
}
@ -109,6 +116,8 @@ class SettingsViewModel @Inject constructor(
is SettingsAction.Internal.DefaultSaveOptionUpdated -> {
handleDefaultSaveOptionUpdated(action)
}
is SettingsAction.Internal.DynamicColorsUpdated -> handleDynamicColorsUpdated(action)
}
}
@ -213,6 +222,12 @@ class SettingsViewModel @Inject constructor(
}
}
private fun handleDynamicColorsUpdated(action: SettingsAction.Internal.DynamicColorsUpdated) {
mutableStateFlow.update {
it.copy(appearance = it.appearance.copy(isDynamicColorsEnabled = action.isEnabled))
}
}
private fun handleSyncWithBitwardenClick() {
when (authenticatorBridgeManager.accountSyncStateFlow.value) {
AccountSyncState.AppNotInstalled -> {
@ -241,36 +256,46 @@ class SettingsViewModel @Inject constructor(
private fun handleAppearanceChange(action: SettingsAction.AppearanceChange) {
when (action) {
is SettingsAction.AppearanceChange.DynamicColorChange -> {
handleDynamicColorChange(action)
}
is SettingsAction.AppearanceChange.LanguageChange -> {
handleLanguageChange(action.language)
handleLanguageChange(action)
}
is SettingsAction.AppearanceChange.ThemeChange -> {
handleThemeChange(action.appTheme)
handleThemeChange(action)
}
}
}
private fun handleLanguageChange(language: AppLanguage) {
private fun handleDynamicColorChange(
action: SettingsAction.AppearanceChange.DynamicColorChange,
) {
settingsRepository.isDynamicColorsEnabled = action.isEnabled
}
private fun handleLanguageChange(action: SettingsAction.AppearanceChange.LanguageChange) {
mutableStateFlow.update {
it.copy(
appearance = it.appearance.copy(language = language),
appearance = it.appearance.copy(language = action.language),
)
}
settingsRepository.appLanguage = language
settingsRepository.appLanguage = action.language
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(
language.localeName,
action.language.localeName,
)
AppCompatDelegate.setApplicationLocales(appLocale)
}
private fun handleThemeChange(theme: AppTheme) {
private fun handleThemeChange(action: SettingsAction.AppearanceChange.ThemeChange) {
mutableStateFlow.update {
it.copy(
appearance = it.appearance.copy(theme = theme),
appearance = it.appearance.copy(theme = action.appTheme),
)
}
settingsRepository.appTheme = theme
settingsRepository.appTheme = action.appTheme
}
private fun handleHelpClick(action: SettingsAction.HelpClick) {
@ -332,6 +357,7 @@ class SettingsViewModel @Inject constructor(
accountSyncState: AccountSyncState,
sharedAccountsState: SharedVerificationCodesState,
isScreenCaptureAllowed: Boolean,
isDynamicColorsEnabled: Boolean,
): SettingsState {
val currentYear = Year.now(clock)
val copyrightInfo = "© Bitwarden Inc. 2015-$currentYear".asText()
@ -345,6 +371,8 @@ class SettingsViewModel @Inject constructor(
appearance = SettingsState.Appearance(
language = appLanguage,
theme = appTheme,
isDynamicColorsSupported = isBuildVersionAtLeast(Build.VERSION_CODES.S),
isDynamicColorsEnabled = isDynamicColorsEnabled,
),
isUnlockWithBiometricsEnabled = unlockWithBiometricsEnabled,
isSubmitCrashLogsEnabled = isSubmitCrashLogsEnabled,
@ -400,6 +428,8 @@ data class SettingsState(
data class Appearance(
val language: AppLanguage,
val theme: AppTheme,
val isDynamicColorsSupported: Boolean,
val isDynamicColorsEnabled: Boolean,
) : Parcelable
}
@ -558,6 +588,13 @@ sealed class SettingsAction(
data class ThemeChange(
val appTheme: AppTheme,
) : AppearanceChange()
/**
* Indicates the user selected a new theme.
*/
data class DynamicColorChange(
val isEnabled: Boolean,
) : AppearanceChange()
}
/**
@ -604,5 +641,12 @@ sealed class SettingsAction(
data class DefaultSaveOptionUpdated(
val option: DefaultSaveOption,
) : SettingsAction()
/**
* Indicates that the dynamic colors state on disk was updated.
*/
data class DynamicColorsUpdated(
val isEnabled: Boolean,
) : SettingsAction()
}
}

View File

@ -17,28 +17,28 @@ class MainViewModelTest : BaseViewModelTest() {
private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT)
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(false)
private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false)
private val settingsRepository = mockk<SettingsRepository> {
every { appTheme } returns AppTheme.DEFAULT
every { appThemeStateFlow } returns mutableAppThemeFlow
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedFlow
every { isDynamicColorsEnabled } returns false
every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow
}
private val fakeServerConfigRepository = FakeServerConfigRepository()
private val mainViewModel: MainViewModel = MainViewModel(
settingsRepository = settingsRepository,
configRepository = fakeServerConfigRepository,
)
@Test
fun `on AppThemeChanged should update state`() = runTest {
mainViewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
eventFlow.skipItems(count = 2)
assertEquals(
MainState(theme = AppTheme.DEFAULT),
DEFAULT_STATE,
stateFlow.awaitItem(),
)
mainViewModel.trySendAction(MainAction.Internal.ThemeUpdate(theme = AppTheme.DARK))
viewModel.trySendAction(MainAction.Internal.ThemeUpdate(theme = AppTheme.DARK))
assertEquals(
MainState(theme = AppTheme.DARK),
DEFAULT_STATE.copy(theme = AppTheme.DARK),
stateFlow.awaitItem(),
)
assertEquals(
@ -53,13 +53,46 @@ class MainViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on DynamicColorUpdate should update state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE,
awaitItem(),
)
viewModel.trySendAction(MainAction.Internal.DynamicColorUpdate(isEnabled = true))
assertEquals(
DEFAULT_STATE.copy(isDynamicColorsEnabled = true),
awaitItem(),
)
}
verify {
settingsRepository.isDynamicColorsEnabled
settingsRepository.isDynamicColorsEnabledFlow
}
}
@Test
fun `send NavigateToDebugMenu action when OpenDebugMenu action is sent`() = runTest {
mainViewModel.eventFlow.test {
val viewModel = createViewModel()
viewModel.eventFlow.test {
// Ignore the events that are fired off by flows in the ViewModel init
skipItems(2)
mainViewModel.trySendAction(MainAction.OpenDebugMenu)
viewModel.trySendAction(MainAction.OpenDebugMenu)
assertEquals(MainEvent.NavigateToDebugMenu, awaitItem())
}
}
private fun createViewModel(): MainViewModel =
MainViewModel(
settingsRepository = settingsRepository,
configRepository = fakeServerConfigRepository,
)
}
private val DEFAULT_STATE = MainState(
theme = AppTheme.DEFAULT,
isDynamicColorsEnabled = false,
)

View File

@ -7,6 +7,7 @@ import com.bitwarden.authenticator.data.authenticator.datasource.sdk.Authenticat
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import io.mockk.every
import io.mockk.just
@ -117,6 +118,36 @@ class SettingsRepositoryTest {
}
}
@Test
fun `isDynamicColorsEnabled should pull from and update SettingsDiskSource`() {
// Reading from repository should read from disk source:
every { settingsDiskSource.isDynamicColorsEnabled } returns null
assertFalse(settingsRepository.isDynamicColorsEnabled)
verify { settingsDiskSource.isDynamicColorsEnabled }
// Writing to repository should write to disk source:
every { settingsDiskSource.isDynamicColorsEnabled = true } just runs
settingsRepository.isDynamicColorsEnabled = true
verify { settingsDiskSource.isDynamicColorsEnabled = true }
}
@Test
fun `isDynamicColorsEnabledFlow should match SettingsDiskSource`() = runTest {
// Reading from repository should read from disk source:
val mutableDynamicColorsFlow = bufferedMutableSharedFlow<Boolean?>()
every { settingsDiskSource.isDynamicColorsEnabledFlow } returns mutableDynamicColorsFlow
every { settingsDiskSource.isDynamicColorsEnabled } returns null
settingsRepository.isDynamicColorsEnabledFlow.test {
assertFalse(awaitItem())
mutableDynamicColorsFlow.emit(true)
assertTrue(awaitItem())
mutableDynamicColorsFlow.emit(false)
assertFalse(awaitItem())
expectNoEvents()
}
}
@Test
fun `previouslySyncedBitwardenAccountIds should pull from and update SettingsDiskSource`() {
// Reading from repository should read from disk source:

View File

@ -208,6 +208,17 @@ class SettingsScreenTest : AuthenticatorComposeTest() {
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `on use dynamic colors row click should send DynamicColorChange event`() {
composeTestRule
.onNodeWithText(text = "Use dynamic colors")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(SettingsAction.AppearanceChange.DynamicColorChange(true))
}
}
}
private val APP_LANGUAGE = AppLanguage.ENGLISH
@ -215,8 +226,10 @@ private val APP_THEME = AppTheme.DEFAULT
private val DEFAULT_SAVE_OPTION = DefaultSaveOption.NONE
private val DEFAULT_STATE = SettingsState(
appearance = SettingsState.Appearance(
APP_LANGUAGE,
APP_THEME,
language = APP_LANGUAGE,
theme = APP_THEME,
isDynamicColorsSupported = true,
isDynamicColorsEnabled = false,
),
isSubmitCrashLogsEnabled = true,
isUnlockWithBiometricsEnabled = true,

View File

@ -1,5 +1,6 @@
package com.bitwarden.authenticator.ui.platform.feature.settings
import android.os.Build
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.authenticator.BuildConfig
@ -13,6 +14,7 @@ import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.Defau
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.resource.BitwardenString
@ -48,6 +50,7 @@ class SettingsViewModelTest : BaseViewModelTest() {
}
private val mutableDefaultSaveOptionFlow = bufferedMutableSharedFlow<DefaultSaveOption>()
private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(false)
private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false)
private val settingsRepository: SettingsRepository = mockk {
every { appLanguage } returns APP_LANGUAGE
every { appTheme } returns APP_THEME
@ -58,6 +61,9 @@ class SettingsViewModelTest : BaseViewModelTest() {
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedStateFlow
every { isScreenCaptureAllowed } answers { mutableScreenCaptureAllowedStateFlow.value }
every { isScreenCaptureAllowed = any() } just runs
every { isDynamicColorsEnabled } answers { mutableIsDynamicColorsEnabledFlow.value }
every { isDynamicColorsEnabled = any() } just runs
every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow
}
private val clipboardManager: BitwardenClipboardManager = mockk()
@ -65,11 +71,14 @@ class SettingsViewModelTest : BaseViewModelTest() {
fun setup() {
mockkStatic(SharedVerificationCodesState::isSyncWithBitwardenEnabled)
every { MOCK_SHARED_CODES_STATE.isSyncWithBitwardenEnabled } returns false
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(Build.VERSION_CODES.S) } returns true
}
@AfterEach
fun teardown() {
unmockkStatic(SharedVerificationCodesState::isSyncWithBitwardenEnabled)
unmockkStatic(::isBuildVersionAtLeast)
}
@Test
@ -225,6 +234,35 @@ class SettingsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on DynamicColorChange should update value in state and SettingsRepository`() =
runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
SettingsAction.AppearanceChange.DynamicColorChange(isEnabled = true),
)
verify(exactly = 1) {
settingsRepository.isDynamicColorsEnabled = true
}
}
@Test
fun `on DynamicColorsUpdated should update value in state and SettingsRepository`() =
runTest {
val viewModel = createViewModel()
viewModel.trySendAction(SettingsAction.Internal.DynamicColorsUpdated(isEnabled = true))
assertEquals(
DEFAULT_STATE.copy(
appearance = DEFAULT_APPEARANCE_STATE.copy(isDynamicColorsEnabled = true),
),
viewModel.stateFlow.value,
)
}
private fun createViewModel(
savedState: SettingsState? = DEFAULT_STATE,
) = SettingsViewModel(
@ -245,11 +283,14 @@ private val CLOCK = Clock.fixed(
ZoneOffset.UTC,
)
private val DEFAULT_SAVE_OPTION = DefaultSaveOption.NONE
private val DEFAULT_APPEARANCE_STATE = SettingsState.Appearance(
language = APP_LANGUAGE,
theme = APP_THEME,
isDynamicColorsSupported = true,
isDynamicColorsEnabled = false,
)
private val DEFAULT_STATE = SettingsState(
appearance = SettingsState.Appearance(
APP_LANGUAGE,
APP_THEME,
),
appearance = DEFAULT_APPEARANCE_STATE,
isSubmitCrashLogsEnabled = true,
isUnlockWithBiometricsEnabled = true,
showSyncWithBitwarden = true,