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

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