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 13, 2024
1 parent cc21a12 commit 969ed0e
Show file tree
Hide file tree
Showing 17 changed files with 1,493 additions and 794 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,51 @@ 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
Timber.i("Autogenerated? %s, Previous autostored login ID: %s", autogenerated, autologinId)
val autosavedLogin = autologinId?.let { autofillStore.getCredentialsWithId(it) }

val actions = passwordEventResolver.decideActions(autosavedLogin, autogenerated)
processStoreFormDataActions(actions, currentUrl, credentials, wasBackfilled)
}

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,
wasUsernameBackfilled: Boolean,
) {
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 +383,19 @@ 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) {
return BackfillNotSupported
}

val username = partialCredentialSaveStore.consumeUsername(currentUrl) ?: return BackfillNotSupported
return BackfillSupported(username)
}

private fun isUpdateRequired(
existingCredentials: LoginCredentials,
credentials: LoginCredentials,
Expand Down Expand Up @@ -421,6 +469,22 @@ 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 -> {
Timber.v("Backfilling username: %s for %s", result.username, currentUrl)
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,81 @@
/*
* 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 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

interface PartialCredentialSaveStore {
suspend fun saveUsername(url: String, username: String)
suspend fun consumeUsername(url: String): String?

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 {

private val map = mutableMapOf<String, PartialSave>()

override suspend fun saveUsername(
url: String,
username: String,
) {
withContext(dispatchers.io()) {
val etldPlusOne = urlMatcher.extractUrlPartsForAutofill(url).eTldPlus1 ?: return@withContext
map[etldPlusOne] = PartialSave(username = username, creationTimestamp = timeProvider.currentTimeMillis())
}
}

override suspend fun consumeUsername(url: String): String? {
return withContext(dispatchers.io()) {
removeExpiredEntries()
val etldPlusOne = urlMatcher.extractUrlPartsForAutofill(url).eTldPlus1
map[etldPlusOne]?.username?.also {
map.remove(etldPlusOne)
}
}
}

private fun removeExpiredEntries() {
map.entries.removeIf { it.value.isExpired() }
}

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

companion object {
val MAX_VALIDITY_MS = TimeUnit.MINUTES.toMillis(3)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ 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.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
Expand Down Expand Up @@ -79,6 +81,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()
Expand Down Expand Up @@ -107,6 +110,7 @@ class AutofillStoredBackJavascriptInterfaceTest {
loginDeduplicator = loginDeduplicator,
systemAutofillServiceSuppressor = systemAutofillServiceSuppressor,
neverSavedSiteRepository = neverSavedSiteRepository,
partialCredentialSaveStore = partialCredentialSaveStore,
)
testee.callback = testCallback
testee.webView = testWebView
Expand Down Expand Up @@ -340,7 +344,7 @@ class AutofillStoredBackJavascriptInterfaceTest {
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))
}
Expand Down
Loading

0 comments on commit 969ed0e

Please sign in to comment.