diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillService.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillService.kt index 630c5608d..4c350f17f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/BitwardenAutofillService.kt @@ -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, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilder.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilder.kt index c34ff87ec..050816bad 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilder.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilder.kt @@ -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? } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt index c8b5756b0..f7ebd91c8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/FillResponseBuilderImpl.kt @@ -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 -> diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/SaveInfoBuilder.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/SaveInfoBuilder.kt new file mode 100644 index 000000000..fde25f84c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/SaveInfoBuilder.kt @@ -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? +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/SaveInfoBuilderImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/SaveInfoBuilderImpl.kt new file mode 100644 index 000000000..328c97d0b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/builder/SaveInfoBuilderImpl.kt @@ -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 = 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", +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt index 06f535d4f..5c9b9db0e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt @@ -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, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillPartition.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillPartition.kt index df3b6f31f..a5e7c4939 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillPartition.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillPartition.kt @@ -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]s that are required for save requests. If there are no required fields present, + * then save requests aren't allowed. + */ + abstract val requiredSaveIds: List + + /** + * The autofill save associated with this [AutofillPartition]. + */ + abstract val saveType: Int + /** * The views that correspond to this partition. */ abstract val views: List + /** + * 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, - ) : AutofillPartition() + ) : AutofillPartition() { + override val optionalSaveIds: List + get() = views + .filter { it !is AutofillView.Card.Number } + .map { it.data.autofillId } + override val requiredSaveIds: List + get() = views + .filterIsInstance() + .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, - ) : AutofillPartition() + ) : AutofillPartition() { + override val optionalSaveIds: List + get() = views + .filter { it !is AutofillView.Login.Password } + .map { it.data.autofillId } + override val requiredSaveIds: List + get() = views + .filterIsInstance() + .map { it.data.autofillId } + override val saveType: Int + get() = SaveInfo.SAVE_DATA_TYPE_PASSWORD + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt index 9817deb84..ac8efa47f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillRequest.kt @@ -15,6 +15,7 @@ sealed class AutofillRequest { val ignoreAutofillIds: List, val inlinePresentationSpecs: List, val maxInlineSuggestionsCount: Int, + val packageName: String?, val partition: AutofillPartition, val uri: String?, ) : AutofillRequest() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt index 68067a4c9..2d112bbdd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt @@ -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() /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt index ecae82f31..8dc77c6f5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -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, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessor.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessor.kt index ffdceec38..538ea4545 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessor.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessor.kt @@ -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, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorImpl.kt index 5ffa8ad5f..d8d49810b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorImpl.kt @@ -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) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofilValueExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofilValueExtensions.kt new file mode 100644 index 000000000..1723e6b0c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofilValueExtensions.kt @@ -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? = + 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() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillIntentUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillIntentUtils.kt index c11bbaa34..0bee896f1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillIntentUtils.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillIntentUtils.kt @@ -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. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillPartitionExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillPartitionExtensions.kt new file mode 100644 index 000000000..cea625eb7 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillPartitionExtensions.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillRequestExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillRequestExtensions.kt new file mode 100644 index 000000000..554970f29 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/AutofillRequestExtensions.kt @@ -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, + ) + } + } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt index 552e1604b..1ea8e97ce 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt @@ -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, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensions.kt index edd4f6779..f06cc3dd0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensions.kt @@ -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.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.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.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() } /** diff --git a/app/src/main/res/xml-v30/autofill_service_configuration.xml b/app/src/main/res/xml-v30/autofill_service_configuration.xml index 0ef0f187c..261f9c62f 100644 --- a/app/src/main/res/xml-v30/autofill_service_configuration.xml +++ b/app/src/main/res/xml-v30/autofill_service_configuration.xml @@ -2,8 +2,9 @@ { + it.addDataset(dataset) + it.addDataset(vaultItemDataSet) + } + mockBuilder { + it.setIgnoredIds( + ignoredAutofillIdOne, + ignoredAutofillIdTwo, + ) + } + mockBuilder { + 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().addDataset(vaultItemDataSet) + anyConstructed().setIgnoredIds( + ignoredAutofillIdOne, + ignoredAutofillIdTwo, + ) + anyConstructed().setSaveInfo(saveInfo) + } + verify(exactly = 3) { + anyConstructed().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 diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt index 6515d8a79..f0b642bb6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderTest.kt @@ -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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/SaveInfoBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/SaveInfoBuilderTest.kt new file mode 100644 index 000000000..0bf3066a3 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/builder/SaveInfoBuilderTest.kt @@ -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().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 { + it.setOptionalIds(arrayOf(autofillIdOptional)) + } + mockBuilder { + 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().setOptionalIds(arrayOf(autofillIdOptional)) + anyConstructed().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 { + 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().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 = 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", +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt index 41144de71..eb80e515f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt @@ -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>().buildUriOrNull(assistStructure) } returns URI + every { + any>().buildPackageNameOrNull(assistStructure) + } returns PACKAGE_NAME + every { any>().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>().buildUriOrNull(assistStructure) + any>().buildPackageNameOrNull(assistStructure) + any>().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>().buildUriOrNull(assistStructure) + any>().buildPackageNameOrNull(assistStructure) + any>().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>().buildUriOrNull(assistStructure) + any>().buildPackageNameOrNull(assistStructure) + any>().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>().buildUriOrNull(assistStructure) + any>().buildPackageNameOrNull(assistStructure) + any>().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>().buildUriOrNull(assistStructure) + any>().buildPackageNameOrNull(assistStructure) + any>().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>().buildUriOrNull(assistStructure) + any>().buildPackageNameOrNull(assistStructure) + any>().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>().buildUriOrNull(assistStructure) + any>().buildUriOrNull(PACKAGE_NAME) } returns blockListedUri // Test @@ -531,7 +567,8 @@ class AutofillParserTests { // Verify all tests verify(exactly = BLOCK_LISTED_URIS.size + remoteBlockList.size) { - any>().buildUriOrNull(assistStructure) + any>().buildPackageNameOrNull(assistStructure) + any>().buildUriOrNull(PACKAGE_NAME) } } @@ -554,5 +591,6 @@ private val BLOCK_LISTED_URIS: List = 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" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt index e448f1057..274e0c451 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/processor/AutofillProcessorTest.kt @@ -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 = 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" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillPartitionExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillPartitionExtensionsTest.kt new file mode 100644 index 000000000..c81e6072c --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillPartitionExtensionsTest.kt @@ -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" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillRequestExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillRequestExtensionsTest.kt new file mode 100644 index 000000000..3acd73543 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillRequestExtensionsTest.kt @@ -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 = listOf( + "androidapp://URI", + "https://URI", + "http://URI", +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillValueExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillValueExtensionsTest.kt new file mode 100644 index 000000000..4dff60d56 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillValueExtensionsTest.kt @@ -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" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillViewExtensionsTest.kt index abc50be58..e28ea0c76 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/AutofillViewExtensionsTest.kt @@ -20,6 +20,7 @@ class AutofillViewExtensionsTest { private val autofillViewData = AutofillView.Data( autofillId = autofillId, isFocused = false, + textValue = null, ) @BeforeEach diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensionsTest.kt index 21c82b30c..214129a5c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/FilledDataExtensionsTest.kt @@ -70,6 +70,7 @@ class FilledDataExtensionsTest { data = AutofillView.Data( autofillId = autofillId, isFocused = true, + textValue = null, ), ), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt index 709e50895..9afde68e2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensionsTest.kt @@ -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 = listOf( "phone", "username", ) +private const val MONTH_VALUE: String = "MONTH_VALUE" +private const val TEXT_VALUE: String = "TEXT_VALUE" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt index 99e7c1b1e..23d4c8a97 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/util/ViewNodeTraversalDataExtensionsTest.kt @@ -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"