Skip to content

Commit

Permalink
Autofill: Increase ratio of complete credential saves
Browse files Browse the repository at this point in the history
  • Loading branch information
CDRussell committed Dec 20, 2024
1 parent f509798 commit 16e1970
Show file tree
Hide file tree
Showing 19 changed files with 522 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,28 @@ 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
import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore
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
import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -182,6 +190,7 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
emailProtectionInContextSignupFlowCallback?.closeInContextSignup()
}

@Suppress("UNUSED_PARAMETER")
@JavascriptInterface
fun showInContextEmailProtectionSignupPrompt(data: String) {
coroutineScope.launch(dispatcherProvider.io()) {
Expand Down Expand Up @@ -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<Actions>,
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 -> {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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?
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class RealAutofillRuntimeConfigProvider @Inject constructor(
showInlineKeyIcon = true,
showInContextEmailProtectionSignup = canShowInContextEmailProtectionSignup(url),
unknownUsernameCategorization = canCategorizeUnknownUsername(),
partialFormSaves = partialFormSaves(),
)
val availableInputTypes = generateAvailableInputTypes(url)

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface RuntimeConfigurationWriter {
showInlineKeyIcon: Boolean,
showInContextEmailProtectionSignup: Boolean,
unknownUsernameCategorization: Boolean,
partialFormSaves: Boolean,
): String
}

Expand Down Expand Up @@ -88,6 +89,7 @@ class RealRuntimeConfigurationWriter @Inject constructor(val moshi: Moshi) : Run
showInlineKeyIcon: Boolean,
showInContextEmailProtectionSignup: Boolean,
unknownUsernameCategorization: Boolean,
partialFormSaves: Boolean,
): String {
return """
userPreferences = {
Expand All @@ -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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,

;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<AutofillDataRequest>
Expand All @@ -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<AutofillDataRequest> {
return withContext(dispatchers.io()) {
Expand All @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.duckduckgo.autofill.impl.jsbridge.request

data class AutofillStoreFormDataRequest(
val credentials: AutofillStoreFormDataCredentialsRequest?,
val trigger: FormSubmissionTriggerType,
)

data class AutofillStoreFormDataCredentialsRequest(
Expand Down
Loading

0 comments on commit 16e1970

Please sign in to comment.