diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt index 9f38f2078e..7ab595aed5 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt @@ -67,6 +67,7 @@ class MainActivity : AppCompatActivity() { LocalManagerProvider { BitwardenTheme( theme = state.theme, + dynamicColor = state.isDynamicColorsEnabled, ) { RootNavScreen( navController = navController, diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt index 20ff6ffe7a..6661affe42 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt @@ -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( 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. diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt index 4427d79b75..5c6c8faaec 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt @@ -35,6 +35,16 @@ interface SettingsDiskSource { */ val defaultSaveOptionFlow: Flow + /** + * The currently persisted dynamic colors setting (or `null` if not set). + */ + var isDynamicColorsEnabled: Boolean? + + /** + * Emits updates that track [isDynamicColorsEnabled]. + */ + val isDynamicColorsEnabledFlow: Flow + /** * The currently persisted biometric integrity source for the system. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 3549ffcd8d..1c41016b90 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -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() + private val mutableDynamicColorsFlow = bufferedMutableSharedFlow() + 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 + get() = mutableDynamicColorsFlow.onSubscription { emit(isDynamicColorsEnabled) } + override var systemBiometricIntegritySource: String? get() = getString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY) set(value) { diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index 51241506da..e3954317fb 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -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 + /** * Flow that emits changes to [defaultSaveOption] */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index 5f2cfbd77a..735cc0e162 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -47,6 +47,22 @@ class SettingsRepositoryImpl( override val defaultSaveOptionFlow: Flow by settingsDiskSource::defaultSaveOptionFlow + override var isDynamicColorsEnabled: Boolean + get() = settingsDiskSource.isDynamicColorsEnabled ?: false + set(value) { + settingsDiskSource.isDynamicColorsEnabled = value + } + + override val isDynamicColorsEnabledFlow: StateFlow + get() = settingsDiskSource + .isDynamicColorsEnabledFlow + .map { it ?: false } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = isDynamicColorsEnabled, + ) + override val isUnlockWithBiometricsEnabled: Boolean get() = authDiskSource.getUserBiometricUnlockKey() != null diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index c807fbe93b..cb8c08251c 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -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, ) } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index fbebc12342..63881754d9 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -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() } } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/MainViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/MainViewModelTest.kt index 51cee18c38..c2ef72fe04 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/MainViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/MainViewModelTest.kt @@ -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 { 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, +) diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt index c08b741c35..22e718d66b 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt @@ -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() + 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: diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt index ec39b18392..7a805a3617 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt @@ -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, diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt index b43fffd3e0..dc2f625ae8 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -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() 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,