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 }
}
/**

View file

@ -3,8 +3,11 @@ package com.x8bit.bitwarden
import android.content.Intent
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
@ -25,6 +28,7 @@ import org.junit.jupiter.api.Test
class MainViewModelTest : BaseViewModelTest() {
private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl()
private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT)
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true)
@ -59,6 +63,22 @@ class MainViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `autofill selection updates should emit CompleteAutofill events`() = runTest {
val viewModel = createViewModel()
val cipherView = mockk<CipherView>()
viewModel.eventFlow.test {
// Ignore initial screen capture event
awaitItem()
autofillSelectionManager.emitAutofillSelection(cipherView = cipherView)
assertEquals(
MainEvent.CompleteAutofill(cipherView = cipherView),
awaitItem(),
)
}
}
@Test
fun `SpecialCircumstance updates should update the SavedStateHandle`() {
createViewModel()
@ -217,6 +237,7 @@ class MainViewModelTest : BaseViewModelTest() {
private fun createViewModel(
initialSpecialCircumstance: SpecialCircumstance? = null,
) = MainViewModel(
autofillSelectionManager = autofillSelectionManager,
specialCircumstanceManager = specialCircumstanceManager,
settingsRepository = settingsRepository,
intentManager = intentManager,

View file

@ -0,0 +1,230 @@
package com.x8bit.bitwarden.data.autofill.manager
import android.app.Activity
import android.app.assist.AssistStructure
import android.content.Intent
import android.service.autofill.Dataset
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledPartition
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
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.base.FakeDispatcherManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AutofillCompletionManagerTest {
private val activity: Activity = mockk {
every { finish() } just runs
every { setResult(any()) } just runs
every { setResult(any(), any()) } just runs
}
private val assistStructure: AssistStructure = mockk()
private val autofillAppInfo: AutofillAppInfo = mockk()
private val autofillParser: AutofillParser = mockk()
private val cipherView: CipherView = mockk()
private val dataset: Dataset = mockk()
private val dispatcherManager = FakeDispatcherManager()
private val fillableRequest: AutofillRequest.Fillable = mockk()
private val filledDataBuilder: FilledDataBuilder = mockk()
private val filledPartition: FilledPartition = mockk()
private val mockIntent: Intent = mockk()
private val resultIntent: Intent = mockk()
private val autofillCompletionManager: AutofillCompletionManager =
AutofillCompletionManagerImpl(
autofillParser = autofillParser,
dispatcherManager = dispatcherManager,
filledDataBuilderProvider = { filledDataBuilder },
)
@BeforeEach
fun setUp() {
dispatcherManager.setMain(dispatcherManager.unconfined)
mockkStatic(::createAutofillSelectionResultIntent)
mockkStatic(Activity::toAutofillAppInfo)
mockkStatic(FilledPartition::buildDataset)
mockkStatic(Intent::getAutofillAssistStructureOrNull)
every { activity.toAutofillAppInfo() } returns autofillAppInfo
}
@AfterEach
fun tearDown() {
dispatcherManager.resetMain()
unmockkStatic(::createAutofillSelectionResultIntent)
unmockkStatic(Activity::toAutofillAppInfo)
unmockkStatic(FilledPartition::buildDataset)
unmockkStatic(Intent::getAutofillAssistStructureOrNull)
}
@Suppress("MaxLineLength")
@Test
fun `completeAutofill when there is no Intent present should cancel and finish the Activity`() {
every { activity.intent } returns null
autofillCompletionManager.completeAutofill(
activity = activity,
cipherView = cipherView,
)
verify {
activity.setResult(Activity.RESULT_CANCELED)
activity.finish()
}
verify {
activity.intent
}
}
@Suppress("MaxLineLength")
@Test
fun `completeAutofill when there is no AssistStructure present should cancel and finish the Activity`() {
every { activity.intent } returns mockIntent
every { mockIntent.getAutofillAssistStructureOrNull() } returns null
autofillCompletionManager.completeAutofill(
activity = activity,
cipherView = cipherView,
)
verify {
activity.setResult(Activity.RESULT_CANCELED)
activity.finish()
}
verify {
activity.intent
mockIntent.getAutofillAssistStructureOrNull()
}
}
@Suppress("MaxLineLength")
@Test
fun `completeAutofill when the request is not fillable should cancel and finish the Activity`() {
every { activity.intent } returns mockIntent
every { mockIntent.getAutofillAssistStructureOrNull() } returns assistStructure
every {
autofillParser.parse(
autofillAppInfo = autofillAppInfo,
assistStructure = assistStructure,
)
} returns AutofillRequest.Unfillable
autofillCompletionManager.completeAutofill(
activity = activity,
cipherView = cipherView,
)
verify {
activity.setResult(Activity.RESULT_CANCELED)
activity.finish()
}
verify {
activity.intent
mockIntent.getAutofillAssistStructureOrNull()
autofillParser.parse(
autofillAppInfo = autofillAppInfo,
assistStructure = assistStructure,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `completeAutofill when there are no filled partitions should cancel and finish the Activity`() {
val filledData: FilledData = mockk {
every { filledPartitions } returns emptyList()
}
every { activity.intent } returns mockIntent
every { mockIntent.getAutofillAssistStructureOrNull() } returns assistStructure
every {
autofillParser.parse(
autofillAppInfo = autofillAppInfo,
assistStructure = assistStructure,
)
} returns fillableRequest
coEvery {
filledDataBuilder.build(autofillRequest = fillableRequest)
} returns filledData
autofillCompletionManager.completeAutofill(
activity = activity,
cipherView = cipherView,
)
verify {
activity.setResult(Activity.RESULT_CANCELED)
activity.finish()
}
verify {
activity.intent
mockIntent.getAutofillAssistStructureOrNull()
autofillParser.parse(
autofillAppInfo = autofillAppInfo,
assistStructure = assistStructure,
)
}
coVerify {
filledDataBuilder.build(autofillRequest = fillableRequest)
}
}
@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`() {
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
coEvery {
filledDataBuilder.build(autofillRequest = fillableRequest)
} returns filledData
every { filledPartition.buildDataset(autofillAppInfo = autofillAppInfo) } returns dataset
every { createAutofillSelectionResultIntent(dataset = dataset) } returns resultIntent
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(autofillAppInfo = autofillAppInfo)
createAutofillSelectionResultIntent(dataset = dataset)
}
coVerify {
filledDataBuilder.build(autofillRequest = fillableRequest)
}
}
}

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.data.autofill.manager
import app.cash.turbine.test
import com.bitwarden.core.CipherView
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class AutofillSelectionManagerTest {
private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl()
@Test
fun `autofillSelectionFlow should emit whenever emitAutofillSelection is called`() =
runTest {
autofillSelectionManager.autofillSelectionFlow.test {
expectNoEvents()
val cipherView1 = mockk<CipherView>()
autofillSelectionManager.emitAutofillSelection(cipherView = cipherView1)
assertEquals(cipherView1, awaitItem())
val cipherView2 = mockk<CipherView>()
autofillSelectionManager.emitAutofillSelection(cipherView = cipherView2)
assertEquals(cipherView2, awaitItem())
}
}
}

View file

@ -90,6 +90,12 @@ class AutofillParserTests {
isInlineAutofillEnabled = false,
)
} returns emptyList()
every {
null.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = false,
)
} returns emptyList()
every {
fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
@ -102,6 +108,12 @@ class AutofillParserTests {
isInlineAutofillEnabled = false,
)
} returns 0
every {
null.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = false,
)
} returns 0
every { any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure) } returns URI
parser = AutofillParserImpl(
settingsRepository = settingsRepository,
@ -420,6 +432,57 @@ class AutofillParserTests {
}
}
@Test
fun `parse should return empty inline suggestions when parsing an AssistStructure directly`() {
// Setup
mockIsInlineAutofillEnabled = false
setupAssistStructureWithAllAutofillViewTypes()
val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
data = AutofillView.Data(
autofillId = cardAutofillId,
isFocused = true,
),
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
isFocused = true,
),
)
val autofillPartition = AutofillPartition.Card(
views = listOf(cardAutofillView),
)
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = emptyList(),
maxInlineSuggestionsCount = 0,
partition = autofillPartition,
uri = URI,
)
every { cardViewNode.toAutofillView() } returns cardAutofillView
every { loginViewNode.toAutofillView() } returns loginAutofillView
// Test
val actual = parser.parse(
autofillAppInfo = autofillAppInfo,
assistStructure = assistStructure,
)
// Verify
assertEquals(expected, actual)
verify(exactly = 1) {
null.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = false,
)
null.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = false,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
}
}
/**
* Setup [assistStructure] to return window nodes with each [AutofillView] type (card and login)
* so we can test how different window node configurations produce different partitions.

View file

@ -19,6 +19,22 @@ class FillRequestExtensionsTest {
every { this@mockk.inlineSuggestionsRequest } returns expectedInlineSuggestionsRequest
}
@Test
fun `getInlinePresentationSpecs should return empty list when request is null`() {
// Setup
val autofillAppInfo: AutofillAppInfo = mockk()
val expected: List<InlinePresentationSpec> = emptyList()
// Test
val actual = null.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = false,
)
// Verify
assertEquals(expected, actual)
}
@Test
fun `getInlinePresentationSpecs should return empty list when disabled`() {
// Setup
@ -75,6 +91,22 @@ class FillRequestExtensionsTest {
assertEquals(expected, actual)
}
@Test
fun `getMaxInlineSuggestionsCount should return 0 when request is null`() {
// Setup
val autofillAppInfo: AutofillAppInfo = mockk()
val expected = 0
// Test
val actual = null.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = false,
)
// Verify
assertEquals(expected, actual)
}
@Test
fun `getMaxInlineSuggestionsCount should return 0 when disabled`() {
// Setup

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.platform.manager
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class SpecialCircumstanceManagerTest {
private val specialCircumstanceManager: SpecialCircumstanceManager =
SpecialCircumstanceManagerImpl()
@Test
fun `specialCircumstanceStateFlow should emit whenever the SpecialCircumstance is updated`() =
runTest {
specialCircumstanceManager.specialCircumstanceStateFlow.test {
assertNull(awaitItem())
val specialCircumstance1 = mockk<SpecialCircumstance>()
specialCircumstanceManager.specialCircumstance = specialCircumstance1
assertEquals(specialCircumstance1, awaitItem())
val specialCircumstance2 = mockk<SpecialCircumstance>()
specialCircumstanceManager.specialCircumstance = specialCircumstance2
assertEquals(specialCircumstance2, awaitItem())
}
}
}

View file

@ -4,8 +4,11 @@ import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
@ -49,6 +52,8 @@ import java.time.ZoneOffset
@Suppress("LargeClass")
class VaultItemListingViewModelTest : BaseViewModelTest() {
private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl()
private val clock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
@ -150,6 +155,35 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ItemClick for vault item when autofill should post to the AutofillSelectionManager`() =
runTest {
setupMockUri()
val cipherView = createMockCipherView(number = 1)
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.AutofillSelection(
autofillSelectionData = mockk(),
shouldFinishWhenComplete = true,
)
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(cipherView),
folderViewList = emptyList(),
collectionViewList = emptyList(),
sendViewList = emptyList(),
),
)
val viewModel = createVaultItemListingViewModel()
autofillSelectionManager.autofillSelectionFlow.test {
viewModel.trySendAction(VaultItemListingsAction.ItemClick(id = "mockId-1"))
assertEquals(
cipherView,
awaitItem(),
)
}
}
@Test
fun `ItemClick for vault item should emit NavigateToVaultItem`() = runTest {
val viewModel = createVaultItemListingViewModel()
@ -943,6 +977,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
vaultRepository = vaultRepository,
environmentRepository = environmentRepository,
settingsRepository = settingsRepository,
autofillSelectionManager = autofillSelectionManager,
specialCircumstanceManager = specialCircumstanceManager,
)