Send autofill selections back to autofill flow (#829)

This commit is contained in:
Brian Yencho
2024-01-28 12:25:15 -06:00
committed by Álison Fernandes
parent b199a67b7d
commit b3fa33a02c
22 changed files with 823 additions and 13 deletions

View File

@@ -12,6 +12,7 @@ import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@@ -28,6 +29,9 @@ class MainActivity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModels()
@Inject
lateinit var autofillCompletionManager: AutofillCompletionManager
@Inject
lateinit var settingsRepository: SettingsRepository
@@ -79,6 +83,10 @@ class MainActivity : AppCompatActivity() {
.eventFlow
.onEach { event ->
when (event) {
is MainEvent.CompleteAutofill -> {
handleCompleteAutofill(event)
}
is MainEvent.ScreenCaptureSettingChange -> {
handleScreenCaptureSettingChange(event)
}
@@ -87,6 +95,13 @@ class MainActivity : AppCompatActivity() {
.launchIn(lifecycleScope)
}
private fun handleCompleteAutofill(event: MainEvent.CompleteAutofill) {
autofillCompletionManager.completeAutofill(
activity = this,
cipherView = event.cipherView,
)
}
private fun handleScreenCaptureSettingChange(event: MainEvent.ScreenCaptureSettingChange) {
if (event.isAllowed) {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)

View File

@@ -4,6 +4,8 @@ import android.content.Intent
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -25,6 +27,7 @@ private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
*/
@HiltViewModel
class MainViewModel @Inject constructor(
private val autofillSelectionManager: AutofillSelectionManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val intentManager: IntentManager,
settingsRepository: SettingsRepository,
@@ -49,6 +52,11 @@ class MainViewModel @Inject constructor(
.onEach { specialCircumstance = it }
.launchIn(viewModelScope)
autofillSelectionManager
.autofillSelectionFlow
.onEach { trySendAction(MainAction.Internal.AutofillSelectionReceive(it)) }
.launchIn(viewModelScope)
settingsRepository
.appThemeStateFlow
.onEach { trySendAction(MainAction.Internal.ThemeUpdate(it)) }
@@ -64,12 +72,22 @@ class MainViewModel @Inject constructor(
override fun handleAction(action: MainAction) {
when (action) {
is MainAction.Internal.AutofillSelectionReceive -> {
handleAutofillSelectionReceive(action)
}
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
}
}
private fun handleAutofillSelectionReceive(
action: MainAction.Internal.AutofillSelectionReceive,
) {
sendEvent(MainEvent.CompleteAutofill(cipherView = action.cipherView))
}
private fun handleAppThemeUpdated(action: MainAction.Internal.ThemeUpdate) {
mutableStateFlow.update { it.copy(theme = action.theme) }
}
@@ -144,6 +162,13 @@ sealed class MainAction {
* Actions for internal use by the ViewModel.
*/
sealed class Internal : MainAction() {
/**
* Indicates the user has manually selected the given [cipherView] for autofill.
*/
data class AutofillSelectionReceive(
val cipherView: CipherView,
) : Internal()
/**
* Indicates that the app theme has changed.
*/
@@ -157,6 +182,11 @@ sealed class MainAction {
* Represents events that are emitted by the [MainViewModel].
*/
sealed class MainEvent {
/**
* Event indicating that the user has chosen the given [cipherView] for autofill and that the
* process is ready to complete.
*/
data class CompleteAutofill(val cipherView: CipherView) : MainEvent()
/**
* Event indicating a change in the screen capture setting.

View File

@@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.autofill.di
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.hilt.android.scopes.ActivityRetainedScoped
/**
* Provides dependencies in the autofill package that must be scoped to a retained Activity. These
* are for dependencies that must operate independently in different application tasks that contain
* unique [MainActivity] instances.
*/
@Module
@InstallIn(ActivityRetainedComponent::class)
object ActivityAutofillModule {
@ActivityRetainedScoped
@Provides
fun provideAutofillSelectionManager(): AutofillSelectionManager =
AutofillSelectionManagerImpl()
}

View File

@@ -7,6 +7,8 @@ import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilder
import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilderImpl
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilderImpl
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManagerImpl
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
import com.x8bit.bitwarden.data.autofill.parser.AutofillParserImpl
import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor
@@ -36,6 +38,17 @@ object AutofillModule {
@ApplicationContext context: Context,
): AutofillManager = context.getSystemService(AutofillManager::class.java)
@Singleton
@Provides
fun provideAutofillCompletionManager(
autofillParser: AutofillParser,
dispatcherManager: DispatcherManager,
): AutofillCompletionManager =
AutofillCompletionManagerImpl(
autofillParser = autofillParser,
dispatcherManager = dispatcherManager,
)
@Provides
fun providesAutofillParser(
settingsRepository: SettingsRepository,

View File

@@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.autofill.manager
import android.app.Activity
import com.bitwarden.core.CipherView
/**
* A manager for completing the autofill process after the user has made a selection.
*/
interface AutofillCompletionManager {
/**
* Completes the autofill flow originating with the given [activity] using the selected
* [cipherView].
*/
fun completeAutofill(
activity: Activity,
cipherView: CipherView,
)
}

View File

@@ -0,0 +1,123 @@
package com.x8bit.bitwarden.data.autofill.manager
import android.app.Activity
import android.content.Intent
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilderImpl
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.autofill.util.buildDataset
import com.x8bit.bitwarden.data.autofill.util.createAutofillSelectionResultIntent
import com.x8bit.bitwarden.data.autofill.util.getAutofillAssistStructureOrNull
import com.x8bit.bitwarden.data.autofill.util.toAutofillAppInfo
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.util.subtitle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Primary implementation of [AutofillCompletionManager].
*/
class AutofillCompletionManagerImpl(
private val autofillParser: AutofillParser,
private val dispatcherManager: DispatcherManager,
private val filledDataBuilderProvider: (CipherView) -> FilledDataBuilder =
{ createSingleItemFilledDataBuilder(cipherView = it) },
) : AutofillCompletionManager {
private val mainScope = CoroutineScope(dispatcherManager.main)
override fun completeAutofill(
activity: Activity,
cipherView: CipherView,
) {
val autofillAppInfo = activity.toAutofillAppInfo()
val assistStructure = activity
.intent
?.getAutofillAssistStructureOrNull()
?: run {
activity.cancelAndFinish()
return
}
val autofillRequest = autofillParser
.parse(
autofillAppInfo = autofillAppInfo,
assistStructure = assistStructure,
)
if (autofillRequest !is AutofillRequest.Fillable) {
activity.cancelAndFinish()
return
}
val fillDataBuilder = filledDataBuilderProvider(cipherView)
// We'll launch a coroutine here but this code will technically run synchronously given
// how we've constructed a single-item AutofillCipherProvider.
mainScope.launch {
val dataset = fillDataBuilder
.build(autofillRequest)
.filledPartitions
.firstOrNull()
?.buildDataset(autofillAppInfo = autofillAppInfo)
?: run {
activity.cancelAndFinish()
return@launch
}
val resultIntent = createAutofillSelectionResultIntent(dataset)
activity.setResultAndFinish(resultIntent = resultIntent)
}
}
}
private fun createSingleItemFilledDataBuilder(
cipherView: CipherView,
): FilledDataBuilder =
FilledDataBuilderImpl(
autofillCipherProvider = cipherView.toAutofillCipherProvider(),
)
private fun Activity.cancelAndFinish() {
this.setResult(Activity.RESULT_CANCELED)
this.finish()
}
private fun Activity.setResultAndFinish(resultIntent: Intent) {
this.setResult(Activity.RESULT_OK, resultIntent)
this.finish()
}
private fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider =
object : AutofillCipherProvider {
override suspend fun isVaultLocked(): Boolean = true
override suspend fun getCardAutofillCiphers(): List<AutofillCipher.Card> {
val card = this@toAutofillCipherProvider.card ?: return emptyList()
return listOf(
AutofillCipher.Card(
name = name,
subtitle = subtitle.orEmpty(),
cardholderName = card.cardholderName.orEmpty(),
code = card.code.orEmpty(),
expirationMonth = card.expMonth.orEmpty(),
expirationYear = card.expYear.orEmpty(),
number = card.number.orEmpty(),
),
)
}
override suspend fun getLoginAutofillCiphers(
uri: String,
): List<AutofillCipher.Login> {
val login = this@toAutofillCipherProvider.login ?: return emptyList()
return listOf(
AutofillCipher.Login(
name = name,
password = login.password.orEmpty(),
subtitle = subtitle.orEmpty(),
username = login.username.orEmpty(),
),
)
}
}

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.autofill.manager
import com.bitwarden.core.CipherView
import kotlinx.coroutines.flow.Flow
/**
* Tracks the selection of a [CipherView] during the autofill flow within the app.
*/
interface AutofillSelectionManager {
/**
* Emits a [CipherView] as a result of calls to [emitAutofillSelection].
*/
val autofillSelectionFlow: Flow<CipherView>
/**
* Triggers an emission via [autofillSelectionFlow].
*/
fun emitAutofillSelection(cipherView: CipherView)
}

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.autofill.manager
import com.bitwarden.core.CipherView
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
/**
* Primary implementation of [AutofillSelectionManager].
*/
class AutofillSelectionManagerImpl : AutofillSelectionManager {
private val autofillSelectionChannel = Channel<CipherView>(capacity = Int.MAX_VALUE)
override val autofillSelectionFlow: Flow<CipherView> =
autofillSelectionChannel.receiveAsFlow()
override fun emitAutofillSelection(cipherView: CipherView) {
autofillSelectionChannel.trySend(cipherView)
}
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.autofill.parser
import android.app.assist.AssistStructure
import android.service.autofill.FillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
@@ -19,4 +20,15 @@ interface AutofillParser {
autofillAppInfo: AutofillAppInfo,
fillRequest: FillRequest,
): AutofillRequest
/**
* Parse the useful information from [assistStructure] into an [AutofillRequest].
*
* @param autofillAppInfo Provides app context that is required to properly parse the request.
* @param assistStructure The key data from the original request that needs parsing.
*/
fun parse(
autofillAppInfo: AutofillAppInfo,
assistStructure: AssistStructure,
): AutofillRequest
}

View File

@@ -40,13 +40,23 @@ class AutofillParserImpl(
}
?: AutofillRequest.Unfillable
override fun parse(
autofillAppInfo: AutofillAppInfo,
assistStructure: AssistStructure,
): AutofillRequest =
parseInternal(
assistStructure = assistStructure,
autofillAppInfo = autofillAppInfo,
fillRequest = null,
)
/**
* Parse the [AssistStructure] into an [AutofillRequest].
*/
private fun parseInternal(
assistStructure: AssistStructure,
autofillAppInfo: AutofillAppInfo,
fillRequest: FillRequest,
fillRequest: FillRequest?,
): AutofillRequest {
// Parse the `assistStructure` into internal models.
val traversalDataList = assistStructure.traverse()

View File

@@ -0,0 +1,18 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.data.autofill.util
import android.app.Activity
import android.os.Build
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* Build an [AutofillAppInfo] from the given [Activity].
*/
fun Activity.toAutofillAppInfo(): AutofillAppInfo =
AutofillAppInfo(
context = this.applicationContext,
packageName = this.packageName,
sdkInt = Build.VERSION.SDK_INT,
)

View File

@@ -2,12 +2,15 @@
package com.x8bit.bitwarden.data.autofill.util
import android.app.assist.AssistStructure
import android.content.Context
import android.content.Intent
import android.os.Build
import android.service.autofill.Dataset
import android.view.autofill.AutofillManager
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
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"
@@ -33,14 +36,28 @@ fun createAutofillSelectionIntent(
)
}
/**
* Creates an [Intent] in order to specify that there is a successful selection during a manual
* autofill process.
*/
fun createAutofillSelectionResultIntent(
dataset: Dataset,
): Intent =
Intent()
.apply {
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)
}
/**
* Checks if the given [Intent] contains an [AssistStructure] related to an ongoing manual autofill
* selection process.
*/
fun Intent.getAutofillAssistStructureOrNull(): AssistStructure? =
this.getSafeParcelableExtra(AutofillManager.EXTRA_ASSIST_STRUCTURE)
/**
* Checks if the given [Intent] contains data about an ongoing manual autofill selection process.
* The [AutofillSelectionData] will be returned when present.
*/
fun Intent.getAutofillSelectionDataOrNull(): AutofillSelectionData? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.getParcelableExtra(AUTOFILL_SELECTION_DATA_KEY, AutofillSelectionData::class.java)
} else {
this.getParcelableExtra(AUTOFILL_SELECTION_DATA_KEY)
}
}
fun Intent.getAutofillSelectionDataOrNull(): AutofillSelectionData? =
this.getSafeParcelableExtra(AUTOFILL_SELECTION_DATA_KEY)

View File

@@ -10,11 +10,14 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
* Extract the list of [InlinePresentationSpec]s. If it fails, return an empty list.
*/
@SuppressLint("NewApi")
fun FillRequest.getInlinePresentationSpecs(
fun FillRequest?.getInlinePresentationSpecs(
autofillAppInfo: AutofillAppInfo,
isInlineAutofillEnabled: Boolean,
): List<InlinePresentationSpec> =
if (isInlineAutofillEnabled && autofillAppInfo.sdkInt >= Build.VERSION_CODES.R) {
if (this != null &&
isInlineAutofillEnabled &&
autofillAppInfo.sdkInt >= Build.VERSION_CODES.R
) {
inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
} else {
emptyList()
@@ -25,11 +28,14 @@ fun FillRequest.getInlinePresentationSpecs(
* return 0.
*/
@SuppressLint("NewApi")
fun FillRequest.getMaxInlineSuggestionsCount(
fun FillRequest?.getMaxInlineSuggestionsCount(
autofillAppInfo: AutofillAppInfo,
isInlineAutofillEnabled: Boolean,
): Int =
if (isInlineAutofillEnabled && autofillAppInfo.sdkInt >= Build.VERSION_CODES.R) {
if (this != null &&
isInlineAutofillEnabled &&
autofillAppInfo.sdkInt >= Build.VERSION_CODES.R
) {
inlineSuggestionsRequest?.maxSuggestionCount ?: 0
} else {
0

View File

@@ -0,0 +1,23 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.data.platform.util
import android.content.Intent
import android.os.Build
import android.os.Parcelable
import android.view.autofill.AutofillManager
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* A means of retrieving a [Parcelable] from an [Intent] using the given [name] in a manner that
* is safe across SDK versions.
*/
inline fun <reified T> Intent.getSafeParcelableExtra(name: String): T? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(
name,
T::class.java,
)
} else {
getParcelableExtra(AutofillManager.EXTRA_ASSIST_STRUCTURE)
}

View File

@@ -4,6 +4,7 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
@@ -55,6 +56,7 @@ class VaultItemListingViewModel @Inject constructor(
private val vaultRepository: VaultRepository,
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
private val autofillSelectionManager: AutofillSelectionManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
) : BaseViewModel<VaultItemListingState, VaultItemListingEvent, VaultItemListingsAction>(
initialState = run {
@@ -180,6 +182,12 @@ class VaultItemListingViewModel @Inject constructor(
}
private fun handleItemClick(action: VaultItemListingsAction.ItemClick) {
if (state.isAutofill) {
val cipherView = getCipherViewOrNull(action.id) ?: return
autofillSelectionManager.emitAutofillSelection(cipherView = cipherView)
return
}
val event = when (state.itemListingType) {
is VaultItemListingState.ItemListingType.Vault -> {
VaultItemListingEvent.NavigateToVaultItem(id = action.id)
@@ -518,6 +526,14 @@ class VaultItemListingViewModel @Inject constructor(
)
}
}
private fun getCipherViewOrNull(cipherId: String) =
vaultRepository
.vaultDataStateFlow
.value
.data
?.cipherViewList
?.firstOrNull { it.id == cipherId }
}
/**