Skip to content

Commit 5ee7574

Browse files
authored
Add in-browser prompt to import Google Passwords (#6290)
Task/Issue URL: https://app.asana.com/1/137249556945/project/608920331025315/task/1210270953487596?focus=true ### Description Adds ability to show a prompt offering to import passwords from Google at a time when it's most useful to the user: when they've hit a login form and they haven't imported before. ### Steps to test this PR - [x] Fresh install from this branch - [x] Visit https://fill.dev/form/login-simple - [x] Tap on username field; verify you see the dialog letting you import Google passwords #### Soft-Dismiss scenarios - [x] Tap on the ✖️ button to dismiss it [dismiss count == 1]; verify it dismisses - [x] Tap on password field; verify it doesn't show again (it shouldn't show more than once on a page load) - [x] Refresh the page and tap on username field again; verify the dialog shows again - [x] Tap the back button [dismiss count == 2]; verify the dialog dismisses - [x] Tap on password field; verify it doesn't show again (it shouldn't show more than once on a page load) - [x] Refresh the page and tap on username field again; verify the dialog shows again - [x] Tap outside the dialog [dismiss count == 3]; verify the dialog dismisses - [x] Refresh the page and tap on username field again; verify the dialog shows again - [x] Tap the ✖️ button again [dismiss count == 4] - [x] Refresh the page and tap on username field again; verify the dialog shows again - [x] Tap the ✖️ button again [dismiss count == 5] - [x] Refresh the page and tap on username field again; verify the dialog shows again - [x] Tap the ✖️ button again [dismiss count == 6] - [x] Refresh the page and tap on username field again; verify the dialog **does not** show again #### Hard-Dismiss scenario - [x] Fresh install from this branch (or use the autofill dev setting called `Previous Google Imports` to clear previous knowledge of the import dialogs) - [x] Visit https://fill.dev/form/login-simple - [x] Tap on username field; verify you see the dialog letting you import Google passwords - [x] Tap on **Set Up Later in Settings** button; verify the dialog dismisses - [x] Refresh the page and tap on username field again; verify the dialog **does not** show again #### Site already has credentials - [x] Fresh install from this branch (or use the autofill dev setting called `Previous Google Imports` to clear previous knowledge of the import dialogs) - [x] Manually add a login credential for fill.dev in the autofill management screen - [x] Visit https://fill.dev/form/login-simple - [x] Decline to autofill - [x] Tap on username field; verify you are **not** prompted to import passwords #### Completing import scenario - [x] Add a password for fill.dev to your Google Passwords; instructions for doing so can be found [here](https://app.asana.com/1/137249556945/project/608920331025315/task/1210786951836501?focus=true) - [x] Fresh install from this branch - [x] Visit https://fill.dev/form/login-simple - [x] Tap on username field; tap **Import From Google** button and complete the import flow - [x] Verify you see the import success dialog showing how many credentials were imported - [x] Dismiss that dialog (doesn't matter how). Verify you are then prompted to autofill with the new credential it just imported and that autofilling works if you agree to do it. #### Already imported scenario - [x] Fresh install from this branch - [x] Visit `Settings -> Passwords & Autofill` and tap on **Import Passwords from Google** button - [x] Complete the import - [x] Visit password list and delete any credentials for fill.dev (having a saved password for that site is not the scenario we want to test here) - [x] Visit https://fill.dev/form/login-simple - [x] Tap on username field; verify you **do not** see the dialog letting you import Google passwords #### Feature flag disabled scenario - [x] Fresh install from this branch - [x] Visit FF inventory and disable `canPromoteImportGooglePasswordsInBrowser` - [x] Visit https://fill.dev/form/login-simple - [x] Tap on username field; verify you **do not** see the import password dialog --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/864955901782631
1 parent 7c330c3 commit 5ee7574

File tree

62 files changed

+1488
-498
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+1488
-498
lines changed

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ import com.duckduckgo.autoconsent.api.AutoconsentCallback
210210
import com.duckduckgo.autofill.api.AutofillCapabilityChecker
211211
import com.duckduckgo.autofill.api.AutofillEventListener
212212
import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin
213+
import com.duckduckgo.autofill.api.AutofillImportLaunchSource.InBrowserPromo
213214
import com.duckduckgo.autofill.api.AutofillScreenLaunchSource
214215
import com.duckduckgo.autofill.api.AutofillScreens.AutofillPasswordsManagementScreenWithSuggestions
215216
import com.duckduckgo.autofill.api.AutofillScreens.AutofillPasswordsManagementViewCredential
@@ -762,6 +763,20 @@ class BrowserTabFragment :
762763
viewModel.onShowUserCredentialsSaved(savedCredentials)
763764
}
764765

766+
override suspend fun promptUserToImportPassword(originalUrl: String) {
767+
withContext(dispatchers.main()) {
768+
showDialogHidingPrevious(
769+
credentialAutofillDialogFactory.autofillImportPasswordsPromoDialog(
770+
importSource = InBrowserPromo,
771+
tabId = tabId,
772+
url = originalUrl,
773+
),
774+
tabId,
775+
requiredUrl = originalUrl,
776+
)
777+
}
778+
}
779+
765780
override suspend fun onCredentialsAvailableToSave(
766781
currentUrl: String,
767782
credentials: LoginCredentials,
@@ -3181,13 +3196,16 @@ class BrowserTabFragment :
31813196
autofillFragmentResultListeners.getPlugins().forEach { plugin ->
31823197
setFragmentResultListener(plugin.resultKey(tabId)) { _, result ->
31833198
context?.let {
3184-
plugin.processResult(
3185-
result = result,
3186-
context = it,
3187-
tabId = tabId,
3188-
fragment = this@BrowserTabFragment,
3189-
autofillCallback = this@BrowserTabFragment,
3190-
)
3199+
lifecycleScope.launch {
3200+
plugin.processResult(
3201+
result = result,
3202+
context = it,
3203+
tabId = tabId,
3204+
fragment = this@BrowserTabFragment,
3205+
autofillCallback = this@BrowserTabFragment,
3206+
webView = webView,
3207+
)
3208+
}
31913209
}
31923210
}
31933211
}

autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillCredentialDialogs.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,11 @@ interface CredentialAutofillDialogFactory {
249249
* Creates a dialog which prompts the user to sign up for Email Protection
250250
*/
251251
fun emailProtectionInContextSignUpDialog(tabId: String): DialogFragment
252+
253+
/**
254+
* Creates a dialog which prompts the user to import passwords from Google Passwords
255+
*/
256+
fun autofillImportPasswordsPromoDialog(importSource: AutofillImportLaunchSource, tabId: String, url: String): DialogFragment
252257
}
253258

254259
private fun prefix(
@@ -257,3 +262,13 @@ private fun prefix(
257262
): String {
258263
return "$tabId/$tag"
259264
}
265+
266+
@Parcelize
267+
enum class AutofillImportLaunchSource(val value: String) : Parcelable {
268+
PasswordManagementPromo("password_management_promo"),
269+
PasswordManagementEmptyState("password_management_empty_state"),
270+
PasswordManagementOverflow("password_management_overflow"),
271+
AutofillSettings("autofill_settings_button"),
272+
InBrowserPromo("in_browser_promo"),
273+
Unknown("unknown"),
274+
}

autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,4 +154,7 @@ interface AutofillFeature {
154154

155155
@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE)
156156
fun canPromoteImportPasswordsInPasswordManagement(): Toggle
157+
158+
@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE)
159+
fun canPromoteImportGooglePasswordsInBrowser(): Toggle
157160
}

autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFragmentResultsPlugin.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.duckduckgo.autofill.api
1818

1919
import android.content.Context
2020
import android.os.Bundle
21+
import android.webkit.WebView
2122
import androidx.annotation.MainThread
2223
import androidx.fragment.app.Fragment
2324

@@ -34,12 +35,13 @@ interface AutofillFragmentResultsPlugin {
3435
* Will be invoked on the main thread.
3536
*/
3637
@MainThread
37-
fun processResult(
38+
suspend fun processResult(
3839
result: Bundle,
3940
context: Context,
4041
tabId: String,
4142
fragment: Fragment,
4243
autofillCallback: AutofillEventListener,
44+
webView: WebView?,
4345
)
4446

4547
/**

autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/BrowserAutofill.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,9 @@ interface Callback {
153153
* Called when credentials have been saved, and we want to show the user some visual confirmation.
154154
*/
155155
fun onCredentialsSaved(savedCredentials: LoginCredentials)
156+
157+
/**
158+
* Called when the user should be prompted to import passwords from Google.
159+
*/
160+
suspend fun promptUserToImportPassword(originalUrl: String)
156161
}

autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.duckduckgo.autofill.impl.deduper.AutofillLoginDeduplicator
3333
import com.duckduckgo.autofill.impl.domain.javascript.JavascriptCredentials
3434
import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextRecentInstallChecker
3535
import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore
36+
import com.duckduckgo.autofill.impl.importing.InBrowserImportPromo
3637
import com.duckduckgo.autofill.impl.jsbridge.AutofillMessagePoster
3738
import com.duckduckgo.autofill.impl.jsbridge.request.AutofillDataRequest
3839
import com.duckduckgo.autofill.impl.jsbridge.request.AutofillRequestParser
@@ -46,6 +47,7 @@ import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubTy
4647
import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.USERNAME
4748
import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType
4849
import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.AUTOPROMPT
50+
import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.CREDENTIALS_IMPORT
4951
import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.USER_INITIATED
5052
import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter
5153
import com.duckduckgo.autofill.impl.partialsave.PartialCredentialSaveStore
@@ -133,6 +135,7 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
133135
private val partialCredentialSaveStore: PartialCredentialSaveStore,
134136
private val usernameBackFiller: UsernameBackFiller,
135137
private val existingCredentialMatchDetector: ExistingCredentialMatchDetector,
138+
private val inBrowserImportPromo: InBrowserImportPromo,
136139
) : AutofillJavascriptInterface {
137140

138141
override var callback: Callback? = null
@@ -186,6 +189,12 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
186189
}
187190
}
188191

192+
private fun handlePromoteImport(url: String) {
193+
coroutineScope.launch(dispatcherProvider.io()) {
194+
callback?.promptUserToImportPassword(url)
195+
}
196+
}
197+
189198
@JavascriptInterface
190199
override fun getIncontextSignupDismissedAt(data: String) {
191200
emailProtectionInContextSignupJob += coroutineScope.launch(dispatcherProvider.io()) {
@@ -246,7 +255,12 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
246255
val finalCredentialList = ensureUsernamesNotNull(dedupedCredentials)
247256

248257
if (finalCredentialList.isEmpty()) {
249-
callback?.noCredentialsAvailable(url)
258+
val canShowImport = inBrowserImportPromo.canShowPromo(credentialsAvailableForCurrentPage = false, url = url)
259+
if (canShowImport) {
260+
handlePromoteImport(url)
261+
} else {
262+
callback?.noCredentialsAvailable(url)
263+
}
250264
} else {
251265
callback?.onCredentialsAvailableToInject(url, finalCredentialList, triggerType)
252266
}
@@ -265,6 +279,7 @@ class AutofillStoredBackJavascriptInterface @Inject constructor(
265279
return when (trigger) {
266280
USER_INITIATED -> LoginTriggerType.USER_INITIATED
267281
AUTOPROMPT -> LoginTriggerType.AUTOPROMPT
282+
CREDENTIALS_IMPORT -> LoginTriggerType.AUTOPROMPT
268283
}
269284
}
270285

autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStore.kt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,24 @@ class SecureStoreBackedAutofillStore @Inject constructor(
9494
return autofillPrefsStore.hasEverImportedPasswordsFlow()
9595
}
9696

97-
override var hasDismissedImportedPasswordsPromo: Boolean
98-
get() = autofillPrefsStore.hasDismissedImportedPasswordsPromo
97+
override var hasDeclinedPasswordManagementImportPromo: Boolean
98+
get() = autofillPrefsStore.hasDeclinedPasswordManagementImportPromo
9999
set(value) {
100-
autofillPrefsStore.hasDismissedImportedPasswordsPromo = value
100+
autofillPrefsStore.hasDeclinedPasswordManagementImportPromo = value
101101
}
102+
103+
override var hasDeclinedInBrowserPasswordImportPromo: Boolean
104+
get() = autofillPrefsStore.hasDeclinedInBrowserPasswordImportPromo
105+
set(value) {
106+
autofillPrefsStore.hasDeclinedInBrowserPasswordImportPromo = value
107+
}
108+
109+
override var inBrowserImportPromoShownCount: Int
110+
get() = autofillPrefsStore.inBrowserImportPromoShownCount
111+
set(value) {
112+
autofillPrefsStore.inBrowserImportPromoShownCount = value
113+
}
114+
102115
override var autofillDeclineCount: Int
103116
get() = autofillPrefsStore.autofillDeclineCount
104117
set(value) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.autofill.impl.configuration
18+
19+
import com.duckduckgo.autofill.api.AutofillCapabilityChecker
20+
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
21+
import com.duckduckgo.autofill.api.email.EmailManager
22+
import com.duckduckgo.autofill.impl.configuration.AutofillAvailableInputTypesProvider.AvailableInputTypes
23+
import com.duckduckgo.autofill.impl.importing.InBrowserImportPromo
24+
import com.duckduckgo.autofill.impl.jsbridge.response.AvailableInputTypeCredentials
25+
import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials
26+
import com.duckduckgo.autofill.impl.store.InternalAutofillStore
27+
import com.duckduckgo.common.utils.DispatcherProvider
28+
import com.duckduckgo.di.scopes.AppScope
29+
import com.squareup.anvil.annotations.ContributesBinding
30+
import javax.inject.Inject
31+
import kotlinx.coroutines.withContext
32+
33+
interface AutofillAvailableInputTypesProvider {
34+
suspend fun getTypes(url: String?): AvailableInputTypes
35+
36+
data class AvailableInputTypes(
37+
val username: Boolean = false,
38+
val password: Boolean = false,
39+
val email: Boolean = false,
40+
val credentialsImport: Boolean = false,
41+
)
42+
}
43+
44+
@ContributesBinding(AppScope::class)
45+
class RealAutofillAvailableInputTypesProvider @Inject constructor(
46+
private val emailManager: EmailManager,
47+
private val autofillStore: InternalAutofillStore,
48+
private val shareableCredentials: ShareableCredentials,
49+
private val autofillCapabilityChecker: AutofillCapabilityChecker,
50+
private val inBrowserPromo: InBrowserImportPromo,
51+
private val dispatchers: DispatcherProvider,
52+
) : AutofillAvailableInputTypesProvider {
53+
54+
override suspend fun getTypes(url: String?): AvailableInputTypes {
55+
return withContext(dispatchers.io()) {
56+
val availableInputTypeCredentials = determineIfCredentialsAvailable(url)
57+
val credentialsAvailableOnThisPage = availableInputTypeCredentials.username || availableInputTypeCredentials.password
58+
val emailAvailable = determineIfEmailAvailable()
59+
val importPromoAvailable = inBrowserPromo.canShowPromo(credentialsAvailableOnThisPage, url)
60+
61+
AvailableInputTypes(
62+
username = availableInputTypeCredentials.username,
63+
password = availableInputTypeCredentials.password,
64+
email = emailAvailable,
65+
credentialsImport = importPromoAvailable,
66+
)
67+
}
68+
}
69+
70+
private suspend fun determineIfCredentialsAvailable(url: String?): AvailableInputTypeCredentials {
71+
return if (url == null || !autofillCapabilityChecker.canInjectCredentialsToWebView(url)) {
72+
AvailableInputTypeCredentials(username = false, password = false)
73+
} else {
74+
val matches = mutableListOf<LoginCredentials>()
75+
val directMatches = autofillStore.getCredentials(url)
76+
val shareableMatches = shareableCredentials.shareableCredentials(url)
77+
matches.addAll(directMatches)
78+
matches.addAll(shareableMatches)
79+
80+
val usernameSearch = matches.find { !it.username.isNullOrEmpty() }
81+
val passwordSearch = matches.find { !it.password.isNullOrEmpty() }
82+
83+
AvailableInputTypeCredentials(username = usernameSearch != null, password = passwordSearch != null)
84+
}
85+
}
86+
private fun determineIfEmailAvailable(): Boolean = emailManager.isSignedIn()
87+
}

autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,7 @@ package com.duckduckgo.autofill.impl.configuration
1818

1919
import com.duckduckgo.autofill.api.AutofillCapabilityChecker
2020
import com.duckduckgo.autofill.api.AutofillFeature
21-
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
22-
import com.duckduckgo.autofill.api.email.EmailManager
2321
import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextAvailabilityRules
24-
import com.duckduckgo.autofill.impl.jsbridge.response.AvailableInputTypeCredentials
25-
import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials
26-
import com.duckduckgo.autofill.impl.store.InternalAutofillStore
2722
import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository
2823
import com.duckduckgo.di.scopes.AppScope
2924
import com.squareup.anvil.annotations.ContributesBinding
@@ -40,15 +35,13 @@ interface AutofillRuntimeConfigProvider {
4035

4136
@ContributesBinding(AppScope::class)
4237
class RealAutofillRuntimeConfigProvider @Inject constructor(
43-
private val emailManager: EmailManager,
44-
private val autofillStore: InternalAutofillStore,
4538
private val runtimeConfigurationWriter: RuntimeConfigurationWriter,
4639
private val autofillCapabilityChecker: AutofillCapabilityChecker,
4740
private val autofillFeature: AutofillFeature,
48-
private val shareableCredentials: ShareableCredentials,
4941
private val emailProtectionInContextAvailabilityRules: EmailProtectionInContextAvailabilityRules,
5042
private val neverSavedSiteRepository: NeverSavedSiteRepository,
5143
private val siteSpecificFixesStore: AutofillSiteSpecificFixesStore,
44+
private val autofillAvailableInputTypesProvider: AutofillAvailableInputTypesProvider,
5245
) : AutofillRuntimeConfigProvider {
5346
override suspend fun getRuntimeConfiguration(
5447
rawJs: String,
@@ -88,32 +81,14 @@ class RealAutofillRuntimeConfigProvider @Inject constructor(
8881
}
8982

9083
private suspend fun generateAvailableInputTypes(url: String?): String {
91-
val credentialsAvailable = determineIfCredentialsAvailable(url)
92-
val emailAvailable = determineIfEmailAvailable()
84+
val inputTypes = autofillAvailableInputTypesProvider.getTypes(url)
9385

94-
val json = runtimeConfigurationWriter.generateResponseGetAvailableInputTypes(credentialsAvailable, emailAvailable).also {
86+
val json = runtimeConfigurationWriter.generateResponseGetAvailableInputTypes(inputTypes).also {
9587
logcat(VERBOSE) { "autofill-config: availableInputTypes for $url: \n$it" }
9688
}
9789
return "availableInputTypes = $json"
9890
}
9991

100-
private suspend fun determineIfCredentialsAvailable(url: String?): AvailableInputTypeCredentials {
101-
return if (url == null || !autofillCapabilityChecker.canInjectCredentialsToWebView(url)) {
102-
AvailableInputTypeCredentials(username = false, password = false)
103-
} else {
104-
val matches = mutableListOf<LoginCredentials>()
105-
val directMatches = autofillStore.getCredentials(url)
106-
val shareableMatches = shareableCredentials.shareableCredentials(url)
107-
matches.addAll(directMatches)
108-
matches.addAll(shareableMatches)
109-
110-
val usernameSearch = matches.find { !it.username.isNullOrEmpty() }
111-
val passwordSearch = matches.find { !it.password.isNullOrEmpty() }
112-
113-
AvailableInputTypeCredentials(username = usernameSearch != null, password = passwordSearch != null)
114-
}
115-
}
116-
11792
private suspend fun canInjectCredentials(url: String?): Boolean {
11893
if (url == null) return false
11994
return autofillCapabilityChecker.canInjectCredentialsToWebView(url)
@@ -160,8 +135,6 @@ class RealAutofillRuntimeConfigProvider @Inject constructor(
160135
return emailProtectionInContextAvailabilityRules.permittedToShow(url)
161136
}
162137

163-
private fun determineIfEmailAvailable(): Boolean = emailManager.isSignedIn()
164-
165138
companion object {
166139
private const val TAG_INJECT_CONTENT_SCOPE = "// INJECT contentScope HERE"
167140
private const val TAG_INJECT_USER_UNPROTECTED_DOMAINS = "// INJECT userUnprotectedDomains HERE"

0 commit comments

Comments
 (0)