diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt index 77f989ad6..a9309a3b9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt @@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManagerImpl import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManagerImpl +import com.x8bit.bitwarden.data.autofill.manager.AutofillTotpManager +import com.x8bit.bitwarden.data.autofill.manager.AutofillTotpManagerImpl import com.x8bit.bitwarden.data.autofill.parser.AutofillParser import com.x8bit.bitwarden.data.autofill.parser.AutofillParserImpl import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor @@ -32,6 +34,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import java.time.Clock import javax.inject.Singleton /** @@ -56,21 +59,15 @@ object AutofillModule { @Provides fun provideAutofillCompletionManager( autofillParser: AutofillParser, - authRepository: AuthRepository, - clipboardManager: BitwardenClipboardManager, dispatcherManager: DispatcherManager, - settingsRepository: SettingsRepository, - vaultRepository: VaultRepository, organizationEventManager: OrganizationEventManager, + totpManager: AutofillTotpManager, ): AutofillCompletionManager = AutofillCompletionManagerImpl( - authRepository = authRepository, autofillParser = autofillParser, - clipboardManager = clipboardManager, dispatcherManager = dispatcherManager, - settingsRepository = settingsRepository, - vaultRepository = vaultRepository, organizationEventManager = organizationEventManager, + totpManager = totpManager, ) @Singleton @@ -82,6 +79,25 @@ object AutofillModule { settingsRepository = settingsRepository, ) + @Singleton + @Provides + fun providesAutofillTotpManager( + @ApplicationContext context: Context, + clock: Clock, + clipboardManager: BitwardenClipboardManager, + authRepository: AuthRepository, + settingsRepository: SettingsRepository, + vaultRepository: VaultRepository, + ): AutofillTotpManager = + AutofillTotpManagerImpl( + context = context, + clock = clock, + clipboardManager = clipboardManager, + authRepository = authRepository, + settingsRepository = settingsRepository, + vaultRepository = vaultRepository, + ) + @Singleton @Provides fun providesAutofillCipherProvider( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillCompletionManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillCompletionManagerImpl.kt index 81baecadf..cac0cb37f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillCompletionManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillCompletionManagerImpl.kt @@ -2,11 +2,7 @@ package com.x8bit.bitwarden.data.autofill.manager import android.app.Activity import android.content.Intent -import android.widget.Toast -import com.bitwarden.core.DateTime import com.bitwarden.vault.CipherView -import com.x8bit.bitwarden.R -import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilderImpl import com.x8bit.bitwarden.data.autofill.model.AutofillRequest @@ -16,13 +12,9 @@ import com.x8bit.bitwarden.data.autofill.util.createAutofillSelectionResultInten import com.x8bit.bitwarden.data.autofill.util.getAutofillAssistStructureOrNull 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.manager.event.OrganizationEventManager import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent -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 import kotlinx.coroutines.launch @@ -31,15 +23,12 @@ import kotlinx.coroutines.launch */ @Suppress("LongParameterList") class AutofillCompletionManagerImpl( - private val authRepository: AuthRepository, private val autofillParser: AutofillParser, - private val clipboardManager: BitwardenClipboardManager, dispatcherManager: DispatcherManager, private val filledDataBuilderProvider: (CipherView) -> FilledDataBuilder = { createSingleItemFilledDataBuilder(cipherView = it) }, - private val settingsRepository: SettingsRepository, - private val vaultRepository: VaultRepository, private val organizationEventManager: OrganizationEventManager, + private val totpManager: AutofillTotpManager, ) : AutofillCompletionManager { private val mainScope = CoroutineScope(dispatcherManager.main) @@ -82,10 +71,7 @@ class AutofillCompletionManagerImpl( activity.cancelAndFinish() return@launch } - tryCopyTotpToClipboard( - activity = activity, - cipherView = cipherView, - ) + totpManager.tryCopyTotpToClipboard(cipherView = cipherView) val resultIntent = createAutofillSelectionResultIntent(dataset) activity.setResultAndFinish(resultIntent = resultIntent) cipherView.id?.let { @@ -95,40 +81,6 @@ class AutofillCompletionManagerImpl( } } } - - /** - * Attempt to copy the totp code to clipboard. If it succeeds show a toast. - * - * @param activity An activity for launching a toast. - * @param cipherView The [CipherView] for which to generate a TOTP code. - */ - private suspend fun tryCopyTotpToClipboard( - activity: Activity, - cipherView: CipherView, - ) { - val isPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true - val totpAvailableViaPremiumOrOrganization = isPremium || cipherView.organizationUseTotp - val totpCode = cipherView.login?.totp - val isTotpDisabled = settingsRepository.isAutoCopyTotpDisabled - - if (!isTotpDisabled && totpAvailableViaPremiumOrOrganization && totpCode != null) { - val totpResult = vaultRepository.generateTotp( - time = DateTime.now(), - totpCode = totpCode, - ) - - if (totpResult is GenerateTotpResult.Success) { - clipboardManager.setText(totpResult.code) - Toast - .makeText( - activity.applicationContext, - R.string.verification_code_totp, - Toast.LENGTH_LONG, - ) - .show() - } - } - } } private fun createSingleItemFilledDataBuilder( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillTotpManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillTotpManager.kt new file mode 100644 index 000000000..75c07ab59 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillTotpManager.kt @@ -0,0 +1,13 @@ +package com.x8bit.bitwarden.data.autofill.manager + +import com.bitwarden.vault.CipherView + +/** + * Manages copying the totp code to the clipboard for autofill. + */ +interface AutofillTotpManager { + /** + * Attempt to copy the totp code to clipboard. If it succeeds show a toast. + */ + suspend fun tryCopyTotpToClipboard(cipherView: CipherView) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillTotpManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillTotpManagerImpl.kt new file mode 100644 index 000000000..5fff0e1f9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/manager/AutofillTotpManagerImpl.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.data.autofill.manager + +import android.content.Context +import android.widget.Toast +import com.bitwarden.vault.CipherView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +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 java.time.Clock + +/** + * Default implementation of the [AutofillTotpManager]. + */ +class AutofillTotpManagerImpl( + private val context: Context, + private val clock: Clock, + private val clipboardManager: BitwardenClipboardManager, + private val authRepository: AuthRepository, + private val settingsRepository: SettingsRepository, + private val vaultRepository: VaultRepository, +) : AutofillTotpManager { + override suspend fun tryCopyTotpToClipboard(cipherView: CipherView) { + if (settingsRepository.isAutoCopyTotpDisabled) return + val isPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true + if (!isPremium && !cipherView.organizationUseTotp) return + val totpCode = cipherView.login?.totp ?: return + + val totpResult = vaultRepository.generateTotp( + time = clock.instant(), + totpCode = totpCode, + ) + + if (totpResult is GenerateTotpResult.Success) { + clipboardManager.setText(text = totpResult.code) + Toast + .makeText( + context.applicationContext, + R.string.verification_code_totp, + Toast.LENGTH_LONG, + ) + .show() + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillCompletionManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillCompletionManagerTest.kt index 582933344..6b3c1dc53 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillCompletionManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillCompletionManagerTest.kt @@ -5,11 +5,7 @@ import android.app.assist.AssistStructure import android.content.Context import android.content.Intent import android.service.autofill.Dataset -import android.widget.Toast import com.bitwarden.vault.CipherView -import com.x8bit.bitwarden.R -import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillRequest @@ -21,12 +17,8 @@ import com.x8bit.bitwarden.data.autofill.util.createAutofillSelectionResultInten 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.manager.event.OrganizationEventManager import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent -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 import io.mockk.coVerify import io.mockk.every @@ -36,7 +28,6 @@ import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkStatic import io.mockk.verify -import kotlinx.coroutines.flow.MutableStateFlow import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -50,44 +41,32 @@ class AutofillCompletionManagerTest { every { setResult(any(), any()) } just runs } private val assistStructure: AssistStructure = mockk() - private val mutableUserStateFlow = MutableStateFlow(null) - private val authRepository: AuthRepository = mockk { - every { userStateFlow } returns mutableUserStateFlow - } private val autofillAppInfo: AutofillAppInfo = mockk() private val autofillParser: AutofillParser = mockk() private val cipherView: CipherView = mockk { every { id } returns "cipherId" } - private val clipboardManager: BitwardenClipboardManager = mockk { - every { setText(any()) } just runs - } private val dataset: Dataset = mockk() private val dispatcherManager = FakeDispatcherManager() private val fillableRequest: AutofillRequest.Fillable = mockk() 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 - } - private val vaultRepository: VaultRepository = mockk() private val organizationEventManager = mockk { every { trackEvent(event = any()) } just runs } + private val totpManager: AutofillTotpManager = mockk { + coEvery { tryCopyTotpToClipboard(cipherView = cipherView) } just runs + } private val autofillCompletionManager: AutofillCompletionManager = AutofillCompletionManagerImpl( - authRepository = authRepository, autofillParser = autofillParser, - clipboardManager = clipboardManager, dispatcherManager = dispatcherManager, filledDataBuilderProvider = { filledDataBuilder }, - settingsRepository = settingsRepository, - vaultRepository = vaultRepository, organizationEventManager = organizationEventManager, + totpManager = totpManager, ) @BeforeEach @@ -97,7 +76,6 @@ class AutofillCompletionManagerTest { mockkStatic(Activity::toAutofillAppInfo) mockkStatic(FilledPartition::buildDataset) mockkStatic(Intent::getAutofillAssistStructureOrNull) - mockkStatic(Toast::class) every { activity.toAutofillAppInfo() } returns autofillAppInfo } @@ -108,10 +86,8 @@ class AutofillCompletionManagerTest { unmockkStatic(Activity::toAutofillAppInfo) unmockkStatic(FilledPartition::buildDataset) unmockkStatic(Intent::getAutofillAssistStructureOrNull) - unmockkStatic(Toast::class) } - @Suppress("MaxLineLength") @Test fun `completeAutofill when there is no Intent present should cancel and finish the Activity`() { every { activity.intent } returns null @@ -124,8 +100,6 @@ class AutofillCompletionManagerTest { verify { activity.setResult(Activity.RESULT_CANCELED) activity.finish() - } - verify { activity.intent } } @@ -144,8 +118,6 @@ class AutofillCompletionManagerTest { verify { activity.setResult(Activity.RESULT_CANCELED) activity.finish() - } - verify { activity.intent mockIntent.getAutofillAssistStructureOrNull() } @@ -171,8 +143,6 @@ class AutofillCompletionManagerTest { verify { activity.setResult(Activity.RESULT_CANCELED) activity.finish() - } - verify { activity.intent mockIntent.getAutofillAssistStructureOrNull() autofillParser.parse( @@ -208,8 +178,6 @@ class AutofillCompletionManagerTest { verify { activity.setResult(Activity.RESULT_CANCELED) activity.finish() - } - verify { activity.intent mockIntent.getAutofillAssistStructureOrNull() autofillParser.parse( @@ -224,182 +192,7 @@ class AutofillCompletionManagerTest { @Suppress("MaxLineLength") @Test - fun `completeAutofill when filled partition, premium active user, a totp code, and totp generated successfully should build a dataset, place it in a result Intent, copy totp, and finish the Activity`() { - val filledData: FilledData = mockk { - every { filledPartitions } returns listOf(filledPartition) - } - val generateTotpResult = GenerateTotpResult.Success( - code = TOTP_RESULT_VALUE, - periodSeconds = 100, - ) - 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 false - every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent - coEvery { - vaultRepository.generateTotp( - time = any(), - totpCode = TOTP_CODE, - ) - } returns generateTotpResult - every { - Toast.makeText( - context, - R.string.verification_code_totp, - Toast.LENGTH_LONG, - ) - } returns toast - mutableUserStateFlow.value = mockk { - every { activeAccount.isPremium } returns true - } - - autofillCompletionManager.completeAutofill( - activity = activity, - cipherView = cipherView, - ) - - verify { - activity.setResult(Activity.RESULT_OK, resultIntent) - activity.finish() - activity.intent - clipboardManager.setText(any()) - mockIntent.getAutofillAssistStructureOrNull() - autofillParser.parse( - autofillAppInfo = autofillAppInfo, - assistStructure = assistStructure, - ) - filledPartition.buildDataset( - authIntentSender = null, - autofillAppInfo = autofillAppInfo, - ) - settingsRepository.isAutoCopyTotpDisabled - createAutofillSelectionResultIntent(dataset = dataset) - Toast.makeText( - context, - R.string.verification_code_totp, - Toast.LENGTH_LONG, - ) - toast.show() - organizationEventManager.trackEvent( - event = OrganizationEvent.CipherClientAutoFilled(cipherId = "cipherId"), - ) - } - coVerify { - filledDataBuilder.build(autofillRequest = fillableRequest) - vaultRepository.generateTotp( - time = any(), - totpCode = TOTP_CODE, - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `completeAutofill when filled partition, organization uses totp, a totp code, and totp generated successfully should build a dataset, place it in a result Intent, copy totp, and finish the Activity`() { - val filledData: FilledData = mockk { - every { filledPartitions } returns listOf(filledPartition) - } - val generateTotpResult = GenerateTotpResult.Success( - code = TOTP_RESULT_VALUE, - periodSeconds = 100, - ) - 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 - every { cipherView.organizationUseTotp } returns true - mutableUserStateFlow.value = mockk { - every { activeAccount.isPremium } returns false - } - coEvery { - filledDataBuilder.build(autofillRequest = fillableRequest) - } returns filledData - every { - filledPartition.buildDataset( - authIntentSender = null, - autofillAppInfo = autofillAppInfo, - ) - } returns dataset - every { settingsRepository.isAutoCopyTotpDisabled } returns false - every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent - coEvery { - vaultRepository.generateTotp( - time = any(), - totpCode = TOTP_CODE, - ) - } returns generateTotpResult - every { - Toast.makeText( - context, - R.string.verification_code_totp, - Toast.LENGTH_LONG, - ) - } returns toast - - autofillCompletionManager.completeAutofill( - activity = activity, - cipherView = cipherView, - ) - - verify { - activity.setResult(Activity.RESULT_OK, resultIntent) - activity.finish() - activity.intent - clipboardManager.setText(any()) - mockIntent.getAutofillAssistStructureOrNull() - autofillParser.parse( - autofillAppInfo = autofillAppInfo, - assistStructure = assistStructure, - ) - filledPartition.buildDataset( - authIntentSender = null, - autofillAppInfo = autofillAppInfo, - ) - settingsRepository.isAutoCopyTotpDisabled - createAutofillSelectionResultIntent(dataset = dataset) - Toast.makeText( - context, - R.string.verification_code_totp, - Toast.LENGTH_LONG, - ) - toast.show() - organizationEventManager.trackEvent( - event = OrganizationEvent.CipherClientAutoFilled(cipherId = "cipherId"), - ) - } - coVerify { - filledDataBuilder.build(autofillRequest = fillableRequest) - vaultRepository.generateTotp( - time = any(), - totpCode = TOTP_CODE, - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `completeAutofill when filled partition, premium active user, a totp code, and totp generated unsuccessfully should build a dataset, place it in a result Intent, and finish the Activity`() { + fun `completeAutofill when filled partition and totp generated successfully should attempt to copy totp to clipboard, build a dataset, place it in a result Intent and finish the Activity`() { val filledData: FilledData = mockk { every { filledPartitions } returns listOf(filledPartition) } @@ -411,7 +204,6 @@ class AutofillCompletionManagerTest { assistStructure = assistStructure, ) } returns fillableRequest - every { cipherView.login?.totp } returns TOTP_CODE coEvery { filledDataBuilder.build(autofillRequest = fillableRequest) } returns filledData @@ -421,17 +213,7 @@ class AutofillCompletionManagerTest { autofillAppInfo = autofillAppInfo, ) } returns dataset - every { settingsRepository.isAutoCopyTotpDisabled } returns false every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent - coEvery { - vaultRepository.generateTotp( - time = any(), - totpCode = TOTP_CODE, - ) - } returns GenerateTotpResult.Error - mutableUserStateFlow.value = mockk { - every { activeAccount.isPremium } returns true - } autofillCompletionManager.completeAutofill( activity = activity, @@ -441,8 +223,6 @@ class AutofillCompletionManagerTest { verify { activity.setResult(Activity.RESULT_OK, resultIntent) activity.finish() - } - verify { activity.intent mockIntent.getAutofillAssistStructureOrNull() autofillParser.parse( @@ -453,197 +233,6 @@ class AutofillCompletionManagerTest { authIntentSender = null, autofillAppInfo = autofillAppInfo, ) - settingsRepository.isAutoCopyTotpDisabled - createAutofillSelectionResultIntent(dataset = dataset) - organizationEventManager.trackEvent( - event = OrganizationEvent.CipherClientAutoFilled(cipherId = "cipherId"), - ) - } - coVerify { - filledDataBuilder.build(autofillRequest = fillableRequest) - vaultRepository.generateTotp( - time = any(), - totpCode = TOTP_CODE, - ) - } - } - - @Suppress("MaxLineLength") - @Test - fun `completeAutofill when filled partition, premium active user, and no totp code 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 null - coEvery { - filledDataBuilder.build(autofillRequest = fillableRequest) - } returns filledData - every { - filledPartition.buildDataset( - authIntentSender = null, - autofillAppInfo = autofillAppInfo, - ) - } returns dataset - every { settingsRepository.isAutoCopyTotpDisabled } returns false - 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 { - settingsRepository.isAutoCopyTotpDisabled - activity.intent - mockIntent.getAutofillAssistStructureOrNull() - autofillParser.parse( - autofillAppInfo = autofillAppInfo, - assistStructure = assistStructure, - ) - filledPartition.buildDataset( - authIntentSender = null, - autofillAppInfo = autofillAppInfo, - ) - settingsRepository.isAutoCopyTotpDisabled - createAutofillSelectionResultIntent(dataset = dataset) - organizationEventManager.trackEvent( - event = OrganizationEvent.CipherClientAutoFilled(cipherId = "cipherId"), - ) - } - coVerify { - filledDataBuilder.build(autofillRequest = fillableRequest) - } - } - - @Suppress("MaxLineLength") - @Test - fun `completeAutofill when filled partition, no premium active user, organization does not use totp, and totp code 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 - every { cipherView.organizationUseTotp } returns false - coEvery { - filledDataBuilder.build(autofillRequest = fillableRequest) - } returns filledData - every { - filledPartition.buildDataset( - authIntentSender = null, - autofillAppInfo = autofillAppInfo, - ) - } returns dataset - every { settingsRepository.isAutoCopyTotpDisabled } returns false - every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent - mutableUserStateFlow.value = mockk { - every { activeAccount.isPremium } returns false - } - - 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, - ) - cipherView.organizationUseTotp - settingsRepository.isAutoCopyTotpDisabled - createAutofillSelectionResultIntent(dataset = dataset) - organizationEventManager.trackEvent( - event = OrganizationEvent.CipherClientAutoFilled(cipherId = "cipherId"), - ) - } - 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) organizationEventManager.trackEvent( event = OrganizationEvent.CipherClientAutoFilled(cipherId = "cipherId"), @@ -651,9 +240,7 @@ class AutofillCompletionManagerTest { } coVerify { filledDataBuilder.build(autofillRequest = fillableRequest) + totpManager.tryCopyTotpToClipboard(cipherView = cipherView) } } } - -private const val TOTP_CODE: String = "TOTP_CODE" -private const val TOTP_RESULT_VALUE: String = "TOTP_RESULT_VALUE" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillTotpManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillTotpManagerTest.kt new file mode 100644 index 000000000..3a1c2f4fd --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/manager/AutofillTotpManagerTest.kt @@ -0,0 +1,171 @@ +package com.x8bit.bitwarden.data.autofill.manager + +import android.content.Context +import android.widget.Toast +import com.bitwarden.vault.CipherView +import com.bitwarden.vault.LoginView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.UserState +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 +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class AutofillTotpManagerTest { + private val context: Context = mockk { + every { applicationContext } returns this + } + private val toast: Toast = mockk { + every { show() } just runs + } + private val loginView: LoginView = mockk() + private val cipherView: CipherView = mockk { + every { id } returns "cipherId" + every { login } returns loginView + } + private val mutableUserStateFlow = MutableStateFlow(value = null) + private val authRepository: AuthRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } + private val clipboardManager: BitwardenClipboardManager = mockk { + every { setText(any()) } just runs + } + private val settingsRepository: SettingsRepository = mockk() + private val vaultRepository: VaultRepository = mockk() + + private val autofillTotpManager: AutofillTotpManager = AutofillTotpManagerImpl( + context = context, + clock = FIXED_CLOCK, + clipboardManager = clipboardManager, + authRepository = authRepository, + settingsRepository = settingsRepository, + vaultRepository = vaultRepository, + ) + + @BeforeEach + fun setup() { + mockkStatic(Toast::class) + every { + Toast.makeText(context, R.string.verification_code_totp, Toast.LENGTH_LONG) + } returns toast + } + + @AfterEach + fun tearDown() { + unmockkStatic(Toast::class) + } + + @Test + fun `tryCopyTotpToClipboard when isAutoCopyTotpDisabled is true should do nothing`() = runTest { + every { settingsRepository.isAutoCopyTotpDisabled } returns true + + autofillTotpManager.tryCopyTotpToClipboard(cipherView = cipherView) + + verify(exactly = 1) { + settingsRepository.isAutoCopyTotpDisabled + } + verify(exactly = 0) { + clipboardManager.setText(any()) + toast.show() + } + } + + @Suppress("MaxLineLength") + @Test + fun `tryCopyTotpToClipboard when isAutoCopyTotpDisabled is false, user not premium and the organization does not use totp should do nothing`() = + runTest { + every { settingsRepository.isAutoCopyTotpDisabled } returns false + every { cipherView.organizationUseTotp } returns false + mutableUserStateFlow.value = mockk { + every { activeAccount.isPremium } returns false + } + + autofillTotpManager.tryCopyTotpToClipboard(cipherView = cipherView) + + verify(exactly = 0) { + clipboardManager.setText(any()) + toast.show() + } + verify(exactly = 1) { + settingsRepository.isAutoCopyTotpDisabled + } + } + + @Suppress("MaxLineLength") + @Test + fun `tryCopyTotpToClipboard when isAutoCopyTotpDisabled is false, has premium and does not have totp should do nothing`() = + runTest { + every { settingsRepository.isAutoCopyTotpDisabled } returns false + every { cipherView.organizationUseTotp } returns true + mutableUserStateFlow.value = mockk { + every { activeAccount.isPremium } returns true + } + every { loginView.totp } returns null + + autofillTotpManager.tryCopyTotpToClipboard(cipherView = cipherView) + + verify(exactly = 0) { + clipboardManager.setText(any()) + toast.show() + } + verify(exactly = 1) { + settingsRepository.isAutoCopyTotpDisabled + } + } + + @Suppress("MaxLineLength") + @Test + fun `tryCopyTotpToClipboard when isAutoCopyTotpDisabled is false, has premium and has totp should set the clipboard and toast`() = + runTest { + val generateTotpResult = GenerateTotpResult.Success( + code = TOTP_RESULT_VALUE, + periodSeconds = 100, + ) + every { settingsRepository.isAutoCopyTotpDisabled } returns false + every { cipherView.organizationUseTotp } returns true + mutableUserStateFlow.value = mockk { + every { activeAccount.isPremium } returns true + } + every { loginView.totp } returns TOTP_CODE + coEvery { + vaultRepository.generateTotp(time = FIXED_CLOCK.instant(), totpCode = TOTP_CODE) + } returns generateTotpResult + + autofillTotpManager.tryCopyTotpToClipboard(cipherView = cipherView) + + verify(exactly = 1) { + clipboardManager.setText(text = TOTP_RESULT_VALUE) + settingsRepository.isAutoCopyTotpDisabled + toast.show() + } + coVerify(exactly = 1) { + vaultRepository.generateTotp(time = FIXED_CLOCK.instant(), totpCode = TOTP_CODE) + } + } +} + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, +) + +private const val TOTP_CODE: String = "TOTP_CODE" +private const val TOTP_RESULT_VALUE: String = "TOTP_RESULT_VALUE"