diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 25c50b2c6..38fcc9ef5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,6 +46,13 @@ + + + when (event) { + is AutofillTotpCopyEvent.CompleteAutofill -> { + handleCompleteAutofill(event) + } + + is AutofillTotpCopyEvent.FinishActivity -> { + finishActivity() + } + } + } + .launchIn(lifecycleScope) + } + + /** + * Complete autofill with the provided data. + */ + private fun handleCompleteAutofill(event: AutofillTotpCopyEvent.CompleteAutofill) { + autofillCompletionManager.completeAutofill( + activity = this, + cipherView = event.cipherView, + ) + } + + /** + * Finish the activity. + */ + private fun finishActivity() { + setResult(RESULT_CANCELED) + finish() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/AutofillTotpCopyViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/AutofillTotpCopyViewModel.kt new file mode 100644 index 000000000..ddc5d2b4c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/AutofillTotpCopyViewModel.kt @@ -0,0 +1,121 @@ +package com.x8bit.bitwarden + +import android.content.Intent +import androidx.lifecycle.viewModelScope +import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.autofill.util.getTotpCopyIntentOrNull +import com.x8bit.bitwarden.data.platform.util.launchWithTimeout +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData +import com.x8bit.bitwarden.data.vault.repository.util.statusFor +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapNotNull +import javax.inject.Inject + +/** + * The amount of time we should wait for ciphers to be loaded before timing out. + */ +private const val CIPHER_WAIT_TIMEOUT_MILLIS: Long = 500 + +/** + * A view model that handles logic for the [AutofillTotpCopyActivity]. + */ +@HiltViewModel +class AutofillTotpCopyViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val vaultRepository: VaultRepository, +) : BaseViewModel(Unit) { + private val activeUserId: String? get() = authRepository.activeUserId + + override fun handleAction(action: AutofillTotpCopyAction): Unit = when (action) { + is AutofillTotpCopyAction.IntentReceived -> handleIntentReceived(action) + } + + /** + * Process the received intent and alert the activity of what to do next. + */ + private fun handleIntentReceived(action: AutofillTotpCopyAction.IntentReceived) { + viewModelScope + .launchWithTimeout( + timeoutBlock = { finishActivity() }, + timeoutDuration = CIPHER_WAIT_TIMEOUT_MILLIS, + ) { + // Extract TOTP copy data from the intent. + val cipherId = action + .intent + .getTotpCopyIntentOrNull() + ?.cipherId + + if (cipherId == null || isVaultLocked()) { + finishActivity() + return@launchWithTimeout + } + + // Try and find the matching cipher. + vaultRepository + .ciphersStateFlow + .mapNotNull { it.data } + .first() + .find { it.id == cipherId } + ?.let { cipherView -> + sendEvent( + AutofillTotpCopyEvent.CompleteAutofill( + cipherView = cipherView, + ), + ) + } + ?: finishActivity() + } + } + + /** + * Send an event to the activity that signals it to finish. + */ + private fun finishActivity() { + sendEvent(AutofillTotpCopyEvent.FinishActivity) + } + + private suspend fun isVaultLocked(): Boolean { + val userId = activeUserId ?: return true + + // Wait for any unlocking actions to finish. This can be relevant on startup for Never lock + // accounts. + vaultRepository.vaultUnlockDataStateFlow.first { + it.statusFor(userId) != VaultUnlockData.Status.UNLOCKING + } + + return !vaultRepository.isVaultUnlocked(userId = userId) + } +} + +/** + * Represents actions that can be sent to the [AutofillTotpCopyViewModel]. + */ +sealed class AutofillTotpCopyAction { + /** + * An [intent] has been received and is ready to be processed. + */ + data class IntentReceived( + val intent: Intent, + ) : AutofillTotpCopyAction() +} + +/** + * Represents events emitted by the [AutofillTotpCopyViewModel]. + */ +sealed class AutofillTotpCopyEvent { + /** + * Complete autofill with the provided [cipherView]. + */ + data class CompleteAutofill( + val cipherView: CipherView, + ) : AutofillTotpCopyEvent() + + /** + * Finish the activity. + */ + data object FinishActivity : AutofillTotpCopyEvent() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt index 5cc5d23a3..c8b5756b0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt @@ -1,10 +1,13 @@ package com.x8bit.bitwarden.data.autofill.builder +import android.content.IntentSender import android.service.autofill.FillResponse import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.FilledData +import com.x8bit.bitwarden.data.autofill.model.FilledPartition import com.x8bit.bitwarden.data.autofill.util.buildDataset import com.x8bit.bitwarden.data.autofill.util.buildVaultItemDataset +import com.x8bit.bitwarden.data.autofill.util.createTotpCopyIntentSender import com.x8bit.bitwarden.data.autofill.util.fillableAutofillIds /** @@ -28,7 +31,11 @@ class FillResponseBuilderImpl : FillResponseBuilder { // We build a dataset for each filled partition. A filled partition is a // copy of all the views that we are going to fill, loaded with the data // from one of the ciphers that can fulfill this partition type. + val authIntentSender = filledPartition.toAuthIntentSenderOrNull( + autofillAppInfo = autofillAppInfo, + ) val dataset = filledPartition.buildDataset( + authIntentSender = authIntentSender, autofillAppInfo = autofillAppInfo, ) @@ -56,3 +63,22 @@ class FillResponseBuilderImpl : FillResponseBuilder { null } } + +/** + * Convert this [FilledPartition] and [autofillAppInfo] into an [IntentSender] if totp is enabled + * and there the [FilledPartition.autofillCipher] has a valid cipher id. + */ +private fun FilledPartition.toAuthIntentSenderOrNull( + autofillAppInfo: AutofillAppInfo, +): IntentSender? { + val isTotpEnabled = this.autofillCipher.isTotpEnabled + val cipherId = this.autofillCipher.cipherId + return if (isTotpEnabled && cipherId != null) { + createTotpCopyIntentSender( + cipherId = cipherId, + context = autofillAppInfo.context, + ) + } else { + null + } +} 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 de3456a7b..06f535d4f 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 @@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessorImpl import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProviderImpl import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager +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 @@ -50,11 +51,17 @@ object AutofillModule { @Provides fun provideAutofillCompletionManager( autofillParser: AutofillParser, + authRepository: AuthRepository, + clipboardManager: BitwardenClipboardManager, dispatcherManager: DispatcherManager, + vaultRepository: VaultRepository, ): AutofillCompletionManager = AutofillCompletionManagerImpl( + authRepository = authRepository, autofillParser = autofillParser, + clipboardManager = clipboardManager, dispatcherManager = dispatcherManager, + vaultRepository = vaultRepository, ) @Provides 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 d80015f33..b3f33c662 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,7 +2,11 @@ package com.x8bit.bitwarden.data.autofill.manager import android.app.Activity import android.content.Intent +import android.widget.Toast import com.bitwarden.core.CipherView +import com.bitwarden.core.DateTime +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 @@ -12,7 +16,10 @@ 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.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -20,10 +27,13 @@ import kotlinx.coroutines.launch * Primary implementation of [AutofillCompletionManager]. */ class AutofillCompletionManagerImpl( + private val authRepository: AuthRepository, private val autofillParser: AutofillParser, + private val clipboardManager: BitwardenClipboardManager, private val dispatcherManager: DispatcherManager, private val filledDataBuilderProvider: (CipherView) -> FilledDataBuilder = { createSingleItemFilledDataBuilder(cipherView = it) }, + private val vaultRepository: VaultRepository, ) : AutofillCompletionManager { private val mainScope = CoroutineScope(dispatcherManager.main) @@ -58,15 +68,55 @@ class AutofillCompletionManagerImpl( .build(autofillRequest) .filledPartitions .firstOrNull() - ?.buildDataset(autofillAppInfo = autofillAppInfo) + ?.buildDataset( + autofillAppInfo = autofillAppInfo, + authIntentSender = null, + ) ?: run { activity.cancelAndFinish() return@launch } + tryCopyTotpToClipboard( + activity = activity, + cipherView = cipherView, + ) val resultIntent = createAutofillSelectionResultIntent(dataset) activity.setResultAndFinish(resultIntent = resultIntent) } } + + /** + * 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 totpCode = cipherView.login?.totp + + // TODO check global TOTP enabled status BIT-1093 + if (isPremium && 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/model/AutofillCipher.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt index c90f881ed..f1d2a665c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.autofill.model import androidx.annotation.DrawableRes +import com.bitwarden.core.Uuid import com.x8bit.bitwarden.R /** @@ -12,6 +13,11 @@ sealed class AutofillCipher { */ abstract val iconRes: Int + /** + * Whether or not TOTP is enabled for this cipher. + */ + abstract val isTotpEnabled: Boolean + /** * The name of the cipher. */ @@ -22,11 +28,17 @@ sealed class AutofillCipher { */ abstract val subtitle: String + /** + * The ID that corresponds to the CipherView used to create this [AutofillCipher]. + */ + abstract val cipherId: String? + /** * The card [AutofillCipher] model. This contains all of the data for building fulfilling a card * partition. */ data class Card( + override val cipherId: String?, override val name: String, override val subtitle: String, val cardholderName: String, @@ -37,6 +49,9 @@ sealed class AutofillCipher { ) : AutofillCipher() { override val iconRes: Int @DrawableRes get() = R.drawable.ic_card_item + + override val isTotpEnabled: Boolean + get() = false } /** @@ -44,6 +59,8 @@ sealed class AutofillCipher { * login partition. */ data class Login( + override val cipherId: Uuid?, + override val isTotpEnabled: Boolean, override val name: String, override val subtitle: String, val password: String, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillTotpCopyData.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillTotpCopyData.kt new file mode 100644 index 000000000..42633a922 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillTotpCopyData.kt @@ -0,0 +1,14 @@ +package com.x8bit.bitwarden.data.autofill.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Represents data for a TOTP copying during the autofill flow via authentication intents. + * + * @property cipherId The cipher for which we are copying a TOTP to the clipboard. + */ +@Parcelize +data class AutofillTotpCopyData( + val cipherId: String, +) : Parcelable diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/provider/AutofillCipherProviderImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/provider/AutofillCipherProviderImpl.kt index 7e1758bfd..8d5b7f539 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/provider/AutofillCipherProviderImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/provider/AutofillCipherProviderImpl.kt @@ -44,6 +44,7 @@ class AutofillCipherProviderImpl( .takeIf { cipherView.type == CipherType.CARD && cipherView.deletedDate == null } ?.let { nonNullCipherView -> AutofillCipher.Card( + cipherId = cipherView.id, name = nonNullCipherView.name, subtitle = nonNullCipherView.subtitle.orEmpty(), cardholderName = nonNullCipherView.card?.cardholderName.orEmpty(), @@ -72,6 +73,8 @@ class AutofillCipherProviderImpl( ) .map { cipherView -> AutofillCipher.Login( + cipherId = cipherView.id, + isTotpEnabled = cipherView.login?.totp != null, name = cipherView.name, password = cipherView.login?.password.orEmpty(), subtitle = cipherView.subtitle.orEmpty(), diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillIntentUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillIntentUtils.kt index 74930b358..8db69675f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillIntentUtils.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillIntentUtils.kt @@ -2,17 +2,22 @@ package com.x8bit.bitwarden.data.autofill.util +import android.app.PendingIntent import android.app.assist.AssistStructure import android.content.Context import android.content.Intent +import android.content.IntentSender import android.service.autofill.Dataset import android.view.autofill.AutofillManager +import com.x8bit.bitwarden.AutofillTotpCopyActivity import com.x8bit.bitwarden.MainActivity import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.data.autofill.model.AutofillTotpCopyData import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.util.getSafeParcelableExtra private const val AUTOFILL_SELECTION_DATA_KEY = "autofill-selection-data" +private const val AUTOFILL_TOTP_COPY_DATA_KEY = "autofill-totp-copy-data" /** * Creates an [Intent] in order to send the user to a manual selection process for autofill. @@ -36,6 +41,37 @@ fun createAutofillSelectionIntent( ) } +/** + * Creates an [IntentSender] built with the data required for performing a TOTP copying during + * the autofill flow. + */ +fun createTotpCopyIntentSender( + cipherId: String, + context: Context, +): IntentSender { + val intent = Intent( + context, + AutofillTotpCopyActivity::class.java, + ) + .apply { + putExtra( + AUTOFILL_TOTP_COPY_DATA_KEY, + AutofillTotpCopyData( + cipherId = cipherId, + ), + ) + } + + return PendingIntent + .getActivity( + context, + 0, + intent, + PendingIntent.FLAG_CANCEL_CURRENT.toPendingIntentMutabilityFlag(), + ) + .intentSender +} + /** * Creates an [Intent] in order to specify that there is a successful selection during a manual * autofill process. @@ -61,3 +97,10 @@ fun Intent.getAutofillAssistStructureOrNull(): AssistStructure? = */ fun Intent.getAutofillSelectionDataOrNull(): AutofillSelectionData? = this.getSafeParcelableExtra(AUTOFILL_SELECTION_DATA_KEY) + +/** + * Checks if the given [Intent] contains data for TOTP copying. The [AutofillTotpCopyData] will be + * returned when present. + */ +fun Intent.getTotpCopyIntentOrNull(): AutofillTotpCopyData? = + this.getSafeParcelableExtra(AUTOFILL_TOTP_COPY_DATA_KEY) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt index 297d78800..747b36df8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt @@ -16,6 +16,7 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider = val card = this@toAutofillCipherProvider.card ?: return emptyList() return listOf( AutofillCipher.Card( + cipherId = id, name = name, subtitle = subtitle.orEmpty(), cardholderName = card.cardholderName.orEmpty(), @@ -33,6 +34,8 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider = val login = this@toAutofillCipherProvider.login ?: return emptyList() return listOf( AutofillCipher.Login( + cipherId = id, + isTotpEnabled = login.totp != null, name = name, password = login.password.orEmpty(), subtitle = subtitle.orEmpty(), diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensions.kt index b38ba8e66..3c6581e2d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensions.kt @@ -160,15 +160,3 @@ private fun Dataset.Builder.addVaultItemDataPreTiramisu( } return this } - -/** - * Starting from an initial pending intent flag (ex: [PendingIntent.FLAG_CANCEL_CURRENT], derives - * a new flag with the correct mutability determined by [isMutable]. - */ -private fun Int.toPendingIntentMutabilityFlag(): Int = - // Mutable flag was added on API level 31 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - this or PendingIntent.FLAG_MUTABLE - } else { - this - } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensions.kt index 72aecde64..1084031a5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensions.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.autofill.util import android.annotation.SuppressLint +import android.content.IntentSender import android.os.Build import android.service.autofill.Dataset import android.service.autofill.Presentations @@ -13,10 +14,11 @@ import com.x8bit.bitwarden.ui.autofill.util.createCipherInlinePresentationOrNull /** * Build a [Dataset] to represent the [FilledPartition]. This dataset includes an overlay UI - * presentation for each filled item. + * presentation for each filled item. If an [authIntentSender] is present, add it to the dataset. */ @SuppressLint("NewApi") fun FilledPartition.buildDataset( + authIntentSender: IntentSender?, autofillAppInfo: AutofillAppInfo, ): Dataset { val remoteViewsPlaceholder = buildAutofillRemoteViews( @@ -25,6 +27,11 @@ fun FilledPartition.buildDataset( ) val datasetBuilder = Dataset.Builder() + authIntentSender + ?.let { intentSender -> + datasetBuilder.setAuthentication(intentSender) + } + if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.TIRAMISU) { applyToDatasetPostTiramisu( autofillAppInfo = autofillAppInfo, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/IntExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/IntExtensions.kt index d5d2de926..cf79ce30b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/IntExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/IntExtensions.kt @@ -1,6 +1,9 @@ package com.x8bit.bitwarden.data.autofill.util +import android.app.PendingIntent +import android.os.Build import android.text.InputType +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage /** * Whether this [Int] is a password [InputType]. @@ -29,3 +32,15 @@ val Int.isUsernameInputType: Boolean * Whether this [Int] contains [flag]. */ private fun Int.hasFlag(flag: Int): Boolean = (this and flag) == flag + +/** + * Starting from an initial pending intent flag. (ex: [PendingIntent.FLAG_CANCEL_CURRENT]) + */ +@OmitFromCoverage +fun Int.toPendingIntentMutabilityFlag(): Int = + // Mutable flag was added on API level 31 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + this or PendingIntent.FLAG_MUTABLE + } else { + this + } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CoroutineScopeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CoroutineScopeExtensions.kt new file mode 100644 index 000000000..06c82e8cd --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CoroutineScopeExtensions.kt @@ -0,0 +1,24 @@ +package com.x8bit.bitwarden.data.platform.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +/** + * Launch a new coroutine that runs [block] and will safely timeout and invoke [timeoutBlock] after + * a duration of length [timeoutDuration] in milliseconds is elapsed. + */ +fun CoroutineScope.launchWithTimeout( + timeoutBlock: () -> Unit, + timeoutDuration: Long, + block: suspend CoroutineScope.() -> Unit, +): Job = + launch { + try { + withTimeout(timeoutDuration, block) + } catch (e: TimeoutCancellationException) { + timeoutBlock() + } + } diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 4c5b16f9f..f59c051e8 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -17,4 +17,14 @@ @drawable/logo_rounded @color/ic_launcher_background + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/AutofillTotpCopyViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/AutofillTotpCopyViewModelTest.kt new file mode 100644 index 000000000..cee9ceaa9 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/AutofillTotpCopyViewModelTest.kt @@ -0,0 +1,238 @@ +package com.x8bit.bitwarden + +import android.content.Intent +import app.cash.turbine.test +import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.autofill.model.AutofillTotpCopyData +import com.x8bit.bitwarden.data.autofill.util.getTotpCopyIntentOrNull +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AutofillTotpCopyViewModelTest : BaseViewModelTest() { + private lateinit var autofillTotpCopyViewModel: AutofillTotpCopyViewModel + + private val mutableCiphersStateFlow: MutableStateFlow>> = + MutableStateFlow(DataState.Loading) + private val mutableVaultUnlockDataStateFlow: MutableStateFlow> = + MutableStateFlow(emptyList()) + + private val authRepository: AuthRepository = mockk { + every { activeUserId } returns null + } + private val vaultRepository: VaultRepository = mockk { + every { ciphersStateFlow } returns mutableCiphersStateFlow + every { vaultUnlockDataStateFlow } returns mutableVaultUnlockDataStateFlow + } + + private val intent: Intent = mockk() + + @BeforeEach + fun setup() { + mockkStatic(Intent::getTotpCopyIntentOrNull) + + autofillTotpCopyViewModel = AutofillTotpCopyViewModel( + authRepository = authRepository, + vaultRepository = vaultRepository, + ) + } + + @AfterEach + fun teardown() { + unmockkStatic(Intent::getTotpCopyIntentOrNull) + } + + @Suppress("MaxLineLength") + @Test + fun `on IntentReceived should emit CompleteAutofill when cipherID is extracted, vault unlocked, and cipherView found`() = + runTest { + // Setup + val cipherView: CipherView = mockk { + every { id } returns CIPHER_ID + } + val totpCopyData = AutofillTotpCopyData( + cipherId = CIPHER_ID, + ) + val action = AutofillTotpCopyAction.IntentReceived( + intent = intent, + ) + val expectedEvent = AutofillTotpCopyEvent.CompleteAutofill( + cipherView = cipherView, + ) + val vaultUnlockData = VaultUnlockData( + userId = ACTIVE_USER_ID, + status = VaultUnlockData.Status.UNLOCKED, + ) + every { intent.getTotpCopyIntentOrNull() } returns totpCopyData + every { authRepository.activeUserId } returns ACTIVE_USER_ID + every { vaultRepository.isVaultUnlocked(userId = ACTIVE_USER_ID) } returns true + mutableCiphersStateFlow.value = DataState.Loaded( + listOf( + cipherView, + ), + ) + mutableVaultUnlockDataStateFlow.value = listOf(vaultUnlockData) + + // Test + autofillTotpCopyViewModel.trySendAction(action) + + // Verify + autofillTotpCopyViewModel.eventFlow.test { + assertEquals(expectedEvent, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `on IntentReceived should emit FinishActivity when cipherID is not`() = runTest { + // Setup + val action = AutofillTotpCopyAction.IntentReceived( + intent = intent, + ) + val expectedEvent = AutofillTotpCopyEvent.FinishActivity + every { intent.getTotpCopyIntentOrNull() } returns null + + // Test + autofillTotpCopyViewModel.trySendAction(action) + + // Verify + autofillTotpCopyViewModel.eventFlow.test { + assertEquals(expectedEvent, awaitItem()) + expectNoEvents() + } + } + + @Suppress("MaxLineLength") + @Test + fun `on IntentReceived should emit FinishActivity when cipherID is extracted and no active user`() = + runTest { + // Setup + val totpCopyData = AutofillTotpCopyData( + cipherId = CIPHER_ID, + ) + val action = AutofillTotpCopyAction.IntentReceived( + intent = intent, + ) + val expectedEvent = AutofillTotpCopyEvent.FinishActivity + every { intent.getTotpCopyIntentOrNull() } returns totpCopyData + every { authRepository.activeUserId } returns null + + // Test + autofillTotpCopyViewModel.trySendAction(action) + + // Verify + autofillTotpCopyViewModel.eventFlow.test { + assertEquals(expectedEvent, awaitItem()) + expectNoEvents() + } + } + + @Suppress("MaxLineLength") + @Test + fun `on IntentReceived should emit FinishActivity when cipherID is extracted and vault locked`() = + runTest { + // Setup + val totpCopyData = AutofillTotpCopyData( + cipherId = CIPHER_ID, + ) + val action = AutofillTotpCopyAction.IntentReceived( + intent = intent, + ) + val expectedEvent = AutofillTotpCopyEvent.FinishActivity + every { intent.getTotpCopyIntentOrNull() } returns totpCopyData + every { authRepository.activeUserId } returns ACTIVE_USER_ID + every { vaultRepository.isVaultUnlocked(userId = ACTIVE_USER_ID) } returns false + + // Test + autofillTotpCopyViewModel.trySendAction(action) + + // Verify + autofillTotpCopyViewModel.eventFlow.test { + assertEquals(expectedEvent, awaitItem()) + expectNoEvents() + } + } + + @Suppress("MaxLineLength") + @Test + fun `on IntentReceived should emit FinishActivity when cipherID is extracted, vault unlocked, and cipherView not found`() = + runTest { + // Setup + val cipherView: CipherView = mockk { + every { id } returns "NEW CIPHER ID" + } + val totpCopyData = AutofillTotpCopyData( + cipherId = CIPHER_ID, + ) + val action = AutofillTotpCopyAction.IntentReceived( + intent = intent, + ) + val expectedEvent = AutofillTotpCopyEvent.FinishActivity + val vaultUnlockData = VaultUnlockData( + userId = ACTIVE_USER_ID, + status = VaultUnlockData.Status.UNLOCKED, + ) + every { intent.getTotpCopyIntentOrNull() } returns totpCopyData + every { authRepository.activeUserId } returns ACTIVE_USER_ID + every { vaultRepository.isVaultUnlocked(userId = ACTIVE_USER_ID) } returns true + mutableCiphersStateFlow.value = DataState.Loaded( + listOf( + cipherView, + ), + ) + mutableVaultUnlockDataStateFlow.value = listOf(vaultUnlockData) + + // Test + autofillTotpCopyViewModel.trySendAction(action) + + // Verify + autofillTotpCopyViewModel.eventFlow.test { + assertEquals(expectedEvent, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `on IntentReceived should emit FinishActivity when timeout is elapsed`() = runTest { + // Setup + val totpCopyData = AutofillTotpCopyData( + cipherId = CIPHER_ID, + ) + val action = AutofillTotpCopyAction.IntentReceived( + intent = intent, + ) + val expectedEvent = AutofillTotpCopyEvent.FinishActivity + val vaultUnlockData = VaultUnlockData( + userId = ACTIVE_USER_ID, + status = VaultUnlockData.Status.UNLOCKED, + ) + every { intent.getTotpCopyIntentOrNull() } returns totpCopyData + every { authRepository.activeUserId } returns ACTIVE_USER_ID + every { vaultRepository.isVaultUnlocked(userId = ACTIVE_USER_ID) } returns true + mutableVaultUnlockDataStateFlow.value = listOf(vaultUnlockData) + + // Test + autofillTotpCopyViewModel.trySendAction(action) + + // Verify + autofillTotpCopyViewModel.eventFlow.test { + assertEquals(expectedEvent, awaitItem()) + expectNoEvents() + } + } +} + +private const val ACTIVE_USER_ID: String = "ACTIVE_USER_ID" +private const val CIPHER_ID: String = "1234567890" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt index cb33ff16d..28b4911fe 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderTest.kt @@ -1,16 +1,19 @@ package com.x8bit.bitwarden.data.autofill.builder import android.content.Context +import android.content.IntentSender import android.service.autofill.Dataset import android.service.autofill.FillResponse import android.view.autofill.AutofillId import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo +import com.x8bit.bitwarden.data.autofill.model.AutofillCipher import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillView import com.x8bit.bitwarden.data.autofill.model.FilledData import com.x8bit.bitwarden.data.autofill.model.FilledPartition import com.x8bit.bitwarden.data.autofill.util.buildDataset import com.x8bit.bitwarden.data.autofill.util.buildVaultItemDataset +import com.x8bit.bitwarden.data.autofill.util.createTotpCopyIntentSender import com.x8bit.bitwarden.data.util.mockBuilder import io.mockk.every import io.mockk.mockk @@ -30,6 +33,7 @@ class FillResponseBuilderTest { private val context: Context = mockk() private val dataset: Dataset = mockk() + private val intentSender: IntentSender = mockk() private val vaultItemDataSet: Dataset = mockk() private val fillResponse: FillResponse = mockk() private val appInfo: AutofillAppInfo = AutofillAppInfo( @@ -37,19 +41,47 @@ class FillResponseBuilderTest { packageName = PACKAGE_NAME, sdkInt = 17, ) + private val autofillCipherValid: AutofillCipher = mockk { + every { cipherId } returns CIPHER_ID + every { isTotpEnabled } returns true + } + private val autofillCipherNoId: AutofillCipher = mockk { + every { cipherId } returns null + every { isTotpEnabled } returns true + } + private val autofillCipherTotpDisabled: AutofillCipher = mockk { + every { cipherId } returns CIPHER_ID + every { isTotpEnabled } returns false + } private val filledPartitionOne: FilledPartition = mockk { every { this@mockk.filledItems } returns listOf(mockk()) + every { this@mockk.autofillCipher } returns autofillCipherValid } private val filledPartitionTwo: FilledPartition = mockk { every { this@mockk.filledItems } returns emptyList() } + private val filledPartitionThree: FilledPartition = mockk { + every { this@mockk.filledItems } returns listOf(mockk()) + every { this@mockk.autofillCipher } returns autofillCipherNoId + } + private val filledPartitionFour: FilledPartition = mockk { + every { this@mockk.filledItems } returns listOf(mockk()) + every { this@mockk.autofillCipher } returns autofillCipherTotpDisabled + } @BeforeEach fun setup() { mockkConstructor(FillResponse.Builder::class) + mockkStatic(::createTotpCopyIntentSender) mockkStatic(FilledData::buildVaultItemDataset) mockkStatic(FilledPartition::buildDataset) every { anyConstructed().build() } returns fillResponse + every { + createTotpCopyIntentSender( + cipherId = CIPHER_ID, + context = context, + ) + } returns intentSender fillResponseBuilder = FillResponseBuilderImpl() } @@ -57,6 +89,7 @@ class FillResponseBuilderTest { @AfterEach fun teardown() { unmockkConstructor(FillResponse.Builder::class) + unmockkStatic(::createTotpCopyIntentSender) unmockkStatic(FilledData::buildVaultItemDataset) unmockkStatic(FilledPartition::buildDataset) } @@ -102,6 +135,8 @@ class FillResponseBuilderTest { val filledPartitions = listOf( filledPartitionOne, filledPartitionTwo, + filledPartitionThree, + filledPartitionFour, ) val filledData = FilledData( filledPartitions = filledPartitions, @@ -122,6 +157,19 @@ class FillResponseBuilderTest { ) every { filledPartitionOne.buildDataset( + authIntentSender = intentSender, + autofillAppInfo = appInfo, + ) + } returns dataset + every { + filledPartitionThree.buildDataset( + authIntentSender = null, + autofillAppInfo = appInfo, + ) + } returns dataset + every { + filledPartitionFour.buildDataset( + authIntentSender = null, autofillAppInfo = appInfo, ) } returns dataset @@ -152,21 +200,33 @@ class FillResponseBuilderTest { verify(exactly = 1) { filledPartitionOne.buildDataset( + authIntentSender = intentSender, + autofillAppInfo = appInfo, + ) + filledPartitionThree.buildDataset( + authIntentSender = null, + autofillAppInfo = appInfo, + ) + filledPartitionFour.buildDataset( + authIntentSender = null, autofillAppInfo = appInfo, ) filledData.buildVaultItemDataset( autofillAppInfo = appInfo, ) - anyConstructed().addDataset(dataset) anyConstructed().addDataset(vaultItemDataSet) anyConstructed().setIgnoredIds( ignoredAutofillIdOne, ignoredAutofillIdTwo, ) } + verify(exactly = 3) { + anyConstructed().addDataset(dataset) + } } companion object { + private const val CIPHER_ID: String = "1234567890" private const val PACKAGE_NAME: String = "com.x8bit.bitwarden" } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt index 42e7bd855..6515d8a79 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt @@ -59,7 +59,9 @@ class FilledDataBuilderTest { val password = "Password" val username = "johnDoe" val autofillCipher = AutofillCipher.Login( + cipherId = null, name = "Cipher One", + isTotpEnabled = false, password = password, username = username, subtitle = "Subtitle", @@ -181,6 +183,7 @@ class FilledDataBuilderTest { val number = "1234567890" val autofillCipher = AutofillCipher.Card( cardholderName = "John", + cipherId = null, code = code, expirationMonth = expirationMonth, expirationYear = expirationYear, @@ -273,6 +276,8 @@ class FilledDataBuilderTest { val password = "Password" val username = "johnDoe" val autofillCipher = AutofillCipher.Login( + cipherId = null, + isTotpEnabled = false, name = "Cipher One", password = password, username = username, 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 4029c898d..9d94bca76 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 @@ -2,9 +2,14 @@ package com.x8bit.bitwarden.data.autofill.manager import android.app.Activity 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.core.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 @@ -16,6 +21,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.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager +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 @@ -25,20 +33,30 @@ 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 class AutofillCompletionManagerTest { + private val context: Context = mockk() private val activity: Activity = mockk { + every { applicationContext } returns context every { finish() } just runs every { setResult(any()) } just runs 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() + 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() @@ -46,12 +64,19 @@ class AutofillCompletionManagerTest { private val filledPartition: FilledPartition = mockk() private val mockIntent: Intent = mockk() private val resultIntent: Intent = mockk() + private val toast: Toast = mockk { + every { show() } just runs + } + private val vaultRepository: VaultRepository = mockk() private val autofillCompletionManager: AutofillCompletionManager = AutofillCompletionManagerImpl( + authRepository = authRepository, autofillParser = autofillParser, + clipboardManager = clipboardManager, dispatcherManager = dispatcherManager, filledDataBuilderProvider = { filledDataBuilder }, + vaultRepository = vaultRepository, ) @BeforeEach @@ -61,6 +86,7 @@ class AutofillCompletionManagerTest { mockkStatic(Activity::toAutofillAppInfo) mockkStatic(FilledPartition::buildDataset) mockkStatic(Intent::getAutofillAssistStructureOrNull) + mockkStatic(Toast::class) every { activity.toAutofillAppInfo() } returns autofillAppInfo } @@ -71,6 +97,7 @@ class AutofillCompletionManagerTest { unmockkStatic(Activity::toAutofillAppInfo) unmockkStatic(FilledPartition::buildDataset) unmockkStatic(Intent::getAutofillAssistStructureOrNull) + unmockkStatic(Toast::class) } @Suppress("MaxLineLength") @@ -186,7 +213,91 @@ class AutofillCompletionManagerTest { @Suppress("MaxLineLength") @Test - fun `completeAutofill when there is a filled partition should build a dataset, place it in a result Intent, and finish the Activity`() { + fun `completeAutofill when filled partition, premium active user, a totp code, and totp generated succesfully 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 { 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() + } + verify { + activity.intent + clipboardManager.setText(any()) + mockIntent.getAutofillAssistStructureOrNull() + autofillParser.parse( + autofillAppInfo = autofillAppInfo, + assistStructure = assistStructure, + ) + filledPartition.buildDataset( + authIntentSender = null, + autofillAppInfo = autofillAppInfo, + ) + createAutofillSelectionResultIntent(dataset = dataset) + Toast.makeText( + context, + R.string.verification_code_totp, + Toast.LENGTH_LONG, + ) + toast.show() + } + 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`() { val filledData: FilledData = mockk { every { filledPartitions } returns listOf(filledPartition) } @@ -198,11 +309,26 @@ class AutofillCompletionManagerTest { assistStructure = assistStructure, ) } returns fillableRequest + every { cipherView.login?.totp } returns TOTP_CODE coEvery { filledDataBuilder.build(autofillRequest = fillableRequest) } returns filledData - every { filledPartition.buildDataset(autofillAppInfo = autofillAppInfo) } returns dataset + every { + filledPartition.buildDataset( + authIntentSender = null, + autofillAppInfo = autofillAppInfo, + ) + } returns dataset 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, @@ -220,7 +346,126 @@ class AutofillCompletionManagerTest { autofillAppInfo = autofillAppInfo, assistStructure = assistStructure, ) - filledPartition.buildDataset(autofillAppInfo = autofillAppInfo) + filledPartition.buildDataset( + authIntentSender = null, + autofillAppInfo = autofillAppInfo, + ) + createAutofillSelectionResultIntent(dataset = dataset) + } + 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 { 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, + ) + createAutofillSelectionResultIntent(dataset = dataset) + } + coVerify { + filledDataBuilder.build(autofillRequest = fillableRequest) + } + } + + @Suppress("MaxLineLength") + @Test + fun `completeAutofill when filled partition, no premium active user, 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 + coEvery { + filledDataBuilder.build(autofillRequest = fillableRequest) + } returns filledData + every { + filledPartition.buildDataset( + authIntentSender = null, + autofillAppInfo = autofillAppInfo, + ) + } returns dataset + 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, + ) createAutofillSelectionResultIntent(dataset = dataset) } coVerify { @@ -228,3 +473,6 @@ class AutofillCompletionManagerTest { } } } + +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/processor/AutofillCipherProviderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillCipherProviderTest.kt index 6b4a87d17..34ded784e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillCipherProviderTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillCipherProviderTest.kt @@ -41,16 +41,31 @@ class AutofillCipherProviderTest { private val cardCipherView: CipherView = mockk { every { card } returns cardView every { deletedDate } returns null + every { id } returns CIPHER_ID every { name } returns CARD_NAME every { type } returns CipherType.CARD } - private val loginView: LoginView = mockk { + private val loginViewWithoutTotp: LoginView = mockk { every { password } returns LOGIN_PASSWORD every { username } returns LOGIN_USERNAME + every { totp } returns null } - private val loginCipherView: CipherView = mockk { + private val loginCipherViewWithoutTotp: CipherView = mockk { every { deletedDate } returns null - every { login } returns loginView + every { id } returns CIPHER_ID + every { login } returns loginViewWithoutTotp + every { name } returns LOGIN_NAME + every { type } returns CipherType.LOGIN + } + private val loginViewWithTotp: LoginView = mockk { + every { password } returns LOGIN_PASSWORD + every { username } returns LOGIN_USERNAME + every { totp } returns "TOTP-CODE" + } + private val loginCipherViewWithTotp: CipherView = mockk { + every { deletedDate } returns null + every { id } returns CIPHER_ID + every { login } returns loginViewWithTotp every { name } returns LOGIN_NAME every { type } returns CipherType.LOGIN } @@ -147,7 +162,8 @@ class AutofillCipherProviderTest { val cipherViews = listOf( cardCipherView, deletedCardCipherView, - loginCipherView, + loginCipherViewWithTotp, + loginCipherViewWithoutTotp, ) mutableCiphersStateFlow.value = DataState.Loaded( data = cipherViews, @@ -189,11 +205,13 @@ class AutofillCipherProviderTest { } val cipherViews = listOf( cardCipherView, - loginCipherView, + loginCipherViewWithTotp, + loginCipherViewWithoutTotp, deletedLoginCipherView, ) val filteredCipherViews = listOf( - loginCipherView, + loginCipherViewWithTotp, + loginCipherViewWithoutTotp, ) coEvery { cipherMatchingManager.filterCiphersForMatches( @@ -211,9 +229,11 @@ class AutofillCipherProviderTest { ), ) val expected = listOf( - LOGIN_AUTOFILL_CIPHER, + LOGIN_AUTOFILL_CIPHER_WITH_TOTP, + LOGIN_AUTOFILL_CIPHER_WITHOUT_TOTP, ) - every { loginCipherView.subtitle } returns LOGIN_SUBTITLE + every { loginCipherViewWithTotp.subtitle } returns LOGIN_SUBTITLE + every { loginCipherViewWithoutTotp.subtitle } returns LOGIN_SUBTITLE // Test val actual = autofillCipherProvider.getLoginAutofillCiphers( @@ -251,8 +271,10 @@ private const val CARD_EXP_YEAR = "2029" private const val CARD_NAME = "John's Card" private const val CARD_NUMBER = "1234567890" private const val CARD_SUBTITLE = "7890" +private const val CIPHER_ID = "1234567890" private val CARD_AUTOFILL_CIPHER = AutofillCipher.Card( cardholderName = CARD_CARDHOLDER_NAME, + cipherId = CIPHER_ID, code = CARD_CODE, expirationMonth = CARD_EXP_MONTH, expirationYear = CARD_EXP_YEAR, @@ -264,14 +286,20 @@ private const val LOGIN_NAME = "John's Login" private const val LOGIN_PASSWORD = "Password123" private const val LOGIN_SUBTITLE = "John Doe" private const val LOGIN_USERNAME = "John-Bitwarden" -private val LOGIN_AUTOFILL_CIPHER = AutofillCipher.Login( +private val LOGIN_AUTOFILL_CIPHER_WITH_TOTP = AutofillCipher.Login( + cipherId = CIPHER_ID, + isTotpEnabled = true, name = LOGIN_NAME, password = LOGIN_PASSWORD, subtitle = LOGIN_SUBTITLE, username = LOGIN_USERNAME, ) -private val CIPHERS = listOf( - CARD_AUTOFILL_CIPHER, - LOGIN_AUTOFILL_CIPHER, +private val LOGIN_AUTOFILL_CIPHER_WITHOUT_TOTP = AutofillCipher.Login( + cipherId = CIPHER_ID, + isTotpEnabled = false, + name = LOGIN_NAME, + password = LOGIN_PASSWORD, + subtitle = LOGIN_SUBTITLE, + username = LOGIN_USERNAME, ) private const val URI: String = "androidapp://com.x8bit.bitwarden" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensionsTest.kt index fd8117c92..5d85b2632 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensionsTest.kt @@ -12,11 +12,12 @@ class CipherViewExtensionsTest { @Suppress("MaxLineLength") @Test - fun `toAutofillCipherProvider should return a provider with the correct data for a Login type`() = + fun `toAutofillCipherProvider should return a provider with the correct data for a Login type without TOTP`() = runTest { val cipherView = createMockCipherView( number = 1, cipherType = CipherType.LOGIN, + totp = null, ) val autofillCipherProvider = cipherView.toAutofillCipherProvider() @@ -29,6 +30,40 @@ class CipherViewExtensionsTest { assertEquals( listOf( AutofillCipher.Login( + cipherId = "mockId-1", + isTotpEnabled = false, + name = "mockName-1", + subtitle = "mockUsername-1", + password = "mockPassword-1", + username = "mockUsername-1", + ), + ), + autofillCipherProvider.getLoginAutofillCiphers(uri = "uri"), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toAutofillCipherProvider should return a provider with the correct data for a Login type with TOTP`() = + runTest { + val cipherView = createMockCipherView( + number = 1, + cipherType = CipherType.LOGIN, + totp = "mockkTotp-1", + ) + + val autofillCipherProvider = cipherView.toAutofillCipherProvider() + + assertFalse(autofillCipherProvider.isVaultLocked()) + assertEquals( + emptyList(), + autofillCipherProvider.getCardAutofillCiphers(), + ) + assertEquals( + listOf( + AutofillCipher.Login( + cipherId = "mockId-1", + isTotpEnabled = true, name = "mockName-1", subtitle = "mockUsername-1", password = "mockPassword-1", @@ -58,6 +93,7 @@ class CipherViewExtensionsTest { assertEquals( listOf( AutofillCipher.Card( + cipherId = "mockId-1", name = "mockName-1", subtitle = "mockBrand-1, *er-1", cardholderName = "mockCardholderName-1", diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensionsTest.kt index 07620ead6..5f7b985be 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledPartitionExtensionsTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.autofill.util import android.content.Context +import android.content.IntentSender import android.content.res.Resources import android.service.autofill.Dataset import android.service.autofill.InlinePresentation @@ -70,8 +71,70 @@ class FilledPartitionExtensionsTest { unmockkStatic(InlinePresentationSpec::createCipherInlinePresentationOrNull) } + @Suppress("MaxLineLength") @Test - fun `buildDataset should applyToDatasetPostTiramisu when sdkInt is at least 33`() { + fun `buildDataset should applyToDatasetPostTiramisu and set auth when sdkInt is at least 33 and has authIntentSender`() { + // Setup + val authIntentSender: IntentSender = mockk() + val autofillAppInfo = AutofillAppInfo( + context = context, + packageName = PACKAGE_NAME, + sdkInt = 34, + ) + val inlinePresentation: InlinePresentation = mockk() + every { + buildAutofillRemoteViews( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + } returns remoteViews + every { + inlinePresentationSpec.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + } returns inlinePresentation + mockBuilder { it.setInlinePresentation(inlinePresentation) } + mockBuilder { it.setMenuPresentation(remoteViews) } + every { + filledItem.applyToDatasetPostTiramisu( + datasetBuilder = any(), + presentations = presentations, + ) + } just runs + every { anyConstructed().build() } returns presentations + + // Test + val actual = filledPartition.buildDataset( + authIntentSender = authIntentSender, + autofillAppInfo = autofillAppInfo, + ) + + // Verify + assertEquals(dataset, actual) + verify(exactly = 1) { + buildAutofillRemoteViews( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + inlinePresentationSpec.createCipherInlinePresentationOrNull( + autofillAppInfo = autofillAppInfo, + autofillCipher = autofillCipher, + ) + anyConstructed().setInlinePresentation(inlinePresentation) + anyConstructed().setMenuPresentation(remoteViews) + anyConstructed().build() + filledItem.applyToDatasetPostTiramisu( + datasetBuilder = any(), + presentations = presentations, + ) + anyConstructed().build() + } + } + + @Suppress("MaxLineLength") + @Test + fun `buildDataset should applyToDatasetPostTiramisu and doesn't set auth when sdkInt is at least 33 and null authIntentSender`() { // Setup val autofillAppInfo = AutofillAppInfo( context = context, @@ -103,6 +166,7 @@ class FilledPartitionExtensionsTest { // Test val actual = filledPartition.buildDataset( + authIntentSender = null, autofillAppInfo = autofillAppInfo, ) @@ -152,6 +216,7 @@ class FilledPartitionExtensionsTest { // Test val actual = filledPartition.buildDataset( + authIntentSender = null, autofillAppInfo = autofillAppInfo, ) @@ -202,6 +267,7 @@ class FilledPartitionExtensionsTest { // Test val actual = filledPartition.buildDataset( + authIntentSender = null, autofillAppInfo = autofillAppInfo, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CoroutineScopeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CoroutineScopeExtensionsTest.kt new file mode 100644 index 000000000..7bc03f3f5 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CoroutineScopeExtensionsTest.kt @@ -0,0 +1,62 @@ +package com.x8bit.bitwarden.data.platform.util + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CoroutineScopeExtensionsTest { + @Test + fun `launchWithTimeout should skip timeout block when main block finishes`() = runTest { + // Setup + val timeoutDuration = 1000L + var timeOutBlockInvoked = false + var mainBlockInvoked = false + + // Test + this + .launchWithTimeout( + timeoutBlock = { timeOutBlockInvoked = true }, + timeoutDuration = timeoutDuration, + ) { + mainBlockInvoked = true + } + .invokeOnCompletion { + // Verify + assertTrue(mainBlockInvoked) + assertFalse(timeOutBlockInvoked) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `launchWithTimeout should invoke timeout block when timeout is elapsed`() = runTest { + // Setup + val timeoutDuration = 1000L + var timeOutBlockInvoked = false + var mainBlockStarted = false + var mainBlockFinished = false + + // Test + this + .launchWithTimeout( + timeoutBlock = { timeOutBlockInvoked = true }, + timeoutDuration = timeoutDuration, + ) { + mainBlockStarted = true + delay(2000) + mainBlockFinished = true + } + .invokeOnCompletion { + // Verify + assertTrue(mainBlockStarted) + assertFalse(mainBlockFinished) + assertTrue(timeOutBlockInvoked) + } + + advanceTimeBy(2000) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt index 0f1bbd16a..9541bf4ee 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -27,6 +27,7 @@ fun createMockCipherView( number: Int, isDeleted: Boolean = false, cipherType: CipherType = CipherType.LOGIN, + totp: String? = "mockTotp-$number", ): CipherView = CipherView( id = "mockId-$number", @@ -37,7 +38,11 @@ fun createMockCipherView( name = "mockName-$number", notes = "mockNotes-$number", type = cipherType, - login = createMockLoginView(number = number).takeIf { cipherType == CipherType.LOGIN }, + login = createMockLoginView( + number = number, + totp = totp, + ) + .takeIf { cipherType == CipherType.LOGIN }, creationDate = ZonedDateTime .parse("2023-10-27T12:00:00Z") .toInstant(), @@ -70,7 +75,10 @@ fun createMockCipherView( /** * Create a mock [LoginView] with a given [number]. */ -fun createMockLoginView(number: Int): LoginView = +fun createMockLoginView( + number: Int, + totp: String? = "mockTotp-$number", +): LoginView = LoginView( username = "mockUsername-$number", password = "mockPassword-$number", @@ -79,7 +87,7 @@ fun createMockLoginView(number: Int): LoginView = .toInstant(), autofillOnPageLoad = false, uris = listOf(createMockUriView(number = number)), - totp = "mockTotp-$number", + totp = totp, ) /**