From 16e1970943b7fc8ecc7aba3bc129046fa78e8ed6 Mon Sep 17 00:00:00 2001 From: Craig Russell <1336281+CDRussell@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:29:47 +0000 Subject: [PATCH] Autofill: Increase ratio of complete credential saves --- .../autofill/api/AutofillFeature.kt | 8 + .../impl/AutofillJavascriptInterface.kt | 84 ++++++++-- .../AutofillRuntimeConfigProvider.kt | 5 + .../RuntimeConfigurationWriter.kt | 3 + .../jsbridge/request/AutofillDataRequest.kt | 12 ++ .../jsbridge/request/AutofillRequestParser.kt | 51 +++++- .../request/AutofillStoreFormDataRequest.kt | 1 + .../partialsave/PartialCredentialSaveStore.kt | 107 +++++++++++++ ...datingExistingCredentialsDialogFragment.kt | 4 + ...tofillStoredBackJavascriptInterfaceTest.kt | 35 +++- .../RealAutofillRuntimeConfigProviderTest.kt | 4 + .../RealRuntimeConfigurationWriterTest.kt | 2 + .../request/AutofillJsonRequestParserTest.kt | 44 ++++- .../PartialCredentialSaveInMemoryStoreTest.kt | 150 ++++++++++++++++++ ...eFormData_passwordMissing_partialSave.json | 6 + .../storeFormData_trigger_formSubmission.json | 7 + .../json/storeFormData_trigger_missing.json | 6 + .../storeFormData_trigger_partialSave.json | 7 + .../json/storeFormData_trigger_unknown.json | 7 + 19 files changed, 522 insertions(+), 21 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/partialsave/PartialCredentialSaveStore.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/partialsave/PartialCredentialSaveInMemoryStoreTest.kt create mode 100644 autofill/autofill-impl/src/test/resources/json/storeFormData_passwordMissing_partialSave.json create mode 100644 autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_formSubmission.json create mode 100644 autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_missing.json create mode 100644 autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_partialSave.json create mode 100644 autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_unknown.json diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt index 0244965bc6d2..8b1c0aa65234 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt @@ -106,4 +106,12 @@ interface AutofillFeature { @InternalAlwaysEnabled @Toggle.DefaultValue(false) fun canImportFromGooglePasswordManager(): Toggle + + /** + * Remote flag that enables the ability to support partial form saves. A partial form save is common with scenarios like: + * - a multi-step login form where username and password are entered on separate pages + * - password reset flow + */ + @Toggle.DefaultValue(false) + fun partialFormSaves(): Toggle } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt index c5ef7fdb189e..480521c9a115 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt @@ -27,6 +27,8 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor +import com.duckduckgo.autofill.impl.AutofillStoredBackJavascriptInterface.BackfillResult.BackfillNotSupported +import com.duckduckgo.autofill.impl.AutofillStoredBackJavascriptInterface.BackfillResult.BackfillSupported import com.duckduckgo.autofill.impl.deduper.AutofillLoginDeduplicator import com.duckduckgo.autofill.impl.domain.javascript.JavascriptCredentials import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextRecentInstallChecker @@ -34,7 +36,11 @@ import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInConte import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster import com.duckduckgo.autofill.impl.jsbridge.request.AutofillDataRequest import com.duckduckgo.autofill.impl.jsbridge.request.AutofillRequestParser +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillStoreFormDataCredentialsRequest import com.duckduckgo.autofill.impl.jsbridge.request.AutofillStoreFormDataRequest +import com.duckduckgo.autofill.impl.jsbridge.request.FormSubmissionTriggerType.FORM_SUBMISSION +import com.duckduckgo.autofill.impl.jsbridge.request.FormSubmissionTriggerType.PARTIAL_SAVE +import com.duckduckgo.autofill.impl.jsbridge.request.FormSubmissionTriggerType.UNKNOWN import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputMainType.CREDENTIALS import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.PASSWORD import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.USERNAME @@ -42,6 +48,7 @@ import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerTyp import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.AUTOPROMPT import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.USER_INITIATED import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter +import com.duckduckgo.autofill.impl.partialsave.PartialCredentialSaveStore import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository @@ -114,6 +121,7 @@ class AutofillStoredBackJavascriptInterface @Inject constructor( private val loginDeduplicator: AutofillLoginDeduplicator, private val systemAutofillServiceSuppressor: SystemAutofillServiceSuppressor, private val neverSavedSiteRepository: NeverSavedSiteRepository, + private val partialCredentialSaveStore: PartialCredentialSaveStore, ) : AutofillJavascriptInterface { override var callback: Callback? = null @@ -182,6 +190,7 @@ class AutofillStoredBackJavascriptInterface @Inject constructor( emailProtectionInContextSignupFlowCallback?.closeInContextSignup() } + @Suppress("UNUSED_PARAMETER") @JavascriptInterface fun showInContextEmailProtectionSignupPrompt(data: String) { coroutineScope.launch(dispatcherProvider.io()) { @@ -297,25 +306,49 @@ class AutofillStoredBackJavascriptInterface @Inject constructor( return@launch } - val jsCredentials = JavascriptCredentials(request.credentials!!.username, request.credentials.password) - val credentials = jsCredentials.asLoginCredentials(currentUrl) + val credentials = request.credentials!! - val autologinId = autoSavedLoginsMonitor?.getAutoSavedLoginId(tabId) - Timber.i("Autogenerated? %s, Previous autostored login ID: %s", request.credentials.autogenerated, autologinId) - val autosavedLogin = autologinId?.let { autofillStore.getCredentialsWithId(it) } - - val autogenerated = request.credentials.autogenerated - val actions = passwordEventResolver.decideActions(autosavedLogin, autogenerated) - processStoreFormDataActions(actions, currentUrl, credentials) + when (request.trigger) { + FORM_SUBMISSION -> handleRequestForFormSubmission(credentials, currentUrl) + PARTIAL_SAVE -> handleRequestForPartialSave(credentials, currentUrl) + UNKNOWN -> Timber.e("Unknown trigger type %s", request.trigger) + } } } + private suspend fun handleRequestForFormSubmission( + requestCredentials: AutofillStoreFormDataCredentialsRequest, + currentUrl: String, + ) { + val jsCredentials = JavascriptCredentials(requestCredentials.username, requestCredentials.password) + + val credentials = jsCredentials + .asLoginCredentials(currentUrl) + .backfillUsernameIfRequired(currentUrl) + + val autologinId = autoSavedLoginsMonitor?.getAutoSavedLoginId(tabId) + val autogenerated = requestCredentials.autogenerated + val autosavedLogin = autologinId?.let { autofillStore.getCredentialsWithId(it) } + + val actions = passwordEventResolver.decideActions(autosavedLogin, autogenerated) + processStoreFormDataActions(actions, currentUrl, credentials) + } + + private suspend fun handleRequestForPartialSave( + requestCredentials: AutofillStoreFormDataCredentialsRequest, + currentUrl: String, + ) { + val username = requestCredentials.username ?: return + partialCredentialSaveStore.saveUsername(url = currentUrl, username = username) + Timber.d("Partial save: username: [%s] for %s", username, currentUrl) + } + private suspend fun processStoreFormDataActions( actions: List, currentUrl: String, credentials: LoginCredentials, ) { - Timber.d("%d actions to take: %s", actions.size, actions.joinToString()) + Timber.d("%d actions to take: %s", actions.size, actions.joinToString { it.javaClass.simpleName }) actions.forEach { when (it) { is DeleteAutoLogin -> { @@ -348,6 +381,24 @@ class AutofillStoredBackJavascriptInterface @Inject constructor( } } + private suspend fun isBackFillingUsernameSupported( + credentialsFromJs: LoginCredentials, + currentUrl: String, + ): BackfillResult { + // if the incoming request has a username populated, that takes precedence + if (credentialsFromJs.username != null) { + Timber.v("Backfilling username from partial save not supported because username provided already, for %s", currentUrl) + return BackfillNotSupported + } + + val username = partialCredentialSaveStore.consumeUsernameFromBackFill(currentUrl) ?: return BackfillNotSupported.also { + Timber.v("Backfilling username from partial save not supported because eligible username not found, for %s", currentUrl) + } + + Timber.v("Backfilling username [%s] from partial save, for %s", username, currentUrl) + return BackfillSupported(username) + } + private fun isUpdateRequired( existingCredentials: LoginCredentials, credentials: LoginCredentials, @@ -421,6 +472,19 @@ class AutofillStoredBackJavascriptInterface @Inject constructor( ) } + private suspend fun LoginCredentials.backfillUsernameIfRequired(currentUrl: String): LoginCredentials { + // determine if we can and should use a partial previous submission's username + return when (val result = isBackFillingUsernameSupported(this, currentUrl)) { + is BackfillSupported -> this.copy(username = result.username) + is BackfillNotSupported -> this + } + } + + sealed interface BackfillResult { + data class BackfillSupported(val username: String) : BackfillResult + data object BackfillNotSupported : BackfillResult + } + interface UrlProvider { suspend fun currentUrl(webView: WebView?): String? } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt index 75fa4a587438..a4f4fbb3ea87 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt @@ -63,6 +63,7 @@ class RealAutofillRuntimeConfigProvider @Inject constructor( showInlineKeyIcon = true, showInContextEmailProtectionSignup = canShowInContextEmailProtectionSignup(url), unknownUsernameCategorization = canCategorizeUnknownUsername(), + partialFormSaves = partialFormSaves(), ) val availableInputTypes = generateAvailableInputTypes(url) @@ -133,6 +134,10 @@ class RealAutofillRuntimeConfigProvider @Inject constructor( return autofillFeature.canCategorizeUnknownUsername().isEnabled() } + private fun partialFormSaves(): Boolean { + return autofillFeature.partialFormSaves().isEnabled() + } + private suspend fun canShowInContextEmailProtectionSignup(url: String?): Boolean { if (url == null) return false return emailProtectionInContextAvailabilityRules.permittedToShow(url) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RuntimeConfigurationWriter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RuntimeConfigurationWriter.kt index 14a4d073c304..966fcf510572 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RuntimeConfigurationWriter.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RuntimeConfigurationWriter.kt @@ -39,6 +39,7 @@ interface RuntimeConfigurationWriter { showInlineKeyIcon: Boolean, showInContextEmailProtectionSignup: Boolean, unknownUsernameCategorization: Boolean, + partialFormSaves: Boolean, ): String } @@ -88,6 +89,7 @@ class RealRuntimeConfigurationWriter @Inject constructor(val moshi: Moshi) : Run showInlineKeyIcon: Boolean, showInContextEmailProtectionSignup: Boolean, unknownUsernameCategorization: Boolean, + partialFormSaves: Boolean, ): String { return """ userPreferences = { @@ -108,6 +110,7 @@ class RealRuntimeConfigurationWriter @Inject constructor(val moshi: Moshi) : Run "inlineIcon_credentials": $showInlineKeyIcon, "emailProtection_incontext_signup": $showInContextEmailProtectionSignup, "unknown_username_categorization": $unknownUsernameCategorization, + "partial_form_saves": $partialFormSaves } } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillDataRequest.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillDataRequest.kt index 7b772b0cc3e0..11672e3ff211 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillDataRequest.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillDataRequest.kt @@ -78,3 +78,15 @@ enum class SupportedAutofillTriggerType { @Json(name = "autoprompt") AUTOPROMPT, } + +enum class FormSubmissionTriggerType { + @Json(name = "formSubmission") + FORM_SUBMISSION, + + @Json(name = "partialSave") + PARTIAL_SAVE, + + UNKNOWN, + + ; +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillRequestParser.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillRequestParser.kt index 23f17dcfe443..35788d0305d5 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillRequestParser.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillRequestParser.kt @@ -16,13 +16,18 @@ package com.duckduckgo.autofill.impl.jsbridge.request -import com.duckduckgo.common.utils.DefaultDispatcherProvider +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillJsonRequestParser.AutofillStoreFormDataCredentialsJsonRequest +import com.duckduckgo.autofill.impl.jsbridge.request.AutofillJsonRequestParser.AutofillStoreFormDataJsonRequest +import com.duckduckgo.autofill.impl.jsbridge.request.FormSubmissionTriggerType.UNKNOWN import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.EnumJsonAdapter +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import javax.inject.Inject import kotlinx.coroutines.withContext +import timber.log.Timber interface AutofillRequestParser { suspend fun parseAutofillDataRequest(request: String): Result @@ -31,12 +36,18 @@ interface AutofillRequestParser { @ContributesBinding(AppScope::class) class AutofillJsonRequestParser @Inject constructor( - val moshi: Moshi, - private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), + private val dispatchers: DispatcherProvider, ) : AutofillRequestParser { private val autofillDataRequestParser by lazy { moshi.adapter(AutofillDataRequest::class.java) } - private val autofillStoreFormDataRequestParser by lazy { moshi.adapter(AutofillStoreFormDataRequest::class.java) } + private val autofillStoreFormDataRequestParser by lazy { moshi.adapter(AutofillStoreFormDataJsonRequest::class.java) } + + private val moshi by lazy { + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .add(FormSubmissionTriggerType::class.java, EnumJsonAdapter.create(FormSubmissionTriggerType::class.java).withUnknownFallback(UNKNOWN)) + .build() + } override suspend fun parseAutofillDataRequest(request: String): Result { return withContext(dispatchers.io()) { @@ -56,13 +67,41 @@ class AutofillJsonRequestParser @Inject constructor( return withContext(dispatchers.io()) { val result = kotlin.runCatching { autofillStoreFormDataRequestParser.fromJson(request) - }.getOrNull() + } + .onFailure { Timber.w(it, "Failed to parse autofill JSON for AutofillStoreFormDataRequest") } + .getOrNull() return@withContext if (result == null) { Result.failure(IllegalArgumentException("Failed to parse autofill JSON for AutofillStoreFormDataRequest")) } else { - Result.success(result) + Result.success(result.mapToPublicType()) } } } + + internal data class AutofillStoreFormDataJsonRequest( + val credentials: AutofillStoreFormDataCredentialsJsonRequest?, + val trigger: FormSubmissionTriggerType?, + ) + + internal data class AutofillStoreFormDataCredentialsJsonRequest( + val username: String?, + val password: String?, + val autogenerated: Boolean = false, + ) +} + +private fun AutofillStoreFormDataJsonRequest?.mapToPublicType(): AutofillStoreFormDataRequest { + return AutofillStoreFormDataRequest( + credentials = this?.credentials?.mapToPublicType(), + trigger = this?.trigger ?: UNKNOWN, + ) +} + +private fun AutofillStoreFormDataCredentialsJsonRequest?.mapToPublicType(): AutofillStoreFormDataCredentialsRequest { + return AutofillStoreFormDataCredentialsRequest( + username = this?.username, + password = this?.password, + autogenerated = this?.autogenerated ?: false, + ) } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillStoreFormDataRequest.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillStoreFormDataRequest.kt index 1a72921a19aa..3adf2ae64e13 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillStoreFormDataRequest.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillStoreFormDataRequest.kt @@ -18,6 +18,7 @@ package com.duckduckgo.autofill.impl.jsbridge.request data class AutofillStoreFormDataRequest( val credentials: AutofillStoreFormDataCredentialsRequest?, + val trigger: FormSubmissionTriggerType, ) data class AutofillStoreFormDataCredentialsRequest( diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/partialsave/PartialCredentialSaveStore.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/partialsave/PartialCredentialSaveStore.kt new file mode 100644 index 000000000000..bde442c762e7 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/partialsave/PartialCredentialSaveStore.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.partialsave + +import androidx.collection.LruCache +import com.duckduckgo.autofill.impl.time.TimeProvider +import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlinx.coroutines.withContext +import timber.log.Timber + +interface PartialCredentialSaveStore { + suspend fun saveUsername( + url: String, + username: String, + ) + + suspend fun consumeUsernameFromBackFill(url: String): String? + suspend fun wasBackFilledRecently(url: String, username: String): Boolean +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class PartialCredentialSaveInMemoryStore @Inject constructor( + private val urlMatcher: AutofillUrlMatcher, + private val timeProvider: TimeProvider, + private val dispatchers: DispatcherProvider, +) : PartialCredentialSaveStore { + + private val backFillHistory = LruCache(5) + + override suspend fun saveUsername( + url: String, + username: String, + ) { + withContext(dispatchers.io()) { + val etldPlusOne = extractEtldPlusOne(url) ?: return@withContext + backFillHistory.put(etldPlusOne, PartialSave(username = username, creationTimestamp = timeProvider.currentTimeMillis())) + } + } + + /** + * If a potential backFill username can be used for the given URL it will be returned + */ + override suspend fun consumeUsernameFromBackFill(url: String): String? { + return withContext(dispatchers.io()) { + val etldPlusOne = extractEtldPlusOne(url) ?: return@withContext null + val activeBackFill = backFillHistory[etldPlusOne] ?: return@withContext null + + if (activeBackFill.isExpired()) { + Timber.v("Found expired username [%s] for %s. Not using for backFill.", activeBackFill.username, etldPlusOne) + return@withContext null + } + + backFillHistory.put(etldPlusOne, activeBackFill.copy(lastConsumedTimestamp = timeProvider.currentTimeMillis())) + activeBackFill.username + } + } + + override suspend fun wasBackFilledRecently(url: String, username: String): Boolean { + val etldPlusOne = extractEtldPlusOne(url) ?: return false + val partialSave = backFillHistory[etldPlusOne] ?: return false + if (partialSave.username != username) return false + + return partialSave.consumedRecently(timeProvider) + } + + private fun extractEtldPlusOne(url: String) = urlMatcher.extractUrlPartsForAutofill(url).eTldPlus1 + + private fun PartialSave.isExpired(): Boolean { + return (timeProvider.currentTimeMillis() - creationTimestamp) > MAX_VALIDITY_MS + } + + data class PartialSave( + val username: String, + val creationTimestamp: Long, + val lastConsumedTimestamp: Long? = null, + ) { + fun consumedRecently(timeProvider: TimeProvider): Boolean { + return lastConsumedTimestamp?.let { timeProvider.currentTimeMillis() - it < TIME_WINDOW_FOR_BEING_RECENT_MS } ?: false + } + } + + companion object { + val MAX_VALIDITY_MS = TimeUnit.MINUTES.toMillis(3) + val TIME_WINDOW_FOR_BEING_RECENT_MS = TimeUnit.SECONDS.toMillis(10) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/AutofillUpdatingExistingCredentialsDialogFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/AutofillUpdatingExistingCredentialsDialogFragment.kt index ee6fd6d155df..e1a51f4dc1a0 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/AutofillUpdatingExistingCredentialsDialogFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/AutofillUpdatingExistingCredentialsDialogFragment.kt @@ -32,6 +32,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor import com.duckduckgo.autofill.impl.R import com.duckduckgo.autofill.impl.databinding.ContentAutofillUpdateExistingCredentialsBinding +import com.duckduckgo.autofill.impl.partialsave.PartialCredentialSaveStore import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_UPDATE_LOGIN_PROMPT_DISMISSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_UPDATE_LOGIN_PROMPT_SAVED @@ -63,6 +64,9 @@ class AutofillUpdatingExistingCredentialsDialogFragment : BottomSheetDialogFragm @Inject lateinit var autofillFireproofDialogSuppressor: AutofillFireproofDialogSuppressor + @Inject + lateinit var partialCredentialSaveStore: PartialCredentialSaveStore + /** * To capture all the ways the BottomSheet can be dismissed, we might end up with onCancel being called when we don't want it * This flag is set to true when taking an action which dismisses the dialog, but should not be treated as a cancellation. diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt index 172112766201..3d598ffafd65 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt @@ -34,11 +34,14 @@ import com.duckduckgo.autofill.impl.jsbridge.request.AutofillDataRequest import com.duckduckgo.autofill.impl.jsbridge.request.AutofillRequestParser import com.duckduckgo.autofill.impl.jsbridge.request.AutofillStoreFormDataCredentialsRequest import com.duckduckgo.autofill.impl.jsbridge.request.AutofillStoreFormDataRequest +import com.duckduckgo.autofill.impl.jsbridge.request.FormSubmissionTriggerType.FORM_SUBMISSION +import com.duckduckgo.autofill.impl.jsbridge.request.FormSubmissionTriggerType.PARTIAL_SAVE import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputMainType.CREDENTIALS import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.PASSWORD import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.USERNAME import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.USER_INITIATED import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter +import com.duckduckgo.autofill.impl.partialsave.PartialCredentialSaveStore import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository @@ -79,6 +82,7 @@ class AutofillStoredBackJavascriptInterfaceTest { private val loginDeduplicator: AutofillLoginDeduplicator = NoopDeduplicator() private val systemAutofillServiceSuppressor: SystemAutofillServiceSuppressor = mock() private val neverSavedSiteRepository: NeverSavedSiteRepository = mock() + private val partialCredentialSaveStore: PartialCredentialSaveStore = mock() private lateinit var testee: AutofillStoredBackJavascriptInterface private val testCallback = TestCallback() @@ -107,6 +111,7 @@ class AutofillStoredBackJavascriptInterfaceTest { loginDeduplicator = loginDeduplicator, systemAutofillServiceSuppressor = systemAutofillServiceSuppressor, neverSavedSiteRepository = neverSavedSiteRepository, + partialCredentialSaveStore = partialCredentialSaveStore, ) testee.callback = testCallback testee.webView = testWebView @@ -335,16 +340,39 @@ class AutofillStoredBackJavascriptInterfaceTest { assertNull(testCallback.credentialsToSave) } + @Test + fun whenPartialFormSubmissionWithUsernameThenPartialSavePersisted() = runTest { + val username = "user" + val url = "https://example.com" + configureRequestParserToReturnPartialSaveRequestType(username = username) + whenever(currentUrlProvider.currentUrl(anyOrNull())).thenReturn(url) + testee.storeFormData("") + verify(partialCredentialSaveStore).saveUsername(url = eq(url), username = eq(username)) + } + + @Test + fun whenPartialFormSubmissionWithNoUsernameThenPartialSaveNotPersisted() = runTest { + configureRequestParserToReturnPartialSaveRequestType(username = null) + testee.storeFormData("") + verifyNoInteractions(partialCredentialSaveStore) + } + private suspend fun configureRequestParserToReturnSaveCredentialRequestType( username: String?, password: String?, ) { val credentials = AutofillStoreFormDataCredentialsRequest(username = username, password = password) - val topLevelRequest = AutofillStoreFormDataRequest(credentials) + val topLevelRequest = AutofillStoreFormDataRequest(credentials, FORM_SUBMISSION) whenever(requestParser.parseStoreFormDataRequest(any())).thenReturn(Result.success(topLevelRequest)) whenever(passwordEventResolver.decideActions(anyOrNull(), any())).thenReturn(listOf(Actions.PromptToSave)) } + private suspend fun configureRequestParserToReturnPartialSaveRequestType(username: String?) { + val credentials = AutofillStoreFormDataCredentialsRequest(username = username, password = null) + val topLevelRequest = AutofillStoreFormDataRequest(credentials, PARTIAL_SAVE) + whenever(requestParser.parseStoreFormDataRequest(any())).thenReturn(Result.success(topLevelRequest)) + } + private fun assertCredentialsContains( property: (LoginCredentials) -> String?, vararg expected: String?, @@ -435,6 +463,9 @@ class AutofillStoredBackJavascriptInterfaceTest { } private class NoopDeduplicator : AutofillLoginDeduplicator { - override fun deduplicate(originalUrl: String, logins: List): List = logins + override fun deduplicate( + originalUrl: String, + logins: List, + ): List = logins } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt index 075448d64f22..41f916fa4e28 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt @@ -84,6 +84,7 @@ class RealAutofillRuntimeConfigProviderTest { showInlineKeyIcon = any(), showInContextEmailProtectionSignup = any(), unknownUsernameCategorization = any(), + partialFormSaves = any(), ), ).thenReturn("") } @@ -394,6 +395,7 @@ class RealAutofillRuntimeConfigProviderTest { showInlineKeyIcon = any(), showInContextEmailProtectionSignup = any(), unknownUsernameCategorization = any(), + partialFormSaves = any(), ) } @@ -405,6 +407,7 @@ class RealAutofillRuntimeConfigProviderTest { showInlineKeyIcon = any(), showInContextEmailProtectionSignup = any(), unknownUsernameCategorization = any(), + partialFormSaves = any(), ) } @@ -420,6 +423,7 @@ class RealAutofillRuntimeConfigProviderTest { showInlineKeyIcon = eq(true), showInContextEmailProtectionSignup = any(), unknownUsernameCategorization = any(), + partialFormSaves = any(), ) } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealRuntimeConfigurationWriterTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealRuntimeConfigurationWriterTest.kt index 2f7b044dea5f..c10a8330cce2 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealRuntimeConfigurationWriterTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealRuntimeConfigurationWriterTest.kt @@ -94,6 +94,7 @@ class RealRuntimeConfigurationWriterTest { "inlineIcon_credentials": true, "emailProtection_incontext_signup": true, "unknown_username_categorization": false, + "partial_form_saves": false } } } @@ -109,6 +110,7 @@ class RealRuntimeConfigurationWriterTest { showInlineKeyIcon = true, showInContextEmailProtectionSignup = true, unknownUsernameCategorization = false, + partialFormSaves = false, ), ) } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillJsonRequestParserTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillJsonRequestParserTest.kt index fe6bb2c47558..f0ce5239a770 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillJsonRequestParserTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/jsbridge/request/AutofillJsonRequestParserTest.kt @@ -16,16 +16,22 @@ package com.duckduckgo.autofill.impl.jsbridge.request +import com.duckduckgo.autofill.impl.jsbridge.request.FormSubmissionTriggerType.FORM_SUBMISSION +import com.duckduckgo.autofill.impl.jsbridge.request.FormSubmissionTriggerType.PARTIAL_SAVE +import com.duckduckgo.autofill.impl.jsbridge.request.FormSubmissionTriggerType.UNKNOWN +import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities -import com.squareup.moshi.Moshi import kotlinx.coroutines.test.runTest import org.junit.Assert.* +import org.junit.Rule import org.junit.Test class AutofillJsonRequestParserTest { - private val moshi = Moshi.Builder().build() - private val testee = AutofillJsonRequestParser(moshi) + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val testee = AutofillJsonRequestParser(dispatchers = coroutineTestRule.testDispatcherProvider) @Test fun whenUsernameAndPasswordBothProvidedThenBothInResponse() = runTest { @@ -69,6 +75,14 @@ class AutofillJsonRequestParserTest { assertNull(parsed.credentials!!.password) } + @Test + fun whenPasswordMissingAndIsPartialSaveThenUsernamePopulatedAndPartialSaveFlagSet() = runTest { + val parsed = "storeFormData_passwordMissing_partialSave".parseStoreFormDataJson() + assertEquals("dax@duck.com", parsed.credentials!!.username) + assertNull(parsed.credentials!!.password) + assertEquals(PARTIAL_SAVE, parsed.trigger) + } + @Test fun whenTopLevelCredentialsObjectMissingThenParsesWithoutError() = runTest { val parsed = "storeFormData_topLevelDataMissing".parseStoreFormDataJson() @@ -87,6 +101,30 @@ class AutofillJsonRequestParserTest { assertTrue(result.isFailure) } + @Test + fun whenStoreFormDataRequestMissingTriggerThenIsUnknown() = runTest { + val parsed = "storeFormData_trigger_missing".parseStoreFormDataJson() + assertEquals(UNKNOWN, parsed.trigger) + } + + @Test + fun whenStoreFormDataRequestUnknownTriggerThenIsUnknown() = runTest { + val parsed = "storeFormData_trigger_unknown".parseStoreFormDataJson() + assertEquals(UNKNOWN, parsed.trigger) + } + + @Test + fun whenStoreFormDataRequestHasFormSubmissionTriggerThenIsPopulated() = runTest { + val parsed = "storeFormData_trigger_formSubmission".parseStoreFormDataJson() + assertEquals(FORM_SUBMISSION, parsed.trigger) + } + + @Test + fun whenStoreFormDataRequestHasPartialSaveTriggerThenIsPopulated() = runTest { + val parsed = "storeFormData_trigger_partialSave".parseStoreFormDataJson() + assertEquals(PARTIAL_SAVE, parsed.trigger) + } + private suspend fun String.parseStoreFormDataJson(): AutofillStoreFormDataRequest { val json = this.loadJsonFile() assertNotNull("Failed to load specified JSON file: $this") diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/partialsave/PartialCredentialSaveInMemoryStoreTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/partialsave/PartialCredentialSaveInMemoryStoreTest.kt new file mode 100644 index 000000000000..b20a796243b0 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/partialsave/PartialCredentialSaveInMemoryStoreTest.kt @@ -0,0 +1,150 @@ +package com.duckduckgo.autofill.impl.partialsave + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.impl.encoding.UrlUnicodeNormalizerImpl +import com.duckduckgo.autofill.impl.partialsave.PartialCredentialSaveInMemoryStore.Companion.MAX_VALIDITY_MS +import com.duckduckgo.autofill.impl.partialsave.PartialCredentialSaveInMemoryStore.Companion.TIME_WINDOW_FOR_BEING_RECENT_MS +import com.duckduckgo.autofill.impl.time.TimeProvider +import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher +import com.duckduckgo.common.test.CoroutineTestRule +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class PartialCredentialSaveInMemoryStoreTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val mockTimeProvider: TimeProvider = mock() + + private val testee = PartialCredentialSaveInMemoryStore( + urlMatcher = AutofillDomainNameUrlMatcher(unicodeNormalizer = UrlUnicodeNormalizerImpl()), + timeProvider = mockTimeProvider, + dispatchers = coroutineTestRule.testDispatcherProvider, + ) + + @Before + fun setup() { + setFixedTimestampForTimeProvider() + } + + @Test + fun whenNothingSavedThenReturnsNullUsername() = runTest { + assertNull(testee.consumeUsernameFromBackFill(URL)) + } + + @Test + fun whenEntrySavedALongTimeAgoWithMatchingEtldThenNullUsername() = runTest { + runWithSimulatedTimestamp(0L) { + testee.saveUsername(URL, "user") + } + assertNull(testee.consumeUsernameFromBackFill(URL)) + } + + @Test + fun whenEntrySaved3MinutesAnd1SecondAgoWithMatchingEtldThenNullUsername() = runTest { + // 1 second too long to be considered valid + val timestampTooOldToBeValid = timestampExactlyOnValidityWindow() - TimeUnit.SECONDS.toMillis(1) + runWithSimulatedTimestamp(timestampTooOldToBeValid) { + testee.saveUsername(URL, "user") + } + assertNull(testee.consumeUsernameFromBackFill(URL)) + } + + @Test + fun whenEntrySaved2MinutesAnd59SecondAgoWithMatchingEtldThenUsernameReturned() = runTest { + // still valid by only 1 second + val timestampWithinValidityWindow = timestampExactlyOnValidityWindow() + TimeUnit.SECONDS.toMillis(1) + runWithSimulatedTimestamp(timestampWithinValidityWindow) { + testee.saveUsername(URL, "user") + } + assertEquals("user", testee.consumeUsernameFromBackFill(URL)) + } + + @Test + fun whenEntrySavedExactly3MinutesAgoWithMatchingEtldThenUsernameReturned() = runTest { + runWithSimulatedTimestamp(timestampExactlyOnValidityWindow()) { + testee.saveUsername(URL, "user") + } + assertEquals("user", testee.consumeUsernameFromBackFill(URL)) + } + + @Test + fun whenUsernameRetrievedThenStillAvailableAfter() = runTest { + runWithSimulatedTimestamp(timestampExactlyOnValidityWindow()) { + testee.saveUsername(URL, "user") + } + assertEquals("user", testee.consumeUsernameFromBackFill(URL)) + + // check it's still there + assertEquals("user", testee.consumeUsernameFromBackFill(URL)) + } + + @Test + fun whenUsernameSavedButNotUsedThenRecentlyUsedCheckReturnsFalse() = runTest { + runWithSimulatedTimestamp(timestampExactlyOnValidityWindow()) { + testee.saveUsername(URL, "user") + } + assertFalse(testee.wasBackFilledRecently(URL, "user")) + } + + @Test + fun whenUsernameUsedRecentlyThenRecentlyUsedCheckReturnsTrue() = runTest { + runWithSimulatedTimestamp(timestampExactlyOnValidityWindow()) { + testee.saveUsername(URL, "user") + } + testee.consumeUsernameFromBackFill(URL) + assertTrue(testee.wasBackFilledRecently(URL, "user")) + } + + @Test + fun whenUsernameUsedButNotRecentlyThenRecentlyUsedCheckReturnsFalse() = runTest { + runWithSimulatedTimestamp(timestampExactlyOnValidityWindow()) { + testee.saveUsername(URL, "user") + } + + // outside window where we'd consider it 'recent' + runWithSimulatedTimestamp(timestampExactlyOnUsedRecentlyWindow() - TimeUnit.SECONDS.toMillis(1)) { + testee.consumeUsernameFromBackFill(URL) + } + + assertFalse(testee.wasBackFilledRecently(URL, "user")) + } + + @Test + fun whenEntrySavedButEldPlusOneNotAMatchThenNotReturned() = runTest { + runWithSimulatedTimestamp(timestampExactlyOnValidityWindow()) { + testee.saveUsername("example.org", "user") + } + assertNull(testee.consumeUsernameFromBackFill("example.com")) + } + + private suspend fun runWithSimulatedTimestamp( + timestamp: Long, + block: suspend () -> Unit, + ) { + whenever(mockTimeProvider.currentTimeMillis()).thenReturn(timestamp) + block() + setFixedTimestampForTimeProvider() + } + + private fun timestampExactlyOnValidityWindow(): Long = CURRENT_TIME_MS - MAX_VALIDITY_MS + private fun timestampExactlyOnUsedRecentlyWindow(): Long = CURRENT_TIME_MS - TIME_WINDOW_FOR_BEING_RECENT_MS + + private fun setFixedTimestampForTimeProvider() { + whenever(mockTimeProvider.currentTimeMillis()).thenReturn(CURRENT_TIME_MS) + } + + companion object { + private const val URL = "example.com" + private const val CURRENT_TIME_MS = 1733935378547L // 2024-12-11T16:43 + } +} diff --git a/autofill/autofill-impl/src/test/resources/json/storeFormData_passwordMissing_partialSave.json b/autofill/autofill-impl/src/test/resources/json/storeFormData_passwordMissing_partialSave.json new file mode 100644 index 000000000000..7b1d6981efae --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/storeFormData_passwordMissing_partialSave.json @@ -0,0 +1,6 @@ +{ + "credentials": { + "username": "dax@duck.com" + }, + "trigger": "partialSave" +} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_formSubmission.json b/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_formSubmission.json new file mode 100644 index 000000000000..1ea6a677efa9 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_formSubmission.json @@ -0,0 +1,7 @@ +{ + "credentials": { + "username": "dax@duck.com", + "password": "123456" + }, + "trigger": "formSubmission" +} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_missing.json b/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_missing.json new file mode 100644 index 000000000000..68b344e9a307 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_missing.json @@ -0,0 +1,6 @@ +{ + "credentials": { + "username": "dax@duck.com", + "password": "123456" + } +} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_partialSave.json b/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_partialSave.json new file mode 100644 index 000000000000..85daf42d98c4 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_partialSave.json @@ -0,0 +1,7 @@ +{ + "credentials": { + "username": "dax@duck.com", + "password": "123456" + }, + "trigger": "partialSave" +} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_unknown.json b/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_unknown.json new file mode 100644 index 000000000000..c7a4d82bc9b6 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/storeFormData_trigger_unknown.json @@ -0,0 +1,7 @@ +{ + "credentials": { + "username": "dax@duck.com", + "password": "123456" + }, + "trigger": "unknown trigger" +} \ No newline at end of file