BIT-1093: Add TOTP copying to autofill flow (#879)

Co-authored-by: David Perez <david@livefront.com>
This commit is contained in:
Lucas Kivi 2024-01-30 18:14:58 -06:00 committed by Álison Fernandes
parent 2be47c5b0f
commit a92d9ff823
25 changed files with 1193 additions and 35 deletions

View file

@ -46,6 +46,13 @@
</intent-filter>
</activity>
<activity
android:name=".AutofillTotpCopyActivity"
android:exported="true"
android:launchMode="singleTop"
android:noHistory="true"
android:theme="@style/AutofillTotpCopyTheme" />
<activity
android:name=".WebAuthCallbackActivity"
android:exported="true"

View file

@ -0,0 +1,72 @@
package com.x8bit.bitwarden
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
/**
* An activity for copying a TOTP code to the clipboard. This is done when an autofill item is
* selected and it requires TOTP authentication. Due to the constraints of the autofill framework,
* we also have to re-fulfill the autofill for the views that are being filled.
*/
@AndroidEntryPoint
class AutofillTotpCopyActivity : AppCompatActivity() {
@Inject
lateinit var autofillCompletionManager: AutofillCompletionManager
private val autofillTotpCopyViewModel: AutofillTotpCopyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
observeViewModelEvents()
autofillTotpCopyViewModel.trySendAction(
AutofillTotpCopyAction.IntentReceived(
intent = intent,
),
)
}
private fun observeViewModelEvents() {
autofillTotpCopyViewModel
.eventFlow
.onEach { event ->
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()
}
}

View file

@ -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, AutofillTotpCopyEvent, AutofillTotpCopyAction>(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()
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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(

View file

@ -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,

View file

@ -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

View file

@ -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(),

View file

@ -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)

View file

@ -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(),

View file

@ -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
}

View file

@ -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,

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -17,4 +17,14 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/logo_rounded</item>
<item name="windowSplashScreenBackground">@color/ic_launcher_background</item>
</style>
<!-- A translucent theme for the autofill TOTP copy activity -->
<style name="AutofillTotpCopyTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowIsTranslucent">true</item>
<item name="statusBarBackground">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
</style>
</resources>

View file

@ -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<DataState<List<CipherView>>> =
MutableStateFlow(DataState.Loading)
private val mutableVaultUnlockDataStateFlow: MutableStateFlow<List<VaultUnlockData>> =
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"

View file

@ -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<FillResponse.Builder>().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<FillResponse.Builder>().addDataset(dataset)
anyConstructed<FillResponse.Builder>().addDataset(vaultItemDataSet)
anyConstructed<FillResponse.Builder>().setIgnoredIds(
ignoredAutofillIdOne,
ignoredAutofillIdTwo,
)
}
verify(exactly = 3) {
anyConstructed<FillResponse.Builder>().addDataset(dataset)
}
}
companion object {
private const val CIPHER_ID: String = "1234567890"
private const val PACKAGE_NAME: String = "com.x8bit.bitwarden"
}
}

View file

@ -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,

View file

@ -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<UserState?>(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<String>()) } 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<String>())
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"

View file

@ -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"

View file

@ -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<AutofillCipher.Card>(),
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",

View file

@ -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<Presentations.Builder> { it.setInlinePresentation(inlinePresentation) }
mockBuilder<Presentations.Builder> { it.setMenuPresentation(remoteViews) }
every {
filledItem.applyToDatasetPostTiramisu(
datasetBuilder = any(),
presentations = presentations,
)
} just runs
every { anyConstructed<Presentations.Builder>().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<Presentations.Builder>().setInlinePresentation(inlinePresentation)
anyConstructed<Presentations.Builder>().setMenuPresentation(remoteViews)
anyConstructed<Presentations.Builder>().build()
filledItem.applyToDatasetPostTiramisu(
datasetBuilder = any(),
presentations = presentations,
)
anyConstructed<Dataset.Builder>().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,
)

View file

@ -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)
}
}

View file

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