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 17, 2024
1 parent 92f9069 commit b196a10
Show file tree
Hide file tree
Showing 18 changed files with 1,517 additions and 764 deletions.
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")
}
}
}

private suspend fun handleRequestForFormSubmission(
requestCredentials: AutofillStoreFormDataCredentialsRequest,
currentUrl: String,
) {
val jsCredentials = JavascriptCredentials(requestCredentials.username, requestCredentials.password)

val (credentials, wasBackfilled) = 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 backfillUsernameSupport(
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.consumeActiveBackFill(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,24 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
)
}

private suspend fun LoginCredentials.backfillUsernameIfRequired(currentUrl: String): Pair<LoginCredentials, Boolean> {
// determine if we can and should use a partial previous submission's username
return when (val result = backfillUsernameSupport(this, currentUrl)) {
is BackfillSupported -> {
Pair(this.copy(username = result.username), true)
}

is BackfillNotSupported -> {
Pair(this, false)
}
}
}

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 @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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.partialsave.PartialCredentialSaveStore.PartialSave
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 consumeActiveBackFill(url: String): String?
fun consumeBackFillHistory(
url: String,
username: String?,
): Boolean

data class PartialSave(
val username: String,
val creationTimestamp: Long,
)
}

@ContributesBinding(AppScope::class)
@SingleInstanceIn(AppScope::class)
class PartialCredentialSaveInMemoryStore @Inject constructor(
private val urlMatcher: AutofillUrlMatcher,
private val timeProvider: TimeProvider,
private val dispatchers: DispatcherProvider,
) : PartialCredentialSaveStore {

// these are potentially useful partial saves; ones which might be used later for backFilling
private val potentialBackFills = LruCache<String, PartialSave>(5)

// these are ones which were actually backFilled
private val backFillHistory = LruCache<String, PartialSave>(5)

override suspend fun saveUsername(
url: String,
username: String,
) {
withContext(dispatchers.io()) {
val etldPlusOne = extractEtldPlusOne(url) ?: return@withContext
potentialBackFills.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 consumeActiveBackFill(url: String): String? {
return withContext(dispatchers.io()) {
removeExpiredEntries()
val etldPlusOne = extractEtldPlusOne(url) ?: return@withContext null
val activeBackFill = potentialBackFills[etldPlusOne]
activeBackFill?.username?.also {
potentialBackFills.remove(etldPlusOne)
backFillHistory.put(etldPlusOne, activeBackFill)
}
}
}

override fun consumeBackFillHistory(
url: String,
username: String?,
): Boolean {
if (username == null) return false
val etldPlusOne = extractEtldPlusOne(url) ?: return false
return (backFillHistory[etldPlusOne]?.username == username).also {
if (it) {
backFillHistory.remove(etldPlusOne)
}
Timber.v("Username [%s] was %sbackfilled for %s", username, if (it) "" else "not ", etldPlusOne)
}
}

private fun removeExpiredEntries() {
potentialBackFills.snapshot().entries.forEach {
if (it.value.isExpired()) {
potentialBackFills.remove(it.key)
}
}
}

private fun extractEtldPlusOne(url: String) = urlMatcher.extractUrlPartsForAutofill(url).eTldPlus1

private fun PartialSave.isExpired(): Boolean {
return (timeProvider.currentTimeMillis() - creationTimestamp) > MAX_VALIDITY_MS
}

companion object {
val MAX_VALIDITY_MS = TimeUnit.MINUTES.toMillis(3)
}
}
Loading

0 comments on commit b196a10

Please sign in to comment.