BIT-1457: Setup autofill save request (#898)

This commit is contained in:
Lucas Kivi 2024-01-31 22:55:22 -06:00 committed by Álison Fernandes
parent 8bb754f85b
commit 81c78fc115
32 changed files with 1906 additions and 92 deletions

View file

@ -25,6 +25,15 @@ class BitwardenAutofillService : AutofillService() {
*/
@Inject
lateinit var processor: AutofillProcessor
/**
* App information for the autofill feature.
*/
private val autofillAppInfo: AutofillAppInfo
get() = AutofillAppInfo(
context = applicationContext,
packageName = packageName,
sdkInt = Build.VERSION.SDK_INT,
)
override fun onFillRequest(
request: FillRequest,
@ -32,11 +41,7 @@ class BitwardenAutofillService : AutofillService() {
fillCallback: FillCallback,
) {
processor.processFillRequest(
autofillAppInfo = AutofillAppInfo(
context = applicationContext,
packageName = packageName,
sdkInt = Build.VERSION.SDK_INT,
),
autofillAppInfo = autofillAppInfo,
cancellationSignal = cancellationSignal,
fillCallback = fillCallback,
request = request,
@ -47,6 +52,10 @@ class BitwardenAutofillService : AutofillService() {
saverRequest: SaveRequest,
saveCallback: SaveCallback,
) {
// TODO: add save request behavior (BIT-1299)
processor.processSaveRequest(
autofillAppInfo = autofillAppInfo,
request = saverRequest,
saveCallback = saveCallback,
)
}
}

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.autofill.builder
import android.service.autofill.FillResponse
import android.service.autofill.SaveInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledData
@ -14,5 +15,6 @@ interface FillResponseBuilder {
fun build(
autofillAppInfo: AutofillAppInfo,
filledData: FilledData,
saveInfo: SaveInfo?,
): FillResponse?
}

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.autofill.builder
import android.content.IntentSender
import android.service.autofill.FillResponse
import android.service.autofill.SaveInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledPartition
@ -18,10 +19,16 @@ class FillResponseBuilderImpl : FillResponseBuilder {
override fun build(
autofillAppInfo: AutofillAppInfo,
filledData: FilledData,
saveInfo: SaveInfo?,
): FillResponse? =
if (filledData.fillableAutofillIds.isNotEmpty()) {
val fillResponseBuilder = FillResponse.Builder()
saveInfo
?.let { nonNullSaveInfo ->
fillResponseBuilder.setSaveInfo(nonNullSaveInfo)
}
filledData
.filledPartitions
.forEach { filledPartition ->

View file

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.autofill.builder
import android.service.autofill.FillRequest
import android.service.autofill.SaveInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
/**
* A builder for converting processed autofill request data into save info.
*/
interface SaveInfoBuilder {
/**
* Build a save info out the provided data. If that isn't possible, return null.
*
* @param autofillAppInfo App data that is required for building the [SaveInfo].
* @param autofillPartition The portion of the processed [FillRequest] that will be filled.
* @param fillRequest The [FillRequest] that initiated the autofill flow.
* @param packageName The package name that was extracted from the [FillRequest].
*/
fun build(
autofillAppInfo: AutofillAppInfo,
autofillPartition: AutofillPartition,
fillRequest: FillRequest,
packageName: String?,
): SaveInfo?
}

View file

@ -0,0 +1,154 @@
package com.x8bit.bitwarden.data.autofill.builder
import android.annotation.SuppressLint
import android.os.Build
import android.service.autofill.FillRequest
import android.service.autofill.SaveInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
/**
* The primary implementation of [SaveInfoBuilder].This is used for converting autofill data into
* a save info.
*/
class SaveInfoBuilderImpl(
val settingsRepository: SettingsRepository,
) : SaveInfoBuilder {
@SuppressLint("InlinedApi")
override fun build(
autofillAppInfo: AutofillAppInfo,
autofillPartition: AutofillPartition,
fillRequest: FillRequest,
packageName: String?,
): SaveInfo? {
// Make sure that the save prompt is possible.
val canPerformSaveRequest = autofillPartition.canPerformSaveRequest
if (settingsRepository.isAutofillSavePromptDisabled || !canPerformSaveRequest) return null
// Docs state that password fields cannot be reliably saved
// in Compat mode since they show as masked values.
val isInCompatMode = if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.Q) {
// Attempt to automatically establish compat request mode on Android 10+
(fillRequest.flags or FillRequest.FLAG_COMPATIBILITY_MODE_REQUEST) == fillRequest.flags
} else {
COMPAT_BROWSERS.contains(packageName)
}
// If login and compat mode, the password might be obfuscated,
// in which case we should skip the save request.
return if (autofillPartition is AutofillPartition.Login && isInCompatMode) {
null
} else {
val saveInfoBuilder = SaveInfo
.Builder(
autofillPartition.saveType,
autofillPartition.requiredSaveIds.toTypedArray(),
)
.setOptionalIds(autofillPartition.optionalSaveIds.toTypedArray())
if (isInCompatMode) saveInfoBuilder.setFlags(SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE)
saveInfoBuilder.build()
}
}
}
/**
* These browsers function using the compatibility shim for the Autofill Framework.
*
* Ensure that these entries are sorted alphabetically and keep this list synchronized with the
* values in /xml/autofill_service_configuration.xml and
* /xml-v30/autofill_service_configuration.xml.
*/
private val COMPAT_BROWSERS: List<String> = listOf(
"alook.browser",
"alook.browser.google",
"app.vanadium.browser",
"com.amazon.cloud9",
"com.android.browser",
"com.android.chrome",
"com.android.htmlviewer",
"com.avast.android.secure.browser",
"com.avg.android.secure.browser",
"com.brave.browser",
"com.brave.browser_beta",
"com.brave.browser_default",
"com.brave.browser_dev",
"com.brave.browser_nightly",
"com.chrome.beta",
"com.chrome.canary",
"com.chrome.dev",
"com.cookiegames.smartcookie",
"com.cookiejarapps.android.smartcookieweb",
"com.ecosia.android",
"com.google.android.apps.chrome",
"com.google.android.apps.chrome_dev",
"com.google.android.captiveportallogin",
"com.iode.firefox",
"com.jamal2367.styx",
"com.kiwibrowser.browser",
"com.kiwibrowser.browser.dev",
"com.lemurbrowser.exts",
"com.microsoft.emmx",
"com.microsoft.emmx.beta",
"com.microsoft.emmx.canary",
"com.microsoft.emmx.dev",
"com.mmbox.browser",
"com.mmbox.xbrowser",
"com.mycompany.app.soulbrowser",
"com.naver.whale",
"com.neeva.app",
"com.opera.browser",
"com.opera.browser.beta",
"com.opera.gx",
"com.opera.mini.native",
"com.opera.mini.native.beta",
"com.opera.touch",
"com.qflair.browserq",
"com.qwant.liberty",
"com.rainsee.create",
"com.sec.android.app.sbrowser",
"com.sec.android.app.sbrowser.beta",
"com.stoutner.privacybrowser.free",
"com.stoutner.privacybrowser.standard",
"com.vivaldi.browser",
"com.vivaldi.browser.snapshot",
"com.vivaldi.browser.sopranos",
"com.yandex.browser",
"com.yjllq.internet",
"com.yjllq.kito",
"com.yujian.ResideMenuDemo",
"com.z28j.feel",
"idm.internet.download.manager",
"idm.internet.download.manager.adm.lite",
"idm.internet.download.manager.plus",
"io.github.forkmaintainers.iceraven",
"mark.via",
"mark.via.gp",
"net.dezor.browser",
"net.slions.fulguris.full.download",
"net.slions.fulguris.full.download.debug",
"net.slions.fulguris.full.playstore",
"net.slions.fulguris.full.playstore.debug",
"org.adblockplus.browser",
"org.adblockplus.browser.beta",
"org.bromite.bromite",
"org.bromite.chromium",
"org.chromium.chrome",
"org.codeaurora.swe.browser",
"org.cromite.cromite",
"org.gnu.icecat",
"org.mozilla.fenix",
"org.mozilla.fenix.nightly",
"org.mozilla.fennec_aurora",
"org.mozilla.fennec_fdroid",
"org.mozilla.firefox",
"org.mozilla.firefox_beta",
"org.mozilla.reference.browser",
"org.mozilla.rocket",
"org.torproject.torbrowser",
"org.torproject.torbrowser_alpha",
"org.ungoogled.chromium.extensions.stable",
"org.ungoogled.chromium.stable",
"us.spotco.fennec_dos",
)

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.builder.SaveInfoBuilder
import com.x8bit.bitwarden.data.autofill.builder.SaveInfoBuilderImpl
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManagerImpl
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
@ -17,6 +19,7 @@ import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor
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.PolicyManager
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
@ -90,12 +93,18 @@ object AutofillModule {
filledDataBuilder: FilledDataBuilder,
fillResponseBuilder: FillResponseBuilder,
parser: AutofillParser,
policyManager: PolicyManager,
saveInfoBuilder: SaveInfoBuilder,
settingsRepository: SettingsRepository,
): AutofillProcessor =
AutofillProcessorImpl(
dispatcherManager = dispatcherManager,
filledDataBuilder = filledDataBuilder,
fillResponseBuilder = fillResponseBuilder,
parser = parser,
policyManager = policyManager,
saveInfoBuilder = saveInfoBuilder,
settingsRepository = settingsRepository,
)
@Provides
@ -107,4 +116,13 @@ object AutofillModule {
@Provides
fun providesFillResponseBuilder(): FillResponseBuilder = FillResponseBuilderImpl()
@Singleton
@Provides
fun providesSaveInfoBuilder(
settingsRepository: SettingsRepository,
): SaveInfoBuilder =
SaveInfoBuilderImpl(
settingsRepository = settingsRepository,
)
}

View file

@ -1,25 +1,75 @@
package com.x8bit.bitwarden.data.autofill.model
import android.service.autofill.SaveInfo
import android.view.autofill.AutofillId
/**
* A partition of autofill data.
*/
sealed class AutofillPartition {
/**
* [AutofillId]s that are optional for save requests. For example, with cards we require a
* phone number too trigger the save request, other card data is optional.
*/
abstract val optionalSaveIds: List<AutofillId>
/**
* [AutofillId]s that are required for save requests. If there are no required fields present,
* then save requests aren't allowed.
*/
abstract val requiredSaveIds: List<AutofillId>
/**
* The autofill save associated with this [AutofillPartition].
*/
abstract val saveType: Int
/**
* The views that correspond to this partition.
*/
abstract val views: List<AutofillView>
/**
* Whether it is possible to perform a save request with this [AutofillPartition].
*/
val canPerformSaveRequest: Boolean
get() = requiredSaveIds.isNotEmpty()
/**
* The credit card [AutofillPartition] data.
*/
data class Card(
override val views: List<AutofillView.Card>,
) : AutofillPartition()
) : AutofillPartition() {
override val optionalSaveIds: List<AutofillId>
get() = views
.filter { it !is AutofillView.Card.Number }
.map { it.data.autofillId }
override val requiredSaveIds: List<AutofillId>
get() = views
.filterIsInstance<AutofillView.Card.Number>()
.map { it.data.autofillId }
override val saveType: Int
get() = SaveInfo.SAVE_DATA_TYPE_CREDIT_CARD
}
/**
* The login [AutofillPartition] data.
*/
data class Login(
override val views: List<AutofillView.Login>,
) : AutofillPartition()
) : AutofillPartition() {
override val optionalSaveIds: List<AutofillId>
get() = views
.filter { it !is AutofillView.Login.Password }
.map { it.data.autofillId }
override val requiredSaveIds: List<AutofillId>
get() = views
.filterIsInstance<AutofillView.Login.Password>()
.map { it.data.autofillId }
override val saveType: Int
get() = SaveInfo.SAVE_DATA_TYPE_PASSWORD
}
}

View file

@ -15,6 +15,7 @@ sealed class AutofillRequest {
val ignoreAutofillIds: List<AutofillId>,
val inlinePresentationSpecs: List<InlinePresentationSpec>,
val maxInlineSuggestionsCount: Int,
val packageName: String?,
val partition: AutofillPartition,
val uri: String?,
) : AutofillRequest()

View file

@ -12,10 +12,12 @@ sealed class AutofillView {
*
* @param autofillId The [AutofillId] associated with this view.
* @param isFocused Whether the view is currently focused.
* @param textValue A text value that represents the input present in the field.
*/
data class Data(
val autofillId: AutofillId,
val isFocused: Boolean,
val textValue: String?,
)
/**
@ -29,10 +31,14 @@ sealed class AutofillView {
sealed class Card : AutofillView() {
/**
* The expiration month [AutofillView] for the [Card] data partition.
* The expiration month [AutofillView] for the [Card] data partition. This implementation
* also has its own [monthValue] because it can be present in lists, in which case there
* is specialized logic for determining its [monthValue]. The [Data.textValue] is very
* likely going to be a very different value.
*/
data class ExpirationMonth(
override val data: Data,
val monthValue: String?,
) : Card()
/**

View file

@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData
import com.x8bit.bitwarden.data.autofill.util.buildPackageNameOrNull
import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull
import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs
import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount
@ -78,9 +79,12 @@ class AutofillParserImpl(
// Find the focused view.
val focusedView = autofillViews.firstOrNull { it.data.isFocused }
val uri = traversalDataList.buildUriOrNull(
val packageName = traversalDataList.buildPackageNameOrNull(
assistStructure = assistStructure,
)
val uri = traversalDataList.buildUriOrNull(
packageName = packageName,
)
val blockListedURIs = settingsRepository.blockedAutofillUris + BLOCK_LISTED_URIS
if (focusedView == null || blockListedURIs.contains(uri)) {
@ -122,6 +126,7 @@ class AutofillParserImpl(
inlinePresentationSpecs = inlinePresentationSpecs,
ignoreAutofillIds = ignoreAutofillIds,
maxInlineSuggestionsCount = maxInlineSuggestionsCount,
packageName = packageName,
partition = partition,
uri = uri,
)

View file

@ -3,6 +3,8 @@ package com.x8bit.bitwarden.data.autofill.processor
import android.os.CancellationSignal
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import android.service.autofill.SaveCallback
import android.service.autofill.SaveRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
/**
@ -12,9 +14,10 @@ interface AutofillProcessor {
/**
* Process the autofill [FillRequest] and invoke the [fillCallback] with the result.
*
* @param autofillAppInfo app data that is required for the autofill [request] processing.
* @param fillCallback the callback to invoke when the [request] has been processed.
* @param request the request data from the OS that contains data about the autofill hierarchy.
* @param autofillAppInfo App data that is required for the autofill [request] processing.
* @param cancellationSignal A signal to listen to for cancellations.
* @param fillCallback The callback to invoke when the [request] has been processed.
* @param request The request data from the OS that contains data about the autofill hierarchy.
*/
fun processFillRequest(
autofillAppInfo: AutofillAppInfo,
@ -22,4 +25,17 @@ interface AutofillProcessor {
fillCallback: FillCallback,
request: FillRequest,
)
/**
* Process the autofill [SaveRequest] and invoke the [saveCallback] with the result.
*
* @param autofillAppInfo App data that is required for the autofill [request] processing.
* @param request The request data from the OS that contains data about the autofill hierarchy.
* @param saveCallback The callback to invoke when the [request] has been processed.
*/
fun processSaveRequest(
autofillAppInfo: AutofillAppInfo,
request: SaveRequest,
saveCallback: SaveCallback,
)
}

View file

@ -3,12 +3,20 @@ package com.x8bit.bitwarden.data.autofill.processor
import android.os.CancellationSignal
import android.service.autofill.FillCallback
import android.service.autofill.FillRequest
import android.service.autofill.SaveCallback
import android.service.autofill.SaveRequest
import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilder
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.builder.SaveInfoBuilder
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
import com.x8bit.bitwarden.data.autofill.util.createAutofillSavedItemIntentSender
import com.x8bit.bitwarden.data.autofill.util.toAutofillSaveItem
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
@ -17,11 +25,15 @@ import kotlinx.coroutines.launch
* The default implementation of [AutofillProcessor]. Its purpose is to handle autofill related
* processing.
*/
@Suppress("LongParameterList")
class AutofillProcessorImpl(
dispatcherManager: DispatcherManager,
private val policyManager: PolicyManager,
private val filledDataBuilder: FilledDataBuilder,
private val fillResponseBuilder: FillResponseBuilder,
private val parser: AutofillParser,
private val saveInfoBuilder: SaveInfoBuilder,
private val settingsRepository: SettingsRepository,
) : AutofillProcessor {
/**
@ -45,6 +57,47 @@ class AutofillProcessorImpl(
)
}
override fun processSaveRequest(
autofillAppInfo: AutofillAppInfo,
request: SaveRequest,
saveCallback: SaveCallback,
) {
if (settingsRepository.isAutofillSavePromptDisabled) {
saveCallback.onSuccess()
return
}
if (policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP).any()) {
saveCallback.onSuccess()
return
}
request
.fillContexts
.lastOrNull()
?.structure
?.let { assistStructure ->
val autofillRequest = parser.parse(
assistStructure = assistStructure,
autofillAppInfo = autofillAppInfo,
)
when (autofillRequest) {
is AutofillRequest.Fillable -> {
val intentSender = createAutofillSavedItemIntentSender(
autofillAppInfo = autofillAppInfo,
autofillSaveItem = autofillRequest.toAutofillSaveItem(),
)
saveCallback.onSuccess(intentSender)
}
AutofillRequest.Unfillable -> saveCallback.onSuccess()
}
}
?: saveCallback.onSuccess()
}
/**
* Process the [fillRequest] and invoke the [FillCallback] with the response.
*/
@ -65,11 +118,18 @@ class AutofillProcessorImpl(
val filledData = filledDataBuilder.build(
autofillRequest = autofillRequest,
)
val saveInfo = saveInfoBuilder.build(
autofillAppInfo = autofillAppInfo,
autofillPartition = autofillRequest.partition,
fillRequest = fillRequest,
packageName = autofillRequest.packageName,
)
// Load the [filledData] into a [FillResponse].
// Load the filledData and saveInfo into a FillResponse.
val response = fillResponseBuilder.build(
autofillAppInfo = autofillAppInfo,
filledData = filledData,
saveInfo = saveInfo,
)
fillCallback.onSuccess(response)

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.autofill.util
import android.view.autofill.AutofillValue
/**
* Extract a month value from this [AutofillValue].
*/
@Suppress("MagicNumber")
fun AutofillValue.extractMonthValue(
autofillOptions: List<String>?,
): String? =
when {
this.isList && autofillOptions?.size == 13 -> {
this.listValue.toString()
}
this.isList && autofillOptions?.size == 12 -> {
(this.listValue + 1).toString()
}
this.isText -> this.textValue.toString()
else -> null
}
/**
* Extract a text value from this [AutofillValue].
*/
fun AutofillValue.extractTextValue(): String? = this
.textValue
.takeIf { it.isNotBlank() }
?.toString()

View file

@ -76,13 +76,13 @@ fun createTotpCopyIntentSender(
}
/**
* Creates an [Intent] in order to start the cipher saving process during the autofill flow.
* Creates an [IntentSender] in order to start the cipher saving process during the autofill flow.
*/
fun createAutofillSavedItemIntent(
fun createAutofillSavedItemIntentSender(
autofillAppInfo: AutofillAppInfo,
autofillSaveItem: AutofillSaveItem,
): Intent =
Intent(
): IntentSender {
val intent = Intent(
autofillAppInfo.context,
MainActivity::class.java,
)
@ -91,6 +91,16 @@ fun createAutofillSavedItemIntent(
putExtra(AUTOFILL_SAVE_ITEM_DATA_KEY, autofillSaveItem)
}
return PendingIntent
.getActivity(
autofillAppInfo.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.

View file

@ -0,0 +1,62 @@
package com.x8bit.bitwarden.data.autofill.util
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillView
/**
* The text value representation of the expiration month from the [AutofillPartition.Card].
*/
val AutofillPartition.Card.expirationMonthSaveValue: String?
get() = this
.views
.firstOrNull { it is AutofillView.Card.ExpirationMonth && it.monthValue != null }
?.data
?.textValue
/**
* The text value representation of the year from the [AutofillPartition.Card].
*/
val AutofillPartition.Card.expirationYearSaveValue: String?
get() = this
.extractNonNullTextValueOrNull { it is AutofillView.Card.ExpirationYear }
/**
* The text value representation of the card number from the [AutofillPartition.Card].
*/
val AutofillPartition.Card.numberSaveValue: String?
get() = this
.extractNonNullTextValueOrNull { it is AutofillView.Card.Number }
/**
* The text value representation of the security code from the [AutofillPartition.Card].
*/
val AutofillPartition.Card.securityCodeSaveValue: String?
get() = this
.extractNonNullTextValueOrNull { it is AutofillView.Card.SecurityCode }
/**
* The text value representation of the password from the [AutofillPartition.Login].
*/
val AutofillPartition.Login.passwordSaveValue: String?
get() = this
.extractNonNullTextValueOrNull { it is AutofillView.Login.Password }
/**
* The text value representation of the username from the [AutofillPartition.Login].
*/
val AutofillPartition.Login.usernameSaveValue: String?
get() = this
.extractNonNullTextValueOrNull { it is AutofillView.Login.Username }
/**
* Search [AutofillPartition.views] for an [AutofillView] that matches [condition] and has a
* non-null text value then return that text value.
*/
private fun AutofillPartition.extractNonNullTextValueOrNull(
condition: (AutofillView) -> Boolean,
): String? =
this
.views
.firstOrNull { condition(it) && it.data.textValue != null }
?.data
?.textValue

View file

@ -0,0 +1,35 @@
package com.x8bit.bitwarden.data.autofill.util
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
/**
* Convert the [AutofillRequest.Fillable] to an [AutofillSaveItem].
*/
fun AutofillRequest.Fillable.toAutofillSaveItem(): AutofillSaveItem =
when (this.partition) {
is AutofillPartition.Card -> {
AutofillSaveItem.Card(
number = partition.numberSaveValue,
expirationMonth = partition.expirationMonthSaveValue,
expirationYear = partition.expirationYearSaveValue,
securityCode = partition.securityCodeSaveValue,
)
}
is AutofillPartition.Login -> {
// Skip the scheme for the save value.
val uri = this
.uri
?.replace("https://", "")
?.replace("http://", "")
?.replace("androidapp://", "")
AutofillSaveItem.Login(
username = partition.usernameSaveValue,
password = partition.passwordSaveValue,
uri = uri,
)
}
}

View file

@ -79,6 +79,7 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? =
val autofillViewData = AutofillView.Data(
autofillId = nonNullAutofillId,
isFocused = isFocused,
textValue = this.autofillValue?.extractTextValue(),
)
buildAutofillView(
autofillViewData = autofillViewData,
@ -97,8 +98,18 @@ private fun AssistStructure.ViewNode.buildAutofillView(
supportedHint: String?,
): AutofillView? = when {
supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> {
val autofillOptions = this
.autofillOptions
?.map { it.toString() }
val monthValue = this
.autofillValue
?.extractMonthValue(
autofillOptions = autofillOptions,
)
AutofillView.Card.ExpirationMonth(
data = autofillViewData,
monthValue = monthValue,
)
}

View file

@ -10,16 +10,14 @@ import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
private const val ANDROID_APP_SCHEME: String = "androidapp"
/**
* Try and build a URI. The try progression looks like this:
* 1. Try searching traversal data for website URIs.
* 2. Try searching traversal data for package names, if one is found, convert it into a URI.
* 3. Try extracting a package name from [assistStructure], if one is found, convert it into a URI.
* Try and build a URI. First, try building a website from the list of [ViewNodeTraversalData]. If
* that fails, try converting [packageName] into an Android app URI.
*/
@Suppress("ReturnCount")
fun List<ViewNodeTraversalData>.buildUriOrNull(
assistStructure: AssistStructure,
packageName: String?,
): String? {
// Search list of [ViewNodeTraversalData] for a website URI.
// Search list of ViewNodeTraversalData for a website URI.
this
.firstOrNull { it.website != null }
?.website
@ -27,26 +25,32 @@ fun List<ViewNodeTraversalData>.buildUriOrNull(
return websiteUri
}
// Search list of [ViewNodeTraversalData] for a valid package name.
this
// If the package name is available, build a URI out of that.
return packageName
?.let { nonNullPackageName ->
buildUri(
domain = nonNullPackageName,
scheme = ANDROID_APP_SCHEME,
)
}
}
/**
* Try and build a package name. First, try searching traversal data for package names. If that
* fails, try extracting a package name from [assistStructure].
*/
fun List<ViewNodeTraversalData>.buildPackageNameOrNull(
assistStructure: AssistStructure,
): String? {
// Search list of ViewNodeTraversalData for a valid package name.
val traversalDataPackageName = this
.firstOrNull { it.idPackage != null }
?.idPackage
?.let { packageName ->
return buildUri(
domain = packageName,
scheme = ANDROID_APP_SCHEME,
)
}
// Try getting the package name from the [AssistStructure] as a last ditch effort.
return assistStructure
.buildPackageNameOrNull()
?.let { packageName ->
buildUri(
domain = packageName,
scheme = ANDROID_APP_SCHEME,
)
}
// Try getting the package name from the AssistStructure as a last ditch effort.
return traversalDataPackageName
?: assistStructure
.buildPackageNameOrNull()
}
/**

View file

@ -2,8 +2,9 @@
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android"
android:supportsInlineSuggestions="true">
<!--
These compatibility packages must be kept in sync with
/xml/autofill_service_configuration.xml
"Maintain alphabetical order for these compatibility packages and ensure synchronization with
both /xml/autofill_service_configuration.xml and the COMPAT_BROWSERS list within
SaveInfoBuilderImpl.kt."
-->
<compatibility-package
android:name="alook.browser"

View file

@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8" ?>
<autofill-service xmlns:android="http://schemas.android.com/apk/res/android">
<!--
These compatibility packages must be kept in sync with
/xml-v30/autofill_service_configuration.xml
"Maintain alphabetical order for these compatibility packages and ensure synchronization with
both /xml-v30/autofill_service_configuration.xml and the COMPAT_BROWSERS list within
SaveInfoBuilderImpl.kt."
-->
<compatibility-package
android:name="alook.browser"

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.content.IntentSender
import android.service.autofill.Dataset
import android.service.autofill.FillResponse
import android.service.autofill.SaveInfo
import android.view.autofill.AutofillId
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
@ -68,6 +69,7 @@ class FillResponseBuilderTest {
every { this@mockk.filledItems } returns listOf(mockk())
every { this@mockk.autofillCipher } returns autofillCipherTotpDisabled
}
private val saveInfo: SaveInfo = mockk()
@BeforeEach
fun setup() {
@ -117,14 +119,16 @@ class FillResponseBuilderTest {
val actual = fillResponseBuilder.build(
autofillAppInfo = appInfo,
filledData = filledData,
saveInfo = saveInfo,
)
// Verify
assertNull(actual)
}
@Suppress("MaxLineLength")
@Test
fun `build should apply FilledPartitions with filledItems and ignore ignoreAutofillIds`() {
fun `build should apply FilledPartitions with filledItems, ignore ignoreAutofillIds, and set valid SaveInfo`() {
// Setup
val ignoredAutofillIdOne: AutofillId = mockk()
val ignoredAutofillIdTwo: AutofillId = mockk()
@ -147,6 +151,116 @@ class FillResponseBuilderTest {
data = AutofillView.Data(
autofillId = mockk(),
isFocused = true,
textValue = null,
),
),
),
),
uri = null,
vaultItemInlinePresentationSpec = null,
isVaultLocked = false,
)
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
every {
filledData.buildVaultItemDataset(
autofillAppInfo = appInfo,
)
} returns vaultItemDataSet
mockBuilder<FillResponse.Builder> {
it.addDataset(dataset)
it.addDataset(vaultItemDataSet)
}
mockBuilder<FillResponse.Builder> {
it.setIgnoredIds(
ignoredAutofillIdOne,
ignoredAutofillIdTwo,
)
}
mockBuilder<FillResponse.Builder> {
it.setSaveInfo(saveInfo)
}
// Test
val actual = fillResponseBuilder.build(
autofillAppInfo = appInfo,
filledData = filledData,
saveInfo = saveInfo,
)
// Verify
assertEquals(fillResponse, actual)
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(vaultItemDataSet)
anyConstructed<FillResponse.Builder>().setIgnoredIds(
ignoredAutofillIdOne,
ignoredAutofillIdTwo,
)
anyConstructed<FillResponse.Builder>().setSaveInfo(saveInfo)
}
verify(exactly = 3) {
anyConstructed<FillResponse.Builder>().addDataset(dataset)
}
}
@Suppress("MaxLineLength")
@Test
fun `build should apply FilledPartitions with filledItems, ignore ignoreAutofillIds, and skip valid SaveInfo`() {
// Setup
val ignoredAutofillIdOne: AutofillId = mockk()
val ignoredAutofillIdTwo: AutofillId = mockk()
val ignoreAutofillIds = listOf(
ignoredAutofillIdOne,
ignoredAutofillIdTwo,
)
val filledPartitions = listOf(
filledPartitionOne,
filledPartitionTwo,
filledPartitionThree,
filledPartitionFour,
)
val filledData = FilledData(
filledPartitions = filledPartitions,
ignoreAutofillIds = ignoreAutofillIds,
originalPartition = AutofillPartition.Login(
views = listOf(
AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = mockk(),
isFocused = true,
textValue = null,
),
),
),
@ -193,6 +307,7 @@ class FillResponseBuilderTest {
val actual = fillResponseBuilder.build(
autofillAppInfo = appInfo,
filledData = filledData,
saveInfo = null,
)
// Verify

View file

@ -36,6 +36,7 @@ class FilledDataBuilderTest {
private val autofillViewData = AutofillView.Data(
autofillId = autofillId,
isFocused = false,
textValue = null,
)
@BeforeEach
@ -83,6 +84,7 @@ class FilledDataBuilderTest {
ignoreAutofillIds = ignoreAutofillIds,
inlinePresentationSpecs = emptyList(),
maxInlineSuggestionsCount = 0,
packageName = null,
partition = autofillPartition,
uri = URI,
)
@ -153,6 +155,7 @@ class FilledDataBuilderTest {
ignoreAutofillIds = ignoreAutofillIds,
inlinePresentationSpecs = emptyList(),
maxInlineSuggestionsCount = 0,
packageName = null,
partition = autofillPartition,
uri = null,
)
@ -196,6 +199,7 @@ class FilledDataBuilderTest {
)
val autofillViewExpirationMonth = AutofillView.Card.ExpirationMonth(
data = autofillViewData,
monthValue = null,
)
val autofillViewExpirationYear = AutofillView.Card.ExpirationYear(
data = autofillViewData,
@ -216,6 +220,7 @@ class FilledDataBuilderTest {
ignoreAutofillIds = ignoreAutofillIds,
inlinePresentationSpecs = emptyList(),
maxInlineSuggestionsCount = 0,
packageName = null,
partition = autofillPartition,
uri = URI,
)
@ -302,6 +307,7 @@ class FilledDataBuilderTest {
inlinePresentationSpec,
),
maxInlineSuggestionsCount = 3,
packageName = null,
partition = autofillPartition,
uri = URI,
)

View file

@ -0,0 +1,305 @@
package com.x8bit.bitwarden.data.autofill.builder
import android.os.Build
import android.service.autofill.FillRequest
import android.service.autofill.SaveInfo
import android.view.autofill.AutofillId
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.util.mockBuilder
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class SaveInfoBuilderTest {
private lateinit var saveInfoBuilder: SaveInfoBuilder
private val settingsRepository: SettingsRepository = mockk()
private val autofillAppInfo: AutofillAppInfo = mockk()
private val fillRequest: FillRequest = mockk()
private val autofillIdOptional: AutofillId = mockk()
private val autofillViewDataOptional = AutofillView.Data(
autofillId = autofillIdOptional,
isFocused = true,
textValue = null,
)
private val autofillIdValid: AutofillId = mockk()
private val autofillViewDataValid = AutofillView.Data(
autofillId = autofillIdValid,
isFocused = true,
textValue = null,
)
private val autofillPartitionCard: AutofillPartition.Card = AutofillPartition.Card(
views = listOf(
AutofillView.Card.Number(
data = autofillViewDataValid,
),
AutofillView.Card.SecurityCode(
data = autofillViewDataOptional,
),
),
)
private val autofillPartitionLogin: AutofillPartition.Login = AutofillPartition.Login(
views = listOf(
AutofillView.Login.Password(
data = autofillViewDataValid,
),
AutofillView.Login.Username(
data = autofillViewDataOptional,
),
),
)
private val saveInfo: SaveInfo = mockk()
@BeforeEach
fun setup() {
mockkConstructor(SaveInfo.Builder::class)
saveInfoBuilder = SaveInfoBuilderImpl(
settingsRepository = settingsRepository,
)
every { anyConstructed<SaveInfo.Builder>().build() } returns saveInfo
}
@AfterEach
fun teardown() {
unmockkConstructor(SaveInfo.Builder::class)
}
@Test
fun `build should return null if autofill disabled`() {
// Setup
every { settingsRepository.isAutofillSavePromptDisabled } returns true
// Test
val actual = saveInfoBuilder.build(
autofillAppInfo = autofillAppInfo,
autofillPartition = autofillPartitionCard,
fillRequest = fillRequest,
packageName = PACKAGE_NAME,
)
// Verify
assertNull(actual)
}
@Test
fun `build should return null if autofill enabled and can't perform autofill`() {
// Setup
every { settingsRepository.isAutofillSavePromptDisabled } returns false
// Test
val actual = saveInfoBuilder.build(
autofillAppInfo = autofillAppInfo,
autofillPartition = AUTOFILL_PARTITION_LOGIN_EMPTY,
fillRequest = fillRequest,
packageName = PACKAGE_NAME,
)
// Verify
assertNull(actual)
}
@Suppress("MaxLineLength")
@Test
fun `build should return null if autofill possible but flags indicate compat mode and is login`() {
// Setup
every { settingsRepository.isAutofillSavePromptDisabled } returns false
every { fillRequest.flags } returns FillRequest.FLAG_COMPATIBILITY_MODE_REQUEST
every { autofillAppInfo.sdkInt } returns Build.VERSION_CODES.TIRAMISU
// Test
val actual = saveInfoBuilder.build(
autofillAppInfo = autofillAppInfo,
autofillPartition = autofillPartitionLogin,
fillRequest = fillRequest,
packageName = PACKAGE_NAME,
)
// Verify
assertNull(actual)
}
@Suppress("MaxLineLength")
@Test
fun `build should return null if autofill possible but package name is in compat list and is login`() {
// Setup
every { settingsRepository.isAutofillSavePromptDisabled } returns false
every { autofillAppInfo.sdkInt } returns Build.VERSION_CODES.P
// Test
COMPAT_BROWSERS
.forEach { packageName ->
val actual = saveInfoBuilder.build(
autofillAppInfo = autofillAppInfo,
autofillPartition = autofillPartitionLogin,
fillRequest = fillRequest,
packageName = packageName,
)
// Verify
assertNull(actual)
}
}
@Suppress("MaxLineLength")
@Test
fun `build should return SaveInfo with flag set if autofill possible, flags indicate compat mode, and is card`() {
// Setup
every { settingsRepository.isAutofillSavePromptDisabled } returns false
every { fillRequest.flags } returns FillRequest.FLAG_COMPATIBILITY_MODE_REQUEST
every { autofillAppInfo.sdkInt } returns Build.VERSION_CODES.TIRAMISU
mockBuilder<SaveInfo.Builder> {
it.setOptionalIds(arrayOf(autofillIdOptional))
}
mockBuilder<SaveInfo.Builder> {
it.setFlags(SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE)
}
// Test
val actual = saveInfoBuilder.build(
autofillAppInfo = autofillAppInfo,
autofillPartition = autofillPartitionCard,
fillRequest = fillRequest,
packageName = PACKAGE_NAME,
)
// Verify
assertEquals(saveInfo, actual)
verify(exactly = 1) {
anyConstructed<SaveInfo.Builder>().setOptionalIds(arrayOf(autofillIdOptional))
anyConstructed<SaveInfo.Builder>().setFlags(SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE)
}
}
@Suppress("MaxLineLength")
@Test
fun `build should return SaveInfo if autofill possible, packageName is not in compat list, and is login`() {
// Setup
every { settingsRepository.isAutofillSavePromptDisabled } returns false
every { autofillAppInfo.sdkInt } returns Build.VERSION_CODES.P
mockBuilder<SaveInfo.Builder> {
it.setOptionalIds(arrayOf(autofillIdOptional))
}
// Test
val actual = saveInfoBuilder.build(
autofillAppInfo = autofillAppInfo,
autofillPartition = autofillPartitionLogin,
fillRequest = fillRequest,
packageName = PACKAGE_NAME,
)
// Verify
assertEquals(saveInfo, actual)
verify(exactly = 1) {
anyConstructed<SaveInfo.Builder>().setOptionalIds(arrayOf(autofillIdOptional))
}
}
}
private const val PACKAGE_NAME: String = "com.google"
private val AUTOFILL_PARTITION_LOGIN_EMPTY: AutofillPartition.Login = AutofillPartition.Login(
views = listOf(),
)
private val COMPAT_BROWSERS: List<String> = listOf(
"alook.browser",
"alook.browser.google",
"app.vanadium.browser",
"com.amazon.cloud9",
"com.android.browser",
"com.android.chrome",
"com.android.htmlviewer",
"com.avast.android.secure.browser",
"com.avg.android.secure.browser",
"com.brave.browser",
"com.brave.browser_beta",
"com.brave.browser_default",
"com.brave.browser_dev",
"com.brave.browser_nightly",
"com.chrome.beta",
"com.chrome.canary",
"com.chrome.dev",
"com.cookiegames.smartcookie",
"com.cookiejarapps.android.smartcookieweb",
"com.ecosia.android",
"com.google.android.apps.chrome",
"com.google.android.apps.chrome_dev",
"com.google.android.captiveportallogin",
"com.iode.firefox",
"com.jamal2367.styx",
"com.kiwibrowser.browser",
"com.kiwibrowser.browser.dev",
"com.lemurbrowser.exts",
"com.microsoft.emmx",
"com.microsoft.emmx.beta",
"com.microsoft.emmx.canary",
"com.microsoft.emmx.dev",
"com.mmbox.browser",
"com.mmbox.xbrowser",
"com.mycompany.app.soulbrowser",
"com.naver.whale",
"com.neeva.app",
"com.opera.browser",
"com.opera.browser.beta",
"com.opera.gx",
"com.opera.mini.native",
"com.opera.mini.native.beta",
"com.opera.touch",
"com.qflair.browserq",
"com.qwant.liberty",
"com.rainsee.create",
"com.sec.android.app.sbrowser",
"com.sec.android.app.sbrowser.beta",
"com.stoutner.privacybrowser.free",
"com.stoutner.privacybrowser.standard",
"com.vivaldi.browser",
"com.vivaldi.browser.snapshot",
"com.vivaldi.browser.sopranos",
"com.yandex.browser",
"com.yjllq.internet",
"com.yjllq.kito",
"com.yujian.ResideMenuDemo",
"com.z28j.feel",
"idm.internet.download.manager",
"idm.internet.download.manager.adm.lite",
"idm.internet.download.manager.plus",
"io.github.forkmaintainers.iceraven",
"mark.via",
"mark.via.gp",
"net.dezor.browser",
"net.slions.fulguris.full.download",
"net.slions.fulguris.full.download.debug",
"net.slions.fulguris.full.playstore",
"net.slions.fulguris.full.playstore.debug",
"org.adblockplus.browser",
"org.adblockplus.browser.beta",
"org.bromite.bromite",
"org.bromite.chromium",
"org.chromium.chrome",
"org.codeaurora.swe.browser",
"org.cromite.cromite",
"org.gnu.icecat",
"org.mozilla.fenix",
"org.mozilla.fenix.nightly",
"org.mozilla.fennec_aurora",
"org.mozilla.fennec_fdroid",
"org.mozilla.firefox",
"org.mozilla.firefox_beta",
"org.mozilla.reference.browser",
"org.mozilla.rocket",
"org.torproject.torbrowser",
"org.torproject.torbrowser_alpha",
"org.ungoogled.chromium.extensions.stable",
"org.ungoogled.chromium.stable",
"us.spotco.fennec_dos",
)

View file

@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData
import com.x8bit.bitwarden.data.autofill.util.buildPackageNameOrNull
import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull
import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs
import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount
@ -115,7 +116,10 @@ class AutofillParserTests {
isInlineAutofillEnabled = false,
)
} returns 0
every { any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure) } returns URI
every {
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
} returns PACKAGE_NAME
every { any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME) } returns URI
parser = AutofillParserImpl(
settingsRepository = settingsRepository,
)
@ -184,7 +188,9 @@ class AutofillParserTests {
data = AutofillView.Data(
autofillId = parentAutofillId,
isFocused = true,
textValue = null,
),
monthValue = null,
)
val parentViewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillHints } returns arrayOf(parentAutofillHint)
@ -206,6 +212,7 @@ class AutofillParserTests {
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
partition = autofillPartition,
packageName = PACKAGE_NAME,
uri = URI,
)
every { assistStructure.windowNodeCount } returns 1
@ -228,7 +235,8 @@ class AutofillParserTests {
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = true,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
}
}
@ -240,12 +248,15 @@ class AutofillParserTests {
data = AutofillView.Data(
autofillId = cardAutofillId,
isFocused = true,
textValue = null,
),
monthValue = null,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
isFocused = false,
textValue = null,
),
)
val autofillPartition = AutofillPartition.Card(
@ -255,6 +266,7 @@ class AutofillParserTests {
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
packageName = PACKAGE_NAME,
partition = autofillPartition,
uri = URI,
)
@ -278,7 +290,8 @@ class AutofillParserTests {
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = true,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
}
}
@ -290,12 +303,15 @@ class AutofillParserTests {
data = AutofillView.Data(
autofillId = cardAutofillId,
isFocused = false,
textValue = null,
),
monthValue = null,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
isFocused = true,
textValue = null,
),
)
val autofillPartition = AutofillPartition.Login(
@ -305,6 +321,7 @@ class AutofillParserTests {
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
packageName = PACKAGE_NAME,
partition = autofillPartition,
uri = URI,
)
@ -328,7 +345,8 @@ class AutofillParserTests {
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = true,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
}
}
@ -340,12 +358,15 @@ class AutofillParserTests {
data = AutofillView.Data(
autofillId = cardAutofillId,
isFocused = true,
textValue = null,
),
monthValue = null,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
isFocused = true,
textValue = null,
),
)
val autofillPartition = AutofillPartition.Card(
@ -355,6 +376,7 @@ class AutofillParserTests {
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
packageName = PACKAGE_NAME,
partition = autofillPartition,
uri = URI,
)
@ -378,7 +400,8 @@ class AutofillParserTests {
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = true,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
}
}
@ -391,12 +414,15 @@ class AutofillParserTests {
data = AutofillView.Data(
autofillId = cardAutofillId,
isFocused = true,
textValue = null,
),
monthValue = null,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
isFocused = true,
textValue = null,
),
)
val autofillPartition = AutofillPartition.Card(
@ -406,6 +432,7 @@ class AutofillParserTests {
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = emptyList(),
maxInlineSuggestionsCount = 0,
packageName = PACKAGE_NAME,
partition = autofillPartition,
uri = URI,
)
@ -429,7 +456,8 @@ class AutofillParserTests {
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = false,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
}
}
@ -442,12 +470,15 @@ class AutofillParserTests {
data = AutofillView.Data(
autofillId = cardAutofillId,
isFocused = true,
textValue = null,
),
monthValue = null,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
isFocused = true,
textValue = null,
),
)
val autofillPartition = AutofillPartition.Card(
@ -457,6 +488,7 @@ class AutofillParserTests {
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = emptyList(),
maxInlineSuggestionsCount = 0,
packageName = PACKAGE_NAME,
partition = autofillPartition,
uri = URI,
)
@ -480,7 +512,8 @@ class AutofillParserTests {
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = false,
)
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
}
}
@ -492,12 +525,15 @@ class AutofillParserTests {
data = AutofillView.Data(
autofillId = cardAutofillId,
isFocused = true,
textValue = null,
),
monthValue = null,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
isFocused = true,
textValue = null,
),
)
val remoteBlockList = listOf(
@ -512,7 +548,7 @@ class AutofillParserTests {
fun testBlockListedUri(blockListedUri: String) {
// Setup
every {
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
} returns blockListedUri
// Test
@ -531,7 +567,8 @@ class AutofillParserTests {
// Verify all tests
verify(exactly = BLOCK_LISTED_URIS.size + remoteBlockList.size) {
any<List<ViewNodeTraversalData>>().buildUriOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
}
}
@ -554,5 +591,6 @@ private val BLOCK_LISTED_URIS: List<String> = listOf(
)
private const val ID_PACKAGE: String = "com.x8bit.bitwarden"
private const val MAX_INLINE_SUGGESTION_COUNT: Int = 42
private const val PACKAGE_NAME: String = "com.google"
private const val URI: String = "androidapp://com.google"
private const val WEBSITE: String = "https://www.google.com"

View file

@ -1,23 +1,39 @@
package com.x8bit.bitwarden.data.autofill.processor
import android.app.assist.AssistStructure
import android.content.IntentSender
import android.os.CancellationSignal
import android.service.autofill.FillCallback
import android.service.autofill.FillContext
import android.service.autofill.FillRequest
import android.service.autofill.FillResponse
import android.service.autofill.SaveCallback
import android.service.autofill.SaveInfo
import android.service.autofill.SaveRequest
import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilder
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.builder.SaveInfoBuilder
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
import com.x8bit.bitwarden.data.autofill.util.createAutofillSavedItemIntentSender
import com.x8bit.bitwarden.data.autofill.util.toAutofillSaveItem
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.confirmVerified
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -35,6 +51,9 @@ class AutofillProcessorTest {
private val filledDataBuilder: FilledDataBuilder = mockk()
private val fillResponseBuilder: FillResponseBuilder = mockk()
private val parser: AutofillParser = mockk()
private val policyManager: PolicyManager = mockk()
private val saveInfoBuilder: SaveInfoBuilder = mockk()
private val settingsRepository: SettingsRepository = mockk()
private val testDispatcher = UnconfinedTestDispatcher()
private val appInfo: AutofillAppInfo = AutofillAppInfo(
@ -46,6 +65,8 @@ class AutofillProcessorTest {
@BeforeEach
fun setup() {
mockkStatic(::createAutofillSavedItemIntentSender)
mockkStatic(AutofillRequest.Fillable::toAutofillSaveItem)
every { dispatcherManager.unconfined } returns testDispatcher
autofillProcessor = AutofillProcessorImpl(
@ -53,6 +74,9 @@ class AutofillProcessorTest {
filledDataBuilder = filledDataBuilder,
fillResponseBuilder = fillResponseBuilder,
parser = parser,
policyManager = policyManager,
saveInfoBuilder = saveInfoBuilder,
settingsRepository = settingsRepository,
)
}
@ -61,13 +85,8 @@ class AutofillProcessorTest {
verify(exactly = 1) {
dispatcherManager.unconfined
}
confirmVerified(
cancellationSignal,
dispatcherManager,
filledDataBuilder,
fillResponseBuilder,
parser,
)
unmockkStatic(::createAutofillSavedItemIntentSender)
unmockkStatic(AutofillRequest.Fillable::toAutofillSaveItem)
}
@Test
@ -117,7 +136,12 @@ class AutofillProcessorTest {
isVaultLocked = false,
)
val fillResponse: FillResponse = mockk()
val autofillRequest: AutofillRequest.Fillable = mockk()
val autofillPartition: AutofillPartition = mockk()
val autofillRequest: AutofillRequest.Fillable = mockk {
every { packageName } returns PACKAGE_NAME
every { partition } returns autofillPartition
}
val saveInfo: SaveInfo = mockk()
coEvery {
filledDataBuilder.build(
autofillRequest = autofillRequest,
@ -130,10 +154,19 @@ class AutofillProcessorTest {
fillRequest = fillRequest,
)
} returns autofillRequest
every {
saveInfoBuilder.build(
autofillAppInfo = appInfo,
autofillPartition = autofillPartition,
fillRequest = fillRequest,
packageName = PACKAGE_NAME,
)
} returns saveInfo
every {
fillResponseBuilder.build(
autofillAppInfo = appInfo,
filledData = filledData,
saveInfo = saveInfo,
)
} returns fillResponse
every { fillCallback.onSuccess(fillResponse) } just runs
@ -161,8 +194,198 @@ class AutofillProcessorTest {
fillResponseBuilder.build(
autofillAppInfo = appInfo,
filledData = filledData,
saveInfo = saveInfo,
)
fillCallback.onSuccess(fillResponse)
}
}
@Test
fun `processSaveRequest should invoke empty callback when autofill prompt disabled`() {
// Setup
val saveCallback: SaveCallback = mockk {
every { onSuccess() } just runs
}
val saveRequest: SaveRequest = mockk()
every { settingsRepository.isAutofillSavePromptDisabled } returns true
// Test
autofillProcessor.processSaveRequest(
autofillAppInfo = appInfo,
request = saveRequest,
saveCallback = saveCallback,
)
// Verify
verify(exactly = 1) {
settingsRepository.isAutofillSavePromptDisabled
saveCallback.onSuccess()
}
}
@Test
fun `processSaveRequest should invoke empty callback when personal ownership applies`() {
// Setup
val saveCallback: SaveCallback = mockk {
every { onSuccess() } just runs
}
val saveRequest: SaveRequest = mockk()
val policies: List<SyncResponseJson.Policy> = listOf(mockk())
every { settingsRepository.isAutofillSavePromptDisabled } returns false
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns policies
// Test
autofillProcessor.processSaveRequest(
autofillAppInfo = appInfo,
request = saveRequest,
saveCallback = saveCallback,
)
// Verify
verify(exactly = 1) {
settingsRepository.isAutofillSavePromptDisabled
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
saveCallback.onSuccess()
}
}
@Test
fun `processSaveRequest should invoke empty callback when no fill contexts`() {
// Setup
val saveCallback: SaveCallback = mockk {
every { onSuccess() } just runs
}
val saveRequest: SaveRequest = mockk {
every { fillContexts } returns emptyList()
}
every { settingsRepository.isAutofillSavePromptDisabled } returns false
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns emptyList()
// Test
autofillProcessor.processSaveRequest(
autofillAppInfo = appInfo,
request = saveRequest,
saveCallback = saveCallback,
)
// Verify
verify(exactly = 1) {
settingsRepository.isAutofillSavePromptDisabled
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
saveCallback.onSuccess()
}
}
@Suppress("MaxLineLength")
@Test
fun `processSaveRequest should invoke intentSender callback when autofill enabled, has fill contexts, and parser returns Fillable`() {
// Setup
val intentSender: IntentSender = mockk()
val saveCallback: SaveCallback = mockk {
every { onSuccess(intentSender) } just runs
}
val assistStructure: AssistStructure = mockk()
val fillContext: FillContext = mockk {
every { structure } returns assistStructure
}
val saveRequest: SaveRequest = mockk {
every { fillContexts } returns listOf(fillContext)
}
val autofillPartition: AutofillPartition = mockk()
val autofillSaveItem: AutofillSaveItem = mockk()
val autofillRequest: AutofillRequest.Fillable = mockk {
every { packageName } returns PACKAGE_NAME
every { partition } returns autofillPartition
every { toAutofillSaveItem() } returns autofillSaveItem
}
every { settingsRepository.isAutofillSavePromptDisabled } returns false
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns emptyList()
every {
parser.parse(
autofillAppInfo = appInfo,
assistStructure = assistStructure,
)
} returns autofillRequest
every {
createAutofillSavedItemIntentSender(
autofillAppInfo = appInfo,
autofillSaveItem = autofillSaveItem,
)
} returns intentSender
// Test
autofillProcessor.processSaveRequest(
autofillAppInfo = appInfo,
request = saveRequest,
saveCallback = saveCallback,
)
// Verify
verify(exactly = 1) {
settingsRepository.isAutofillSavePromptDisabled
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
parser.parse(
autofillAppInfo = appInfo,
assistStructure = assistStructure,
)
createAutofillSavedItemIntentSender(
autofillAppInfo = appInfo,
autofillSaveItem = autofillSaveItem,
)
saveCallback.onSuccess(intentSender)
}
}
@Suppress("MaxLineLength")
@Test
fun `processSaveRequest should invoke empty callback when autofill enabled, has fill contexts, and parser returns Unfillable`() {
// Setup
val saveCallback: SaveCallback = mockk {
every { onSuccess() } just runs
}
val assistStructure: AssistStructure = mockk()
val fillContext: FillContext = mockk {
every { structure } returns assistStructure
}
val saveRequest: SaveRequest = mockk {
every { fillContexts } returns listOf(fillContext)
}
val autofillRequest = AutofillRequest.Unfillable
every { settingsRepository.isAutofillSavePromptDisabled } returns false
every {
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
} returns emptyList()
every {
parser.parse(
autofillAppInfo = appInfo,
assistStructure = assistStructure,
)
} returns autofillRequest
// Test
autofillProcessor.processSaveRequest(
autofillAppInfo = appInfo,
request = saveRequest,
saveCallback = saveCallback,
)
// Verify
verify(exactly = 1) {
settingsRepository.isAutofillSavePromptDisabled
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
parser.parse(
autofillAppInfo = appInfo,
assistStructure = assistStructure,
)
saveCallback.onSuccess()
}
}
}
private const val PACKAGE_NAME: String = "com.google"

View file

@ -0,0 +1,349 @@
package com.x8bit.bitwarden.data.autofill.util
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class AutofillPartitionExtensionsTest {
private val autofillDataEmptyText: AutofillView.Data = AutofillView.Data(
autofillId = mockk(),
isFocused = false,
textValue = null,
)
private val autofillDataValidText: AutofillView.Data = AutofillView.Data(
autofillId = mockk(),
isFocused = false,
textValue = TEXT_VALUE,
)
@Test
fun `expirationMonthSaveValue should return null when no month views present`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.Number(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.expirationMonthSaveValue
// Verify
assertNull(actual)
}
@Test
fun `expirationMonthSaveValue should return null when has month view but no monthValue`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.ExpirationMonth(
data = autofillDataValidText,
monthValue = null,
),
),
)
// Test
val actual = autofillPartition.expirationMonthSaveValue
// Verify
assertNull(actual)
}
@Test
fun `expirationMonthSaveValue should return text value when has month view has monthValue`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.ExpirationMonth(
data = autofillDataValidText,
monthValue = TEXT_VALUE,
),
),
)
// Test
val actual = autofillPartition.expirationMonthSaveValue
// Verify
assertEquals(TEXT_VALUE, actual)
}
@Test
fun `expirationYearSaveValue should return null when no year views present`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.Number(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.expirationYearSaveValue
// Verify
assertNull(actual)
}
@Test
fun `expirationYearSaveValue should return null when has year view but no textValue`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.ExpirationYear(
data = autofillDataEmptyText,
),
),
)
// Test
val actual = autofillPartition.expirationYearSaveValue
// Verify
assertNull(actual)
}
@Test
fun `expirationYearSaveValue should return text value when has year view has textValue`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.ExpirationYear(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.expirationYearSaveValue
// Verify
assertEquals(TEXT_VALUE, actual)
}
@Test
fun `numberSaveValue should return null when no number views present`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.ExpirationYear(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.numberSaveValue
// Verify
assertNull(actual)
}
@Test
fun `numberSaveValue should return null when has number view but no textValue`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.Number(
data = autofillDataEmptyText,
),
),
)
// Test
val actual = autofillPartition.numberSaveValue
// Verify
assertNull(actual)
}
@Test
fun `numberSaveValue should return text value when has number view has textValue`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.Number(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.numberSaveValue
// Verify
assertEquals(TEXT_VALUE, actual)
}
@Test
fun `securityCodeSaveValue should return null when no code views present`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.ExpirationYear(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.securityCodeSaveValue
// Verify
assertNull(actual)
}
@Test
fun `securityCodeSaveValue should return null when has code view but no textValue`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.SecurityCode(
data = autofillDataEmptyText,
),
),
)
// Test
val actual = autofillPartition.securityCodeSaveValue
// Verify
assertNull(actual)
}
@Test
fun `securityCodeSaveValue should return text value when has code view has textValue`() {
// Setup
val autofillPartition = AutofillPartition.Card(
views = listOf(
AutofillView.Card.SecurityCode(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.securityCodeSaveValue
// Verify
assertEquals(TEXT_VALUE, actual)
}
@Test
fun `passwordSaveValue should return null when no password views present`() {
// Setup
val autofillPartition = AutofillPartition.Login(
views = listOf(
AutofillView.Login.Username(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.passwordSaveValue
// Verify
assertNull(actual)
}
@Test
fun `passwordSaveValue should return null when has password view but no textValue`() {
// Setup
val autofillPartition = AutofillPartition.Login(
views = listOf(
AutofillView.Login.Password(
data = autofillDataEmptyText,
),
),
)
// Test
val actual = autofillPartition.passwordSaveValue
// Verify
assertNull(actual)
}
@Test
fun `passwordSaveValue should return text value when has password view has textValue`() {
// Setup
val autofillPartition = AutofillPartition.Login(
views = listOf(
AutofillView.Login.Password(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.passwordSaveValue
// Verify
assertEquals(TEXT_VALUE, actual)
}
@Test
fun `usernameSaveValue should return null when no username views present`() {
// Setup
val autofillPartition = AutofillPartition.Login(
views = listOf(
AutofillView.Login.Password(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.usernameSaveValue
// Verify
assertNull(actual)
}
@Test
fun `usernameSaveValue should return null when has username view but no textValue`() {
// Setup
val autofillPartition = AutofillPartition.Login(
views = listOf(
AutofillView.Login.Username(
data = autofillDataEmptyText,
),
),
)
// Test
val actual = autofillPartition.passwordSaveValue
// Verify
assertNull(actual)
}
@Test
fun `usernameSaveValue should return text value when has username view has textValue`() {
// Setup
val autofillPartition = AutofillPartition.Login(
views = listOf(
AutofillView.Login.Username(
data = autofillDataValidText,
),
),
)
// Test
val actual = autofillPartition.usernameSaveValue
// Verify
assertEquals(TEXT_VALUE, actual)
}
}
private const val TEXT_VALUE: String = "TEXT_VALUE"

View file

@ -0,0 +1,97 @@
package com.x8bit.bitwarden.data.autofill.util
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
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 AutofillRequestExtensionsTest {
@BeforeEach
fun setup() {
mockkStatic(AUTOFILL_REQUEST_EXTENSIONS_PATH)
}
@AfterEach
fun teardown() {
unmockkStatic(AUTOFILL_REQUEST_EXTENSIONS_PATH)
}
@Test
fun `toAutofillSaveItem should return AutofillSaveItem Card when card partition`() {
// Setup
val autofillPartition: AutofillPartition.Card = mockk {
every { expirationMonthSaveValue } returns SAVE_VALUE_MONTH
every { expirationYearSaveValue } returns SAVE_VALUE_YEAR
every { numberSaveValue } returns SAVE_VALUE_NUMBER
every { securityCodeSaveValue } returns SAVE_VALUE_CODE
}
val autofillRequest: AutofillRequest.Fillable = mockk {
every { partition } returns autofillPartition
}
val expected = AutofillSaveItem.Card(
number = SAVE_VALUE_NUMBER,
expirationMonth = SAVE_VALUE_MONTH,
expirationYear = SAVE_VALUE_YEAR,
securityCode = SAVE_VALUE_CODE,
)
// Test
val actual = autofillRequest.toAutofillSaveItem()
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillSaveItem should return AutofillSaveItem Login when card partition`() {
RAW_URI_LIST
.forEach { rawUri ->
// Setup
val autofillPartition: AutofillPartition.Login = mockk {
every { usernameSaveValue } returns SAVE_VALUE_USERNAME
every { passwordSaveValue } returns SAVE_VALUE_PASSWORD
}
val autofillRequest: AutofillRequest.Fillable = mockk {
every { partition } returns autofillPartition
every { uri } returns rawUri
}
val expected = AutofillSaveItem.Login(
username = SAVE_VALUE_USERNAME,
password = SAVE_VALUE_PASSWORD,
uri = FINAL_URI,
)
// Test
val actual = autofillRequest.toAutofillSaveItem()
// Verify
assertEquals(expected, actual)
}
}
}
private const val AUTOFILL_REQUEST_EXTENSIONS_PATH =
"com.x8bit.bitwarden.data.autofill.util.AutofillPartitionExtensionsKt"
// CARD DATA
private const val SAVE_VALUE_CODE: String = "SAVE_VALUE_CODE"
private const val SAVE_VALUE_MONTH: String = "SAVE_VALUE_MONTH"
private const val SAVE_VALUE_NUMBER: String = "SAVE_VALUE_NUMBER"
private const val SAVE_VALUE_YEAR: String = "SAVE_VALUE_YEAR"
// LOGIN DATA
private const val SAVE_VALUE_PASSWORD: String = "SAVE_VALUE_PASSWORD"
private const val SAVE_VALUE_USERNAME: String = "SAVE_VALUE_USERNAME"
private const val FINAL_URI: String = "URI"
private val RAW_URI_LIST: List<String> = listOf(
"androidapp://URI",
"https://URI",
"http://URI",
)

View file

@ -0,0 +1,108 @@
package com.x8bit.bitwarden.data.autofill.util
import android.view.autofill.AutofillValue
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class AutofillValueExtensionsTest {
@Test
fun `extractMonthValue should return listValue when isList and options size 13`() {
// Setup
val autofillOptions = List(13) { "option-$it" }
val autofillValue: AutofillValue = mockk {
every { isList } returns true
every { listValue } returns LIST_VALUE
}
val expected = LIST_VALUE.toString()
// Test
val actual = autofillValue.extractMonthValue(autofillOptions)
// Verify
assertEquals(expected, actual)
}
@Test
fun `extractMonthValue should return listValue plus one when isList and options size 12`() {
// Setup
val autofillOptions = List(12) { "option-$it" }
val autofillValue: AutofillValue = mockk {
every { isList } returns true
every { listValue } returns LIST_VALUE
}
val expected = (LIST_VALUE + 1).toString()
// Test
val actual = autofillValue.extractMonthValue(autofillOptions)
// Verify
assertEquals(expected, actual)
}
@Test
fun `extractMonthValue should return textValue when isText`() {
// Setup
val autofillOptions = List(1) { "option-$it" }
val autofillValue: AutofillValue = mockk {
every { isList } returns false
every { isText } returns true
every { textValue } returns TEXT_VALUE
}
// Test
val actual = autofillValue.extractMonthValue(autofillOptions)
// Verify
assertEquals(TEXT_VALUE, actual)
}
@Test
fun `extractMonthValue should return null not list or text`() {
// Setup
val autofillOptions = List(1) { "option-$it" }
val autofillValue: AutofillValue = mockk {
every { isList } returns false
every { isText } returns false
}
// Test
val actual = autofillValue.extractMonthValue(autofillOptions)
// Verify
assertNull(actual)
}
@Test
fun `extractTextValue should return textValue when not blank`() {
// Setup
val autofillValue: AutofillValue = mockk {
every { textValue } returns TEXT_VALUE
}
// Test
val actual = autofillValue.extractTextValue()
// Verify
assertEquals(TEXT_VALUE, actual)
}
@Test
fun `extractTextValue should return null when not blank`() {
// Setup
val autofillValue: AutofillValue = mockk {
every { textValue } returns " "
}
// Test
val actual = autofillValue.extractTextValue()
// Verify
assertNull(actual)
}
}
private const val LIST_VALUE: Int = 5
private const val TEXT_VALUE: String = "TEXT_VALUE"

View file

@ -20,6 +20,7 @@ class AutofillViewExtensionsTest {
private val autofillViewData = AutofillView.Data(
autofillId = autofillId,
isFocused = false,
textValue = null,
)
@BeforeEach

View file

@ -70,6 +70,7 @@ class FilledDataExtensionsTest {
data = AutofillView.Data(
autofillId = autofillId,
isFocused = true,
textValue = null,
),
),
),

View file

@ -4,6 +4,7 @@ import android.app.assist.AssistStructure
import android.view.View
import android.view.ViewStructure.HtmlInfo
import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import io.mockk.every
import io.mockk.mockk
@ -23,10 +24,18 @@ class ViewNodeExtensionsTest {
private val autofillViewData = AutofillView.Data(
autofillId = expectedAutofillId,
isFocused = expectedIsFocused,
textValue = TEXT_VALUE,
)
private val testAutofillOptions = arrayOf(
"option one",
"option two",
)
private val testAutofillValue: AutofillValue = mockk()
private val viewNode: AssistStructure.ViewNode = mockk {
every { this@mockk.autofillId } returns expectedAutofillId
every { this@mockk.autofillOptions } returns testAutofillOptions
every { this@mockk.autofillValue } returns testAutofillValue
every { this@mockk.childCount } returns 0
every { this@mockk.inputType } returns 1
every { this@mockk.isFocused } returns expectedIsFocused
@ -38,6 +47,14 @@ class ViewNodeExtensionsTest {
mockkStatic(HtmlInfo::isPasswordField)
mockkStatic(Int::isPasswordInputType)
mockkStatic(Int::isUsernameInputType)
mockkStatic(AutofillValue::extractMonthValue)
mockkStatic(AutofillValue::extractTextValue)
every {
testAutofillValue.extractMonthValue(
autofillOptions = testAutofillOptions.toList(),
)
} returns MONTH_VALUE
every { testAutofillValue.extractTextValue() } returns TEXT_VALUE
}
@AfterEach
@ -46,6 +63,8 @@ class ViewNodeExtensionsTest {
unmockkStatic(HtmlInfo::isPasswordField)
unmockkStatic(Int::isPasswordInputType)
unmockkStatic(Int::isUsernameInputType)
unmockkStatic(AutofillValue::extractMonthValue)
unmockkStatic(AutofillValue::extractTextValue)
}
@Test
@ -54,6 +73,7 @@ class ViewNodeExtensionsTest {
val autofillHint = View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH
val expected = AutofillView.Card.ExpirationMonth(
data = autofillViewData,
monthValue = MONTH_VALUE,
)
every { viewNode.autofillHints } returns arrayOf(autofillHint)
@ -474,3 +494,5 @@ private val SUPPORTED_RAW_USERNAME_HINTS: List<String> = listOf(
"phone",
"username",
)
private const val MONTH_VALUE: String = "MONTH_VALUE"
private const val TEXT_VALUE: String = "TEXT_VALUE"

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class ViewNodeTraversalDataExtensionsTest {
@ -17,6 +18,7 @@ class ViewNodeTraversalDataExtensionsTest {
private val autofillViewData = AutofillView.Data(
autofillId = mockk(),
isFocused = false,
textValue = null,
)
@Test
@ -31,7 +33,7 @@ class ViewNodeTraversalDataExtensionsTest {
// Test
val actual = listOf(viewNodeTraversalData).buildUriOrNull(
assistStructure = assistStructure,
packageName = PACKAGE_NAME,
)
// Verify
@ -39,27 +41,7 @@ class ViewNodeTraversalDataExtensionsTest {
}
@Test
fun `buildUriOrNull should return idPackage URI when WEBSITE is null`() {
// Setup
val viewNodeTraversalData = ViewNodeTraversalData(
autofillViews = emptyList(),
idPackage = ID_PACKAGE,
ignoreAutofillIds = emptyList(),
website = null,
)
val expected = "androidapp://$ID_PACKAGE"
// Test
val actual = listOf(viewNodeTraversalData).buildUriOrNull(
assistStructure = assistStructure,
)
// Verify
assertEquals(expected, actual)
}
@Test
fun `buildUriOrNull should return title URI when website and idPackage are null`() {
fun `buildUriOrNull should return package name URI when website is null`() {
// Setup
val viewNodeTraversalData = ViewNodeTraversalData(
autofillViews = emptyList(),
@ -67,20 +49,77 @@ class ViewNodeTraversalDataExtensionsTest {
ignoreAutofillIds = emptyList(),
website = null,
)
val expected = "androidapp://com.x8bit.bitwarden"
every { windowNode.title } returns "com.x8bit.bitwarden/path.deeper.into.app"
val expected = "androidapp://$PACKAGE_NAME"
// Test
val actual = listOf(viewNodeTraversalData).buildUriOrNull(
assistStructure = assistStructure,
packageName = PACKAGE_NAME,
)
// Verify
assertEquals(expected, actual)
}
companion object {
private const val ID_PACKAGE: String = "com.x8bit.bitwarden"
private const val WEBSITE: String = "https://www.google.com"
@Test
fun `buildUriOrNull should return null when website and packageName are null`() {
// Setup
val viewNodeTraversalData = ViewNodeTraversalData(
autofillViews = emptyList(),
idPackage = null,
ignoreAutofillIds = emptyList(),
website = null,
)
// Test
val actual = listOf(viewNodeTraversalData).buildUriOrNull(
packageName = null,
)
// Verify
assertNull(actual)
}
@Test
fun `buildPackageNameOrNull should return idPackage when available`() {
// Setup
val viewNodeTraversalData = ViewNodeTraversalData(
autofillViews = emptyList(),
idPackage = ID_PACKAGE,
ignoreAutofillIds = emptyList(),
website = null,
)
// Test
val actual = listOf(viewNodeTraversalData).buildPackageNameOrNull(
assistStructure = assistStructure,
)
// Verify
assertEquals(ID_PACKAGE, actual)
}
@Test
fun `buildPackageNameOrNull should return title URI when idPackage is null`() {
// Setup
val viewNodeTraversalData = ViewNodeTraversalData(
autofillViews = emptyList(),
idPackage = null,
ignoreAutofillIds = emptyList(),
website = null,
)
val expected = "com.x8bit.bitwarden"
every { windowNode.title } returns "com.x8bit.bitwarden/path.deeper.into.app"
// Test
val actual = listOf(viewNodeTraversalData).buildPackageNameOrNull(
assistStructure = assistStructure,
)
// Verify
assertEquals(expected, actual)
}
}
private const val ID_PACKAGE: String = "com.x8bit.bitwarden"
private const val PACKAGE_NAME: String = "com.google"
private const val WEBSITE: String = "https://www.google.com"