Save the clear clipboard frequency in settings. (#886)

This commit is contained in:
Oleg Semenenko 2024-01-30 23:44:21 -06:00 committed by Álison Fernandes
parent 526ab51a90
commit a985cfaccc
13 changed files with 224 additions and 45 deletions

View file

@ -129,6 +129,16 @@ interface SettingsDiskSource {
vaultTimeoutAction: VaultTimeoutAction?,
)
/**
* Gets the clipboard clearing frequency in seconds for the given [userId].
*/
fun getClearClipboardFrequencySeconds(userId: String): Int?
/**
* Stores the clipboard clearing frequency in seconds for the given [userId].
*/
fun storeClearClipboardFrequencySeconds(userId: String, frequency: Int?)
/**
* Gets the default [UriMatchType] for the given [userId].
*/

View file

@ -31,6 +31,7 @@ private const val SCREEN_CAPTURE_ALLOW_KEY = "$BASE_KEY:screenCaptureAllowed"
private const val SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY = "$BASE_KEY:biometricIntegritySource"
private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "$BASE_KEY:accountBiometricIntegrityValid"
private const val CRASH_LOGGING_ENABLED_KEY = "$BASE_KEY:crashLoggingEnabled"
private const val CLEAR_CLIPBOARD_INTERVAL_KEY = "$BASE_KEY:clearClipboard"
/**
* Primary implementation of [SettingsDiskSource].
@ -136,6 +137,7 @@ class SettingsDiskSourceImpl(
)
storeLastSyncTime(userId = userId, lastSyncTime = null)
storeScreenCaptureAllowed(userId = userId, isScreenCaptureAllowed = null)
storeClearClipboardFrequencySeconds(userId = userId, frequency = null)
removeWithPrefix(prefix = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_$userId")
}
@ -191,6 +193,16 @@ class SettingsDiskSourceImpl(
getMutableVaultTimeoutInMinutesFlow(userId = userId).tryEmit(vaultTimeoutInMinutes)
}
override fun getClearClipboardFrequencySeconds(userId: String): Int? =
getInt(key = "${CLEAR_CLIPBOARD_INTERVAL_KEY}_$userId")
override fun storeClearClipboardFrequencySeconds(userId: String, frequency: Int?) {
putInt(
key = "${CLEAR_CLIPBOARD_INTERVAL_KEY}_$userId",
value = frequency,
)
}
override fun getVaultTimeoutAction(userId: String): VaultTimeoutAction? =
getString(key = "${VAULT_TIMEOUT_ACTION_KEY}_$userId")?.let { storedValue ->
VaultTimeoutAction.entries.firstOrNull { storedValue == it.value }

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@ -51,6 +52,11 @@ interface SettingsRepository {
*/
val isIconLoadingDisabledFlow: Flow<Boolean>
/**
* The frequency in seconds at which we clear the clipboard.
*/
var clearClipboardFrequency: ClearClipboardFrequency
/**
* The current setting for crash logging.
*/

View file

@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@ -101,6 +102,18 @@ class SettingsRepositoryImpl(
.isIconLoadingDisabled
?: false,
)
override var clearClipboardFrequency: ClearClipboardFrequency
get() = activeUserId
?.let { userId ->
settingsDiskSource
.getClearClipboardFrequencySeconds(userId)
.toClearClipboardFrequency()
}
?: ClearClipboardFrequency.NEVER
set(value) {
val userId = activeUserId ?: return
settingsDiskSource.storeClearClipboardFrequencySeconds(userId, value.frequencySeconds)
}
override var isCrashLoggingEnabled: Boolean
get() = settingsDiskSource.isCrashLoggingEnabled ?: true
@ -439,6 +452,13 @@ private fun Int?.toVaultTimeout(): VaultTimeout =
else -> VaultTimeout.Custom(vaultTimeoutInMinutes = this)
}
/**
* Converts the given Int into a [ClearClipboardFrequency] item.
*/
private fun Int?.toClearClipboardFrequency(): ClearClipboardFrequency =
ClearClipboardFrequency.entries.firstOrNull { it.frequencySeconds == this }
?: ClearClipboardFrequency.NEVER
/**
* Returns the given [VaultTimeoutAction] or a default value if `null`.
*/

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.platform.repository.model
/**
* Represents the frequency for clearing the clipboard for the current user.
*/
@Suppress("MagicNumber")
enum class ClearClipboardFrequency(
val frequencySeconds: Int?,
) {
NEVER(null),
TEN_SECONDS(10),
TWENTY_SECONDS(20),
THIRTY_SECONDS(30),
ONE_MINUTE(60),
TWO_MINUTES(120),
FIVE_MINUTES(300),
}

View file

@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.platform.repository.util
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
/**
* Provides a human-readable display label for the given [ClearClipboardFrequency].
*/
val ClearClipboardFrequency.displayLabel: Text
get() = when (this) {
ClearClipboardFrequency.NEVER -> R.string.never
ClearClipboardFrequency.TEN_SECONDS -> R.string.ten_seconds
ClearClipboardFrequency.TWENTY_SECONDS -> R.string.twenty_seconds
ClearClipboardFrequency.THIRTY_SECONDS -> R.string.thirty_seconds
ClearClipboardFrequency.ONE_MINUTE -> R.string.one_minute
ClearClipboardFrequency.TWO_MINUTES -> R.string.two_minutes
ClearClipboardFrequency.FIVE_MINUTES -> R.string.five_minutes
}
.asText()

View file

@ -29,6 +29,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.util.displayLabel
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
@ -191,8 +193,8 @@ private fun ScreenCaptureRow(
@Composable
private fun ClearClipboardFrequencyRow(
currentSelection: OtherState.ClearClipboardFrequency,
onFrequencySelection: (OtherState.ClearClipboardFrequency) -> Unit,
currentSelection: ClearClipboardFrequency,
onFrequencySelection: (ClearClipboardFrequency) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowClearClipboardDialog by remember { mutableStateOf(false) }
@ -204,7 +206,7 @@ private fun ClearClipboardFrequencyRow(
modifier = modifier,
) {
Text(
text = currentSelection.text(),
text = currentSelection.displayLabel.invoke(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -215,14 +217,14 @@ private fun ClearClipboardFrequencyRow(
title = stringResource(id = R.string.clear_clipboard),
onDismissRequest = { shouldShowClearClipboardDialog = false },
) {
OtherState.ClearClipboardFrequency.entries.forEach { option ->
ClearClipboardFrequency.entries.forEach { option ->
BitwardenSelectionRow(
text = option.text,
text = option.displayLabel,
isSelected = option == currentSelection,
onClick = {
shouldShowClearClipboardDialog = false
onFrequencySelection(
OtherState.ClearClipboardFrequency.entries.first { it == option },
ClearClipboardFrequency.entries.first { it == option },
)
},
)

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
@ -38,7 +39,7 @@ class OtherViewModel @Inject constructor(
?: OtherState(
allowScreenCapture = settingsRepo.isScreenCaptureAllowed,
allowSyncOnRefresh = settingsRepo.getPullToRefreshEnabledFlow().value,
clearClipboardFrequency = OtherState.ClearClipboardFrequency.DEFAULT,
clearClipboardFrequency = settingsRepo.clearClipboardFrequency,
lastSyncTime = settingsRepo
.vaultLastSync
?.toFormattedPattern(VAULT_LAST_SYNC_TIME_PATTERN, clock.zone)
@ -80,12 +81,10 @@ class OtherViewModel @Inject constructor(
private fun handleClearClipboardFrequencyChanged(
action: OtherAction.ClearClipboardFrequencyChange,
) {
// TODO BIT-1283 implement clear clipboard setting
mutableStateFlow.update {
it.copy(
clearClipboardFrequency = action.clearClipboardFrequency,
)
it.copy(clearClipboardFrequency = action.clearClipboardFrequency)
}
settingsRepo.clearClipboardFrequency = action.clearClipboardFrequency
}
private fun handleSyncNowButtonClicked() {
@ -125,19 +124,6 @@ data class OtherState(
val lastSyncTime: String,
val dialogState: DialogState?,
) : Parcelable {
/**
* Represents the different frequencies with which the user clipboard can be cleared.
*/
enum class ClearClipboardFrequency(val text: Text) {
DEFAULT(text = R.string.never.asText()),
TEN_SECONDS(text = R.string.ten_seconds.asText()),
TWENTY_SECONDS(text = R.string.twenty_seconds.asText()),
THIRTY_SECONDS(text = R.string.thirty_seconds.asText()),
ONE_MINUTE(text = R.string.one_minute.asText()),
TWO_MINUTES(text = R.string.two_minutes.asText()),
FIVE_MINUTES(text = R.string.five_minutes.asText()),
}
/**
* Represents the current state of any dialogs on the screen.
*/
@ -189,7 +175,7 @@ sealed class OtherAction {
* Indicates that the user changed the clear clipboard frequency.
*/
data class ClearClipboardFrequencyChange(
val clearClipboardFrequency: OtherState.ClearClipboardFrequency,
val clearClipboardFrequency: ClearClipboardFrequency,
) : OtherAction()
/**

View file

@ -4,6 +4,7 @@ import androidx.core.content.edit
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
@ -875,4 +876,64 @@ class SettingsDiskSourceTest {
)
assertFalse(fakeSharedPreferences.contains(screenCaptureAllowKey))
}
@Test
fun `storeClearClipboardFrequency should update SharedPreferences`() {
val clearClipboardBaseKey = "bwPreferencesStorage:clearClipboard"
val mockUserId = "mockUserId"
val clearClipboardKey = "${clearClipboardBaseKey}_$mockUserId"
assertNull(settingsDiskSource.getClearClipboardFrequencySeconds(mockUserId))
assertEquals(fakeSharedPreferences.getInt(mockUserId, 0), 0)
// Updating the disk source updates shared preferences
settingsDiskSource.storeClearClipboardFrequencySeconds(
mockUserId,
ClearClipboardFrequency.ONE_MINUTE.frequencySeconds,
)
assertEquals(
ClearClipboardFrequency.ONE_MINUTE.frequencySeconds,
fakeSharedPreferences.getInt(clearClipboardKey, 0),
)
}
@Suppress("MaxLineLength")
@Test
fun `storeClearClipboardFrequency should clear the SharedPreferences value if the value is null`() {
val clearClipboardBaseKey = "bwPreferencesStorage:clearClipboard"
val mockUserId = "mockUserId"
val clearClipboardKey = "${clearClipboardBaseKey}_$mockUserId"
assertNull(settingsDiskSource.getClearClipboardFrequencySeconds(mockUserId))
assertEquals(fakeSharedPreferences.getInt(mockUserId, 0), 0)
// Updating the disk source updates shared preferences
settingsDiskSource.storeClearClipboardFrequencySeconds(
mockUserId,
null,
)
assertFalse(fakeSharedPreferences.contains(clearClipboardKey))
}
@Test
fun `getClearClipboardFrequency should pull from SharedPreferences`() {
val clearClipboardBaseKey = "bwPreferencesStorage:clearClipboard"
val mockUserId = "mockUserId"
val expectedValue = 20
val clearClipboardKey = "${clearClipboardBaseKey}_$mockUserId"
assertNull(settingsDiskSource.getClearClipboardFrequencySeconds(mockUserId))
assertEquals(fakeSharedPreferences.getInt(mockUserId, 0), 0)
// Update SharedPreferences updates the disk source
fakeSharedPreferences.edit {
putInt(clearClipboardKey, expectedValue)
}
assertEquals(
expectedValue,
settingsDiskSource.getClearClipboardFrequencySeconds(mockUserId),
)
}
}

View file

@ -44,6 +44,7 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val storedVaultTimeoutActions = mutableMapOf<String, VaultTimeoutAction?>()
private val storedVaultTimeoutInMinutes = mutableMapOf<String, Int?>()
private val storedUriMatchTypes = mutableMapOf<String, UriMatchType?>()
private val storedClearClipboardFrequency = mutableMapOf<String, Int?>()
private val storedDisableAutofillSavePrompt = mutableMapOf<String, Boolean?>()
private val storedPullToRefreshEnabled = mutableMapOf<String, Boolean?>()
private val storedInlineAutofillEnabled = mutableMapOf<String, Boolean?>()
@ -121,6 +122,7 @@ class FakeSettingsDiskSource : SettingsDiskSource {
storedInlineAutofillEnabled.remove(userId)
storedBlockedAutofillUris.remove(userId)
storedScreenCaptureAllowed.remove(userId)
storedClearClipboardFrequency.remove(userId)
mutableVaultTimeoutActionsFlowMap.remove(userId)
mutableVaultTimeoutInMinutesFlowMap.remove(userId)
@ -168,6 +170,13 @@ class FakeSettingsDiskSource : SettingsDiskSource {
getMutableVaultTimeoutActionsFlow(userId = userId).tryEmit(vaultTimeoutAction)
}
override fun getClearClipboardFrequencySeconds(userId: String): Int? =
storedClearClipboardFrequency[userId]
override fun storeClearClipboardFrequencySeconds(userId: String, frequency: Int?) {
storedClearClipboardFrequency[userId] = frequency
}
override fun getDefaultUriMatchType(userId: String): UriMatchType? =
storedUriMatchTypes[userId]

View file

@ -12,6 +12,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.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@ -872,6 +873,31 @@ class SettingsRepositoryTest {
assertEquals(false, fakeSettingsDiskSource.getScreenCaptureAllowed(userId))
}
}
@Suppress("MaxLineLength")
@Test
fun `clearClipboardFrequency should pull from and update SettingsDiskSource`() =
runTest {
val userId = "userId"
fakeAuthDiskSource.userState = MOCK_USER_STATE
fakeSettingsDiskSource.storeClearClipboardFrequencySeconds(
userId,
ClearClipboardFrequency.ONE_MINUTE.frequencySeconds,
)
assertEquals(
ClearClipboardFrequency.ONE_MINUTE,
settingsRepository.clearClipboardFrequency,
)
settingsRepository.clearClipboardFrequency = ClearClipboardFrequency.TEN_SECONDS
assertEquals(
ClearClipboardFrequency.TEN_SECONDS,
settingsRepository.clearClipboardFrequency,
)
}
}
private val MOCK_USER_STATE =

View file

@ -9,6 +9,7 @@ import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -103,7 +104,7 @@ class OtherScreenTest : BaseComposeTest() {
verify {
viewModel.trySendAction(
OtherAction.ClearClipboardFrequencyChange(
clearClipboardFrequency = OtherState.ClearClipboardFrequency.TEN_SECONDS,
clearClipboardFrequency = ClearClipboardFrequency.TEN_SECONDS,
),
)
}
@ -148,7 +149,7 @@ class OtherScreenTest : BaseComposeTest() {
private val DEFAULT_STATE = OtherState(
allowScreenCapture = false,
allowSyncOnRefresh = false,
clearClipboardFrequency = OtherState.ClearClipboardFrequency.DEFAULT,
clearClipboardFrequency = ClearClipboardFrequency.NEVER,
lastSyncTime = "5/14/2023 4:52 PM",
dialogState = null,
)

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -31,6 +32,8 @@ class OtherViewModelTest : BaseViewModelTest() {
private val mutableVaultLastSyncStateFlow = MutableStateFlow(instant)
private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(isAllowed)
private val settingsRepository = mockk<SettingsRepository> {
every { clearClipboardFrequency } returns ClearClipboardFrequency.NEVER
every { clearClipboardFrequency = any() } just runs
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshStateFlow
every { vaultLastSyncStateFlow } returns mutableVaultLastSyncStateFlow
every { vaultLastSync } returns instant
@ -49,7 +52,7 @@ class OtherViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct when set`() {
val state = DEFAULT_STATE.copy(
clearClipboardFrequency = OtherState.ClearClipboardFrequency.FIVE_MINUTES,
clearClipboardFrequency = ClearClipboardFrequency.FIVE_MINUTES,
)
val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value)
@ -57,23 +60,28 @@ class OtherViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `on AllowScreenCaptureToggled should update value in state and SettingsRepository`() = runTest {
val viewModel = createViewModel()
val newScreenCaptureAllowedValue = true
fun `on AllowScreenCaptureToggled should update value in state and SettingsRepository`() =
runTest {
val viewModel = createViewModel()
val newScreenCaptureAllowedValue = true
viewModel.trySendAction(OtherAction.AllowScreenCaptureToggle(newScreenCaptureAllowedValue))
verify(exactly = 1) {
settingsRepository.isScreenCaptureAllowed = newScreenCaptureAllowedValue
}
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(allowScreenCapture = true),
awaitItem(),
viewModel.trySendAction(
OtherAction.AllowScreenCaptureToggle(
newScreenCaptureAllowedValue,
),
)
verify(exactly = 1) {
settingsRepository.isScreenCaptureAllowed = newScreenCaptureAllowedValue
}
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(allowScreenCapture = true),
awaitItem(),
)
}
}
}
@Test
fun `on AllowSyncToggled should update value in state`() {
@ -110,12 +118,12 @@ class OtherViewModelTest : BaseViewModelTest() {
)
viewModel.trySendAction(
OtherAction.ClearClipboardFrequencyChange(
clearClipboardFrequency = OtherState.ClearClipboardFrequency.ONE_MINUTE,
clearClipboardFrequency = ClearClipboardFrequency.ONE_MINUTE,
),
)
assertEquals(
DEFAULT_STATE.copy(
clearClipboardFrequency = OtherState.ClearClipboardFrequency.ONE_MINUTE,
clearClipboardFrequency = ClearClipboardFrequency.ONE_MINUTE,
),
awaitItem(),
)
@ -173,7 +181,7 @@ class OtherViewModelTest : BaseViewModelTest() {
private val DEFAULT_STATE = OtherState(
allowScreenCapture = false,
allowSyncOnRefresh = false,
clearClipboardFrequency = OtherState.ClearClipboardFrequency.DEFAULT,
clearClipboardFrequency = ClearClipboardFrequency.NEVER,
lastSyncTime = "10/26/2023 12:00 PM",
dialogState = null,
)