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,
)
/**