mirror of
https://github.com/bitwarden/android.git
synced 2024-11-26 19:36:18 +03:00
BIT-1093: Add TOTP copying to autofill flow (#879)
Co-authored-by: David Perez <david@livefront.com>
This commit is contained in:
parent
2be47c5b0f
commit
a92d9ff823
25 changed files with 1193 additions and 35 deletions
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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(),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in a new issue