BIT-1093: Setup TOTP auto copy settings (#928)

This commit is contained in:
Lucas Kivi 2024-01-31 23:33:28 -06:00 committed by Álison Fernandes
parent 3fe0950983
commit 7738f75bfb
12 changed files with 213 additions and 10 deletions

View file

@ -57,6 +57,7 @@ object AutofillModule {
authRepository: AuthRepository,
clipboardManager: BitwardenClipboardManager,
dispatcherManager: DispatcherManager,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
): AutofillCompletionManager =
AutofillCompletionManagerImpl(
@ -64,6 +65,7 @@ object AutofillModule {
autofillParser = autofillParser,
clipboardManager = clipboardManager,
dispatcherManager = dispatcherManager,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
)

View file

@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.autofill.util.toAutofillAppInfo
import com.x8bit.bitwarden.data.autofill.util.toAutofillCipherProvider
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import kotlinx.coroutines.CoroutineScope
@ -26,6 +27,7 @@ import kotlinx.coroutines.launch
/**
* Primary implementation of [AutofillCompletionManager].
*/
@Suppress("LongParameterList")
class AutofillCompletionManagerImpl(
private val authRepository: AuthRepository,
private val autofillParser: AutofillParser,
@ -33,6 +35,7 @@ class AutofillCompletionManagerImpl(
private val dispatcherManager: DispatcherManager,
private val filledDataBuilderProvider: (CipherView) -> FilledDataBuilder =
{ createSingleItemFilledDataBuilder(cipherView = it) },
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
) : AutofillCompletionManager {
private val mainScope = CoroutineScope(dispatcherManager.main)
@ -97,9 +100,9 @@ class AutofillCompletionManagerImpl(
) {
val isPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true
val totpCode = cipherView.login?.totp
val isTotpDisabled = settingsRepository.isAutoCopyTotpDisabled
// TODO check global TOTP enabled status BIT-1093
if (isPremium && totpCode != null) {
if (!isTotpDisabled && isPremium && totpCode != null) {
val totpResult = vaultRepository.generateTotp(
time = DateTime.now(),
totpCode = totpCode,

View file

@ -81,6 +81,20 @@ interface SettingsDiskSource {
value: Boolean?,
)
/**
* Retrieves the preference indicating whether the TOTP code should be automatically copied to
* the clipboard for autofill suggestions associated with the specified [userId].
*/
fun getAutoCopyTotpDisabled(userId: String): Boolean?
/**
* Stores the given [isAutomaticallyCopyTotpDisabled] for the given [userId].
*/
fun storeAutoCopyTotpDisabled(
userId: String,
isAutomaticallyCopyTotpDisabled: Boolean?,
)
/**
* Gets the last time the app synced the vault data for a given [userId] (or `null` if the
* vault has never been synced).

View file

@ -24,6 +24,7 @@ private const val VAULT_LAST_SYNC_TIME = "$BASE_KEY:vaultLastSyncTime"
private const val VAULT_TIMEOUT_ACTION_KEY = "$BASE_KEY:vaultTimeoutAction"
private const val VAULT_TIME_IN_MINUTES_KEY = "$BASE_KEY:vaultTimeout"
private const val DEFAULT_URI_MATCH_TYPE_KEY = "$BASE_KEY:defaultUriMatch"
private const val DISABLE_AUTO_TOTP_COPY_KEY = "$BASE_KEY:disableAutoTotpCopy"
private const val DISABLE_AUTOFILL_SAVE_PROMPT_KEY = "$BASE_KEY:autofillDisableSavePrompt"
private const val DISABLE_ICON_LOADING_KEY = "$BASE_KEY:disableFavicon"
private const val APPROVE_PASSWORDLESS_LOGINS_KEY = "$BASE_KEY:approvePasswordlessLogins"
@ -137,6 +138,7 @@ class SettingsDiskSourceImpl(
storeVaultTimeoutInMinutes(userId = userId, vaultTimeoutInMinutes = null)
storeVaultTimeoutAction(userId = userId, vaultTimeoutAction = null)
storeDefaultUriMatchType(userId = userId, uriMatchType = null)
storeAutoCopyTotpDisabled(userId = userId, isAutomaticallyCopyTotpDisabled = null)
storeAutofillSavePromptDisabled(userId = userId, isAutofillSavePromptDisabled = null)
storePullToRefreshEnabled(userId = userId, isPullToRefreshEnabled = null)
storeInlineAutofillEnabled(userId = userId, isInlineAutofillEnabled = null)
@ -170,6 +172,19 @@ class SettingsDiskSourceImpl(
)
}
override fun getAutoCopyTotpDisabled(userId: String): Boolean? =
getBoolean(key = "${DISABLE_AUTO_TOTP_COPY_KEY}_$userId")
override fun storeAutoCopyTotpDisabled(
userId: String,
isAutomaticallyCopyTotpDisabled: Boolean?,
) {
putBoolean(
key = "${DISABLE_AUTO_TOTP_COPY_KEY}_$userId",
value = isAutomaticallyCopyTotpDisabled,
)
}
override fun getLastSyncTime(userId: String): Instant? =
getLong(key = "${VAULT_LAST_SYNC_TIME}_$userId")?.let { Instant.ofEpochMilli(it) }

View file

@ -103,6 +103,11 @@ interface SettingsRepository {
*/
var isInlineAutofillEnabled: Boolean
/**
* Whether or not the auto copying totp when autofilling is disabled for the current user.
*/
var isAutoCopyTotpDisabled: Boolean
/**
* Whether or not the autofill save prompt is disabled for the current user.
*/

View file

@ -201,6 +201,18 @@ class SettingsRepositoryImpl(
)
}
override var isAutoCopyTotpDisabled: Boolean
get() = activeUserId
?.let { settingsDiskSource.getAutoCopyTotpDisabled(userId = it) }
?: false
set(value) {
val userId = activeUserId ?: return
settingsDiskSource.storeAutoCopyTotpDisabled(
userId = userId,
isAutomaticallyCopyTotpDisabled = value,
)
}
override var isAutofillSavePromptDisabled: Boolean
get() = activeUserId
?.let { settingsDiskSource.getAutofillSavePromptDisabled(userId = it) }

View file

@ -7,7 +7,6 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
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.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@ -31,7 +30,7 @@ class AutoFillViewModel @Inject constructor(
?: AutoFillState(
isAskToAddLoginEnabled = !settingsRepository.isAutofillSavePromptDisabled,
isAutoFillServicesEnabled = settingsRepository.isAutofillEnabledStateFlow.value,
isCopyTotpAutomaticallyEnabled = false,
isCopyTotpAutomaticallyEnabled = !settingsRepository.isAutoCopyTotpDisabled,
isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled,
defaultUriMatchType = settingsRepository.defaultUriMatchType,
),
@ -84,8 +83,7 @@ class AutoFillViewModel @Inject constructor(
private fun handleCopyTotpAutomaticallyClick(
action: AutoFillAction.CopyTotpAutomaticallyClick,
) {
// TODO BIT-1093: Persist selection
sendEvent(AutoFillEvent.ShowToast("Not yet implemented.".asText()))
settingsRepository.isAutoCopyTotpDisabled = !action.isEnabled
mutableStateFlow.update { it.copy(isCopyTotpAutomaticallyEnabled = action.isEnabled) }
}

View file

@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.autofill.util.getAutofillAssistStructureOrNull
import com.x8bit.bitwarden.data.autofill.util.toAutofillAppInfo
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import io.mockk.coEvery
@ -63,6 +64,7 @@ class AutofillCompletionManagerTest {
private val filledDataBuilder: FilledDataBuilder = mockk()
private val filledPartition: FilledPartition = mockk()
private val mockIntent: Intent = mockk()
private val settingsRepository: SettingsRepository = mockk()
private val resultIntent: Intent = mockk()
private val toast: Toast = mockk {
every { show() } just runs
@ -76,6 +78,7 @@ class AutofillCompletionManagerTest {
clipboardManager = clipboardManager,
dispatcherManager = dispatcherManager,
filledDataBuilderProvider = { filledDataBuilder },
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
)
@ -239,6 +242,7 @@ class AutofillCompletionManagerTest {
autofillAppInfo = autofillAppInfo,
)
} returns dataset
every { settingsRepository.isAutoCopyTotpDisabled } returns false
every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent
coEvery {
vaultRepository.generateTotp(
@ -278,6 +282,7 @@ class AutofillCompletionManagerTest {
authIntentSender = null,
autofillAppInfo = autofillAppInfo,
)
settingsRepository.isAutoCopyTotpDisabled
createAutofillSelectionResultIntent(dataset = dataset)
Toast.makeText(
context,
@ -319,6 +324,7 @@ class AutofillCompletionManagerTest {
autofillAppInfo = autofillAppInfo,
)
} returns dataset
every { settingsRepository.isAutoCopyTotpDisabled } returns false
every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent
coEvery {
vaultRepository.generateTotp(
@ -350,6 +356,7 @@ class AutofillCompletionManagerTest {
authIntentSender = null,
autofillAppInfo = autofillAppInfo,
)
settingsRepository.isAutoCopyTotpDisabled
createAutofillSelectionResultIntent(dataset = dataset)
}
coVerify {
@ -385,6 +392,7 @@ class AutofillCompletionManagerTest {
autofillAppInfo = autofillAppInfo,
)
} returns dataset
every { settingsRepository.isAutoCopyTotpDisabled } returns false
every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent
mutableUserStateFlow.value = mockk {
every { activeAccount.isPremium } returns true
@ -400,6 +408,7 @@ class AutofillCompletionManagerTest {
activity.finish()
}
verify {
settingsRepository.isAutoCopyTotpDisabled
activity.intent
mockIntent.getAutofillAssistStructureOrNull()
autofillParser.parse(
@ -410,6 +419,7 @@ class AutofillCompletionManagerTest {
authIntentSender = null,
autofillAppInfo = autofillAppInfo,
)
settingsRepository.isAutoCopyTotpDisabled
createAutofillSelectionResultIntent(dataset = dataset)
}
coVerify {
@ -441,6 +451,7 @@ class AutofillCompletionManagerTest {
autofillAppInfo = autofillAppInfo,
)
} returns dataset
every { settingsRepository.isAutoCopyTotpDisabled } returns false
every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent
mutableUserStateFlow.value = mockk {
every { activeAccount.isPremium } returns false
@ -466,6 +477,65 @@ class AutofillCompletionManagerTest {
authIntentSender = null,
autofillAppInfo = autofillAppInfo,
)
settingsRepository.isAutoCopyTotpDisabled
createAutofillSelectionResultIntent(dataset = dataset)
}
coVerify {
filledDataBuilder.build(autofillRequest = fillableRequest)
}
}
@Suppress("MaxLineLength")
@Test
fun `completeAutofill when filled partition and totp copy disabled should build a dataset, place it in a result Intent, and finish the Activity`() {
val filledData: FilledData = mockk {
every { filledPartitions } returns listOf(filledPartition)
}
every { activity.intent } returns mockIntent
every { mockIntent.getAutofillAssistStructureOrNull() } returns assistStructure
every {
autofillParser.parse(
autofillAppInfo = autofillAppInfo,
assistStructure = assistStructure,
)
} returns fillableRequest
every { cipherView.login?.totp } returns TOTP_CODE
coEvery {
filledDataBuilder.build(autofillRequest = fillableRequest)
} returns filledData
every {
filledPartition.buildDataset(
authIntentSender = null,
autofillAppInfo = autofillAppInfo,
)
} returns dataset
every { settingsRepository.isAutoCopyTotpDisabled } returns true
every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent
mutableUserStateFlow.value = mockk {
every { activeAccount.isPremium } returns true
}
autofillCompletionManager.completeAutofill(
activity = activity,
cipherView = cipherView,
)
verify {
activity.setResult(Activity.RESULT_OK, resultIntent)
activity.finish()
}
verify {
activity.intent
mockIntent.getAutofillAssistStructureOrNull()
autofillParser.parse(
autofillAppInfo = autofillAppInfo,
assistStructure = assistStructure,
)
filledPartition.buildDataset(
authIntentSender = null,
autofillAppInfo = autofillAppInfo,
)
settingsRepository.isAutoCopyTotpDisabled
createAutofillSelectionResultIntent(dataset = dataset)
}
coVerify {

View file

@ -107,6 +107,10 @@ class SettingsDiskSourceTest {
userId = userId,
uriMatchType = UriMatchType.REGULAR_EXPRESSION,
)
settingsDiskSource.storeAutoCopyTotpDisabled(
userId = userId,
isAutomaticallyCopyTotpDisabled = true,
)
settingsDiskSource.storeAutofillSavePromptDisabled(
userId = userId,
isAutofillSavePromptDisabled = true,
@ -147,6 +151,7 @@ class SettingsDiskSourceTest {
assertNull(settingsDiskSource.getVaultTimeoutInMinutes(userId = userId))
assertNull(settingsDiskSource.getVaultTimeoutAction(userId = userId))
assertNull(settingsDiskSource.getDefaultUriMatchType(userId = userId))
assertNull(settingsDiskSource.getAutoCopyTotpDisabled(userId = userId))
assertNull(settingsDiskSource.getAutofillSavePromptDisabled(userId = userId))
assertNull(settingsDiskSource.getPullToRefreshEnabled(userId = userId))
assertNull(settingsDiskSource.getInlineAutofillEnabled(userId = userId))
@ -561,6 +566,50 @@ class SettingsDiskSourceTest {
assertFalse(fakeSharedPreferences.contains(defaultUriMatchTypeKey))
}
@Suppress("MaxLineLength")
@Test
fun `getAutoCopyTotpDisabled when values are present should pull from SharedPreferences`() {
val disableAutoTotpCopyBaseKey = "bwPreferencesStorage:disableAutoTotpCopy"
val mockUserId = "mockUserId"
val disableAutoTotpCopyKey = "${disableAutoTotpCopyBaseKey}_$mockUserId"
fakeSharedPreferences
.edit {
putBoolean(disableAutoTotpCopyKey, true)
}
assertEquals(true, settingsDiskSource.getAutoCopyTotpDisabled(userId = mockUserId))
}
@Test
fun `getAutoCopyTotpDisabled when values are absent should return null`() {
val mockUserId = "mockUserId"
assertNull(settingsDiskSource.getAutoCopyTotpDisabled(userId = mockUserId))
}
@Test
fun `storeAutoCopyTotpDisabled for non-null values should update SharedPreferences`() {
val disableAutoTotpCopyBaseKey = "bwPreferencesStorage:disableAutoTotpCopy"
val mockUserId = "mockUserId"
val disableAutoTotpCopyKey = "${disableAutoTotpCopyBaseKey}_$mockUserId"
settingsDiskSource.storeAutoCopyTotpDisabled(
userId = mockUserId,
isAutomaticallyCopyTotpDisabled = true,
)
assertTrue(fakeSharedPreferences.getBoolean(disableAutoTotpCopyKey, false))
}
@Test
fun `storeAutoCopyTotpDisabled for null values should clear SharedPreferences`() {
val disableAutoTotpCopyBaseKey = "bwPreferencesStorage:disableAutoTotpCopy"
val mockUserId = "mockUserId"
val disableAutoTotpCopyKey = "${disableAutoTotpCopyBaseKey}_$mockUserId"
fakeSharedPreferences.edit { putBoolean(disableAutoTotpCopyKey, false) }
settingsDiskSource.storeAutoCopyTotpDisabled(
userId = mockUserId,
isAutomaticallyCopyTotpDisabled = null,
)
assertFalse(fakeSharedPreferences.contains(disableAutoTotpCopyKey))
}
@Suppress("MaxLineLength")
@Test
fun `getAutofillSavePromptDisabled when values are present should pull from SharedPreferences`() {

View file

@ -45,6 +45,7 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val storedVaultTimeoutInMinutes = mutableMapOf<String, Int?>()
private val storedUriMatchTypes = mutableMapOf<String, UriMatchType?>()
private val storedClearClipboardFrequency = mutableMapOf<String, Int?>()
private val storedDisableAutoTotpCopy = mutableMapOf<String, Boolean?>()
private val storedDisableAutofillSavePrompt = mutableMapOf<String, Boolean?>()
private val storedPullToRefreshEnabled = mutableMapOf<String, Boolean?>()
private val storedInlineAutofillEnabled = mutableMapOf<String, Boolean?>()
@ -124,6 +125,7 @@ class FakeSettingsDiskSource : SettingsDiskSource {
storedVaultTimeoutActions.remove(userId)
storedVaultTimeoutInMinutes.remove(userId)
storedUriMatchTypes.remove(userId)
storedDisableAutoTotpCopy.remove(userId)
storedDisableAutofillSavePrompt.remove(userId)
storedPullToRefreshEnabled.remove(userId)
storedInlineAutofillEnabled.remove(userId)
@ -194,6 +196,16 @@ class FakeSettingsDiskSource : SettingsDiskSource {
storedUriMatchTypes[userId] = uriMatchType
}
override fun getAutoCopyTotpDisabled(userId: String): Boolean? =
storedDisableAutoTotpCopy[userId]
override fun storeAutoCopyTotpDisabled(
userId: String,
isAutomaticallyCopyTotpDisabled: Boolean?,
) {
storedDisableAutoTotpCopy[userId] = isAutomaticallyCopyTotpDisabled
}
override fun getAutofillSavePromptDisabled(userId: String): Boolean? =
storedDisableAutofillSavePrompt[userId]

View file

@ -473,6 +473,24 @@ class SettingsRepositoryTest {
assertTrue(fakeSettingsDiskSource.getInlineAutofillEnabled(userId = userId)!!)
}
@Test
fun `isAutoCopyTotpDisabled should pull from and update SettingsDiskSource`() {
val userId = "userId"
fakeAuthDiskSource.userState = MOCK_USER_STATE
assertFalse(settingsRepository.isAutoCopyTotpDisabled)
// Updates to the disk source change the repository value.
fakeSettingsDiskSource.storeAutoCopyTotpDisabled(
userId = userId,
isAutomaticallyCopyTotpDisabled = true,
)
assertTrue(settingsRepository.isAutoCopyTotpDisabled)
// Updates to the repository change the disk source value
settingsRepository.isAutoCopyTotpDisabled = false
assertFalse(fakeSettingsDiskSource.getAutoCopyTotpDisabled(userId = userId)!!)
}
@Test
fun `isAutofillSavePromptDisabled should pull from and update SettingsDiskSource`() {
val userId = "userId"

View file

@ -5,7 +5,6 @@ import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -22,6 +21,8 @@ class AutoFillViewModelTest : BaseViewModelTest() {
private val settingsRepository: SettingsRepository = mockk() {
every { isInlineAutofillEnabled } returns true
every { isInlineAutofillEnabled = any() } just runs
every { isAutoCopyTotpDisabled } returns true
every { isAutoCopyTotpDisabled = any() } just runs
every { isAutofillSavePromptDisabled } returns true
every { isAutofillSavePromptDisabled = any() } just runs
every { defaultUriMatchType } returns UriMatchType.DOMAIN
@ -117,19 +118,23 @@ class AutoFillViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `on CopyTotpAutomaticallyClick should update the isCopyTotpAutomaticallyEnabled state`() =
fun `on CopyTotpAutomaticallyClick should update the isCopyTotpAutomaticallyEnabled state and save new value to settings`() =
runTest {
val viewModel = createViewModel()
val isEnabled = true
viewModel.trySendAction(AutoFillAction.CopyTotpAutomaticallyClick(isEnabled))
viewModel.eventFlow.test {
viewModel.trySendAction(AutoFillAction.CopyTotpAutomaticallyClick(isEnabled))
assertEquals(AutoFillEvent.ShowToast("Not yet implemented.".asText()), awaitItem())
expectNoEvents()
}
assertEquals(
DEFAULT_STATE.copy(isCopyTotpAutomaticallyEnabled = isEnabled),
viewModel.stateFlow.value,
)
// The UI enables the value, so the value gets flipped to save it as a "disabled" value.
verify { settingsRepository.isAutoCopyTotpDisabled = !isEnabled }
}
@Test