Skip to content

Add in-browser prompt to import Google Passwords #6290

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ import com.duckduckgo.autoconsent.api.AutoconsentCallback
import com.duckduckgo.autofill.api.AutofillCapabilityChecker
import com.duckduckgo.autofill.api.AutofillEventListener
import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin
import com.duckduckgo.autofill.api.AutofillImportLaunchSource.InBrowserPromo
import com.duckduckgo.autofill.api.AutofillScreenLaunchSource
import com.duckduckgo.autofill.api.AutofillScreens.AutofillPasswordsManagementScreenWithSuggestions
import com.duckduckgo.autofill.api.AutofillScreens.AutofillPasswordsManagementViewCredential
Expand Down Expand Up @@ -761,6 +762,20 @@ class BrowserTabFragment :
viewModel.onShowUserCredentialsSaved(savedCredentials)
}

override suspend fun promptUserToImportPassword(originalUrl: String) {
withContext(dispatchers.main()) {
showDialogHidingPrevious(
credentialAutofillDialogFactory.autofillImportPasswordsPromoDialog(
importSource = InBrowserPromo,
tabId = tabId,
url = originalUrl,
),
tabId,
requiredUrl = originalUrl,
)
}
}

override suspend fun onCredentialsAvailableToSave(
currentUrl: String,
credentials: LoginCredentials,
Expand Down Expand Up @@ -3178,13 +3193,16 @@ class BrowserTabFragment :
autofillFragmentResultListeners.getPlugins().forEach { plugin ->
setFragmentResultListener(plugin.resultKey(tabId)) { _, result ->
context?.let {
plugin.processResult(
result = result,
context = it,
tabId = tabId,
fragment = this@BrowserTabFragment,
autofillCallback = this@BrowserTabFragment,
)
lifecycleScope.launch {
plugin.processResult(
result = result,
context = it,
tabId = tabId,
fragment = this@BrowserTabFragment,
autofillCallback = this@BrowserTabFragment,
webView = webView,
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,11 @@ interface CredentialAutofillDialogFactory {
* Creates a dialog which prompts the user to sign up for Email Protection
*/
fun emailProtectionInContextSignUpDialog(tabId: String): DialogFragment

/**
* Creates a dialog which prompts the user to import passwords from Google Passwords
*/
fun autofillImportPasswordsPromoDialog(importSource: AutofillImportLaunchSource, tabId: String, url: String): DialogFragment
}

private fun prefix(
Expand All @@ -257,3 +262,13 @@ private fun prefix(
): String {
return "$tabId/$tag"
}

@Parcelize
enum class AutofillImportLaunchSource(val value: String) : Parcelable {
PasswordManagementPromo("password_management_promo"),
PasswordManagementEmptyState("password_management_empty_state"),
PasswordManagementOverflow("password_management_overflow"),
AutofillSettings("autofill_settings_button"),
InBrowserPromo("in_browser_promo"),
Unknown("unknown"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,7 @@ interface AutofillFeature {

@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE)
fun canPromoteImportPasswordsInPasswordManagement(): Toggle

@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE)
fun canPromoteImportGooglePasswordsInBrowser(): Toggle
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.duckduckgo.autofill.api

import android.content.Context
import android.os.Bundle
import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.fragment.app.Fragment

Expand All @@ -34,12 +35,13 @@ interface AutofillFragmentResultsPlugin {
* Will be invoked on the main thread.
*/
@MainThread
fun processResult(
suspend fun processResult(
result: Bundle,
context: Context,
tabId: String,
fragment: Fragment,
autofillCallback: AutofillEventListener,
webView: WebView?,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,9 @@ interface Callback {
* Called when credentials have been saved, and we want to show the user some visual confirmation.
*/
fun onCredentialsSaved(savedCredentials: LoginCredentials)

/**
* Called when the user should be prompted to import passwords from Google.
*/
suspend fun promptUserToImportPassword(originalUrl: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ 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.importing.InBrowserImportPromo
import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster
import com.duckduckgo.autofill.impl.jsbridge.request.AutofillDataRequest
import com.duckduckgo.autofill.impl.jsbridge.request.AutofillRequestParser
Expand All @@ -46,6 +47,7 @@ import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubTy
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.CREDENTIALS_IMPORT
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
Expand Down Expand Up @@ -133,6 +135,7 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
private val partialCredentialSaveStore: PartialCredentialSaveStore,
private val usernameBackFiller: UsernameBackFiller,
private val existingCredentialMatchDetector: ExistingCredentialMatchDetector,
private val inBrowserImportPromo: InBrowserImportPromo,
) : AutofillJavascriptInterface {

override var callback: Callback? = null
Expand Down Expand Up @@ -186,6 +189,12 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
}
}

private fun handlePromoteImport(url: String) {
coroutineScope.launch(dispatcherProvider.io()) {
callback?.promptUserToImportPassword(url)
}
}

@JavascriptInterface
override fun getIncontextSignupDismissedAt(data: String) {
emailProtectionInContextSignupJob += coroutineScope.launch(dispatcherProvider.io()) {
Expand Down Expand Up @@ -246,7 +255,12 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
val finalCredentialList = ensureUsernamesNotNull(dedupedCredentials)

if (finalCredentialList.isEmpty()) {
callback?.noCredentialsAvailable(url)
val canShowImport = inBrowserImportPromo.canShowPromo(credentialsAvailableForCurrentPage = false, url = url)
if (canShowImport) {
handlePromoteImport(url)
} else {
callback?.noCredentialsAvailable(url)
}
} else {
callback?.onCredentialsAvailableToInject(url, finalCredentialList, triggerType)
}
Expand All @@ -265,6 +279,7 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
return when (trigger) {
USER_INITIATED -> LoginTriggerType.USER_INITIATED
AUTOPROMPT -> LoginTriggerType.AUTOPROMPT
CREDENTIALS_IMPORT -> LoginTriggerType.AUTOPROMPT
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,24 @@ class SecureStoreBackedAutofillStore @Inject constructor(
return autofillPrefsStore.hasEverImportedPasswordsFlow()
}

override var hasDismissedImportedPasswordsPromo: Boolean
get() = autofillPrefsStore.hasDismissedImportedPasswordsPromo
override var hasDeclinedPasswordManagementImportPromo: Boolean
get() = autofillPrefsStore.hasDeclinedPasswordManagementImportPromo
set(value) {
autofillPrefsStore.hasDismissedImportedPasswordsPromo = value
autofillPrefsStore.hasDeclinedPasswordManagementImportPromo = value
}

override var hasDeclinedInBrowserPasswordImportPromo: Boolean
get() = autofillPrefsStore.hasDeclinedInBrowserPasswordImportPromo
set(value) {
autofillPrefsStore.hasDeclinedInBrowserPasswordImportPromo = value
}

override var inBrowserImportPromoShownCount: Int
get() = autofillPrefsStore.inBrowserImportPromoShownCount
set(value) {
autofillPrefsStore.inBrowserImportPromoShownCount = value
}

override var autofillDeclineCount: Int
get() = autofillPrefsStore.autofillDeclineCount
set(value) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2025 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.configuration

import com.duckduckgo.autofill.api.AutofillCapabilityChecker
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.api.email.EmailManager
import com.duckduckgo.autofill.impl.configuration.AutofillAvailableInputTypesProvider.AvailableInputTypes
import com.duckduckgo.autofill.impl.importing.InBrowserImportPromo
import com.duckduckgo.autofill.impl.jsbridge.response.AvailableInputTypeCredentials
import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials
import com.duckduckgo.autofill.impl.store.InternalAutofillStore
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.withContext

interface AutofillAvailableInputTypesProvider {
suspend fun getTypes(url: String?): AvailableInputTypes

data class AvailableInputTypes(
val username: Boolean = false,
val password: Boolean = false,
val email: Boolean = false,
val credentialsImport: Boolean = false,
)
}

@ContributesBinding(AppScope::class)
class RealAutofillAvailableInputTypesProvider @Inject constructor(
private val emailManager: EmailManager,
private val autofillStore: InternalAutofillStore,
private val shareableCredentials: ShareableCredentials,
private val autofillCapabilityChecker: AutofillCapabilityChecker,
private val inBrowserPromo: InBrowserImportPromo,
private val dispatchers: DispatcherProvider,
) : AutofillAvailableInputTypesProvider {

override suspend fun getTypes(url: String?): AvailableInputTypes {
return withContext(dispatchers.io()) {
val availableInputTypeCredentials = determineIfCredentialsAvailable(url)
val credentialsAvailableOnThisPage = availableInputTypeCredentials.username || availableInputTypeCredentials.password
val emailAvailable = determineIfEmailAvailable()
val importPromoAvailable = inBrowserPromo.canShowPromo(credentialsAvailableOnThisPage, url)

AvailableInputTypes(
username = availableInputTypeCredentials.username,
password = availableInputTypeCredentials.password,
email = emailAvailable,
credentialsImport = importPromoAvailable,
)
}
}

private suspend fun determineIfCredentialsAvailable(url: String?): AvailableInputTypeCredentials {
return if (url == null || !autofillCapabilityChecker.canInjectCredentialsToWebView(url)) {
AvailableInputTypeCredentials(username = false, password = false)
} else {
val matches = mutableListOf<LoginCredentials>()
val directMatches = autofillStore.getCredentials(url)
val shareableMatches = shareableCredentials.shareableCredentials(url)
matches.addAll(directMatches)
matches.addAll(shareableMatches)

val usernameSearch = matches.find { !it.username.isNullOrEmpty() }
val passwordSearch = matches.find { !it.password.isNullOrEmpty() }

AvailableInputTypeCredentials(username = usernameSearch != null, password = passwordSearch != null)
}
}
private fun determineIfEmailAvailable(): Boolean = emailManager.isSignedIn()
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,7 @@ package com.duckduckgo.autofill.impl.configuration

import com.duckduckgo.autofill.api.AutofillCapabilityChecker
import com.duckduckgo.autofill.api.AutofillFeature
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.api.email.EmailManager
import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextAvailabilityRules
import com.duckduckgo.autofill.impl.jsbridge.response.AvailableInputTypeCredentials
import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials
import com.duckduckgo.autofill.impl.store.InternalAutofillStore
import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
Expand All @@ -40,15 +35,13 @@ interface AutofillRuntimeConfigProvider {

@ContributesBinding(AppScope::class)
class RealAutofillRuntimeConfigProvider @Inject constructor(
private val emailManager: EmailManager,
private val autofillStore: InternalAutofillStore,
private val runtimeConfigurationWriter: RuntimeConfigurationWriter,
private val autofillCapabilityChecker: AutofillCapabilityChecker,
private val autofillFeature: AutofillFeature,
private val shareableCredentials: ShareableCredentials,
private val emailProtectionInContextAvailabilityRules: EmailProtectionInContextAvailabilityRules,
private val neverSavedSiteRepository: NeverSavedSiteRepository,
private val siteSpecificFixesStore: AutofillSiteSpecificFixesStore,
private val autofillAvailableInputTypesProvider: AutofillAvailableInputTypesProvider,
) : AutofillRuntimeConfigProvider {
override suspend fun getRuntimeConfiguration(
rawJs: String,
Expand Down Expand Up @@ -88,32 +81,14 @@ class RealAutofillRuntimeConfigProvider @Inject constructor(
}

private suspend fun generateAvailableInputTypes(url: String?): String {
val credentialsAvailable = determineIfCredentialsAvailable(url)
val emailAvailable = determineIfEmailAvailable()
val inputTypes = autofillAvailableInputTypesProvider.getTypes(url)

val json = runtimeConfigurationWriter.generateResponseGetAvailableInputTypes(credentialsAvailable, emailAvailable).also {
val json = runtimeConfigurationWriter.generateResponseGetAvailableInputTypes(inputTypes).also {
logcat(VERBOSE) { "autofill-config: availableInputTypes for $url: \n$it" }
}
return "availableInputTypes = $json"
}

private suspend fun determineIfCredentialsAvailable(url: String?): AvailableInputTypeCredentials {
return if (url == null || !autofillCapabilityChecker.canInjectCredentialsToWebView(url)) {
AvailableInputTypeCredentials(username = false, password = false)
} else {
val matches = mutableListOf<LoginCredentials>()
val directMatches = autofillStore.getCredentials(url)
val shareableMatches = shareableCredentials.shareableCredentials(url)
matches.addAll(directMatches)
matches.addAll(shareableMatches)

val usernameSearch = matches.find { !it.username.isNullOrEmpty() }
val passwordSearch = matches.find { !it.password.isNullOrEmpty() }

AvailableInputTypeCredentials(username = usernameSearch != null, password = passwordSearch != null)
}
}

private suspend fun canInjectCredentials(url: String?): Boolean {
if (url == null) return false
return autofillCapabilityChecker.canInjectCredentialsToWebView(url)
Expand Down Expand Up @@ -160,8 +135,6 @@ class RealAutofillRuntimeConfigProvider @Inject constructor(
return emailProtectionInContextAvailabilityRules.permittedToShow(url)
}

private fun determineIfEmailAvailable(): Boolean = emailManager.isSignedIn()

companion object {
private const val TAG_INJECT_CONTENT_SCOPE = "// INJECT contentScope HERE"
private const val TAG_INJECT_USER_UNPROTECTED_DOMAINS = "// INJECT userUnprotectedDomains HERE"
Expand Down
Loading
Loading