diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt index 6259bc16c7e4..62a761398640 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt @@ -157,4 +157,7 @@ interface AutofillFeature { @Toggle.DefaultValue(defaultValue = DefaultFeatureValue.INTERNAL) fun passkeySupport(): Toggle + + @Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE) + fun canReAuthenticateGoogleLoginsAutomatically(): Toggle } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt index cf156ce6440c..8bc6847ec4cc 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt @@ -262,7 +262,23 @@ class AutofillStoredBackJavascriptInterface @Inject constructor( callback?.noCredentialsAvailable(url) } } else { - callback?.onCredentialsAvailableToInject(url, finalCredentialList, triggerType) + notifyListenerThatCredentialsAvailableToInject(url, finalCredentialList, triggerType, request) + } + } + + private suspend fun notifyListenerThatCredentialsAvailableToInject( + url: String, + finalCredentialList: List, + triggerType: LoginTriggerType, + request: AutofillDataRequest, + ) { + when (val currentCallback = callback) { + is InternalCallback -> { + currentCallback.onCredentialsAvailableToInjectWithReauth(url, finalCredentialList, triggerType, request.subType) + } + else -> { + currentCallback?.onCredentialsAvailableToInject(url, finalCredentialList, triggerType) + } } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/InternalCallback.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/InternalCallback.kt new file mode 100644 index 000000000000..b66b434999e1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/InternalCallback.kt @@ -0,0 +1,46 @@ +/* + * 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 + +import com.duckduckgo.autofill.api.Callback +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType + +/** + * Internal extension of the public Callback interface for extensions that isn't required outside of this module + */ +interface InternalCallback : Callback { + + /** + * Called when we've determined we have credentials we can offer to autofill for the user, + * with additional re-authentication context. + * * @param originalUrl The URL where autofill was requested + * @param credentials List of available stored credentials for this URL + * @param triggerType How this autofill request was triggered + * @param requestSubType The type of autofill request (USERNAME or PASSWORD) + */ + suspend fun onCredentialsAvailableToInjectWithReauth( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + requestSubType: SupportedAutofillInputSubType, + ) { + // Default implementation delegates to the public API + onCredentialsAvailableToInject(originalUrl, credentials, triggerType) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillAvailableInputTypesProvider.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillAvailableInputTypesProvider.kt index 458c1bd8b261..689608908b80 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillAvailableInputTypesProvider.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillAvailableInputTypesProvider.kt @@ -24,6 +24,8 @@ 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.autofill.impl.store.ReAuthenticationDetails +import com.duckduckgo.autofill.impl.store.emptyReAuthenticationDetails import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -31,7 +33,10 @@ import javax.inject.Inject import kotlinx.coroutines.withContext interface AutofillAvailableInputTypesProvider { - suspend fun getTypes(url: String?): AvailableInputTypes + suspend fun getTypes( + url: String?, + reAuthenticationDetails: ReAuthenticationDetails = emptyReAuthenticationDetails(), + ): AvailableInputTypes data class AvailableInputTypes( val username: Boolean = false, @@ -51,16 +56,26 @@ class RealAutofillAvailableInputTypesProvider @Inject constructor( private val dispatchers: DispatcherProvider, ) : AutofillAvailableInputTypesProvider { - override suspend fun getTypes(url: String?): AvailableInputTypes { + override suspend fun getTypes( + url: String?, + reAuthenticationDetails: ReAuthenticationDetails, + ): AvailableInputTypes { return withContext(dispatchers.io()) { val availableInputTypeCredentials = determineIfCredentialsAvailable(url) - val credentialsAvailableOnThisPage = availableInputTypeCredentials.username || availableInputTypeCredentials.password + val reauthCredentials = determineIfReauthenticationDetailsAvailable(reAuthenticationDetails) + + val finalCredentials = AvailableInputTypeCredentials( + username = availableInputTypeCredentials.username || reauthCredentials.username, + password = availableInputTypeCredentials.password || reauthCredentials.password, + ) + + val credentialsAvailableOnThisPage = finalCredentials.username || finalCredentials.password val emailAvailable = determineIfEmailAvailable() val importPromoAvailable = inBrowserPromo.canShowPromo(credentialsAvailableOnThisPage, url) AvailableInputTypes( - username = availableInputTypeCredentials.username, - password = availableInputTypeCredentials.password, + username = finalCredentials.username, + password = finalCredentials.password, email = emailAvailable, credentialsImport = importPromoAvailable, ) @@ -83,5 +98,15 @@ class RealAutofillAvailableInputTypesProvider @Inject constructor( AvailableInputTypeCredentials(username = usernameSearch != null, password = passwordSearch != null) } } + + private fun determineIfReauthenticationDetailsAvailable(reAuthenticationDetails: ReAuthenticationDetails): AvailableInputTypeCredentials { + val reauthPasswordAvailable = !reAuthenticationDetails.password.isNullOrEmpty() + + return AvailableInputTypeCredentials( + username = false, // Re-authentication only provides passwords + password = reauthPasswordAvailable, + ) + } + private fun determineIfEmailAvailable(): Boolean = emailManager.isSignedIn() } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt index 93ef78a5f8b5..8afd6c61d46e 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt @@ -20,6 +20,7 @@ import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextAvailabilityRules import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository +import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -30,6 +31,7 @@ interface AutofillRuntimeConfigProvider { suspend fun getRuntimeConfiguration( rawJs: String, url: String?, + reAuthenticationDetails: ReAuthenticationDetails, ): String } @@ -46,6 +48,7 @@ class RealAutofillRuntimeConfigProvider @Inject constructor( override suspend fun getRuntimeConfiguration( rawJs: String, url: String?, + reAuthenticationDetails: ReAuthenticationDetails, ): String { logcat(VERBOSE) { "BrowserAutofill: getRuntimeConfiguration called" } @@ -63,7 +66,7 @@ class RealAutofillRuntimeConfigProvider @Inject constructor( ).also { logcat(VERBOSE) { "autofill-config: userPreferences for $url: \n$it" } } - val availableInputTypes = generateAvailableInputTypes(url) + val availableInputTypes = generateAvailableInputTypes(url, reAuthenticationDetails) return StringBuilder(rawJs).apply { replacePlaceholder(this, TAG_INJECT_CONTENT_SCOPE, contentScope) @@ -80,8 +83,8 @@ class RealAutofillRuntimeConfigProvider @Inject constructor( } } - private suspend fun generateAvailableInputTypes(url: String?): String { - val inputTypes = autofillAvailableInputTypesProvider.getTypes(url) + private suspend fun generateAvailableInputTypes(url: String?, reAuthenticationDetails: ReAuthenticationDetails): String { + val inputTypes = autofillAvailableInputTypesProvider.getTypes(url, reAuthenticationDetails) val json = runtimeConfigurationWriter.generateResponseGetAvailableInputTypes(inputTypes).also { logcat(VERBOSE) { "autofill-config: availableInputTypes for $url: \n$it" } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfigurator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfigurator.kt index e5a24679ee33..fbb08ea137a4 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfigurator.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfigurator.kt @@ -15,54 +15,12 @@ */ package com.duckduckgo.autofill.impl.configuration -import android.webkit.WebView -import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.BrowserAutofill.Configurator -import com.duckduckgo.common.utils.DefaultDispatcherProvider -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.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.VERBOSE -import logcat.logcat @ContributesBinding(AppScope::class) class InlineBrowserAutofillConfigurator @Inject constructor( - private val autofillRuntimeConfigProvider: AutofillRuntimeConfigProvider, - @AppCoroutineScope private val coroutineScope: CoroutineScope, - private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), - private val autofillCapabilityChecker: AutofillCapabilityChecker, - private val autofillJavascriptLoader: AutofillJavascriptLoader, -) : Configurator { - override fun configureAutofillForCurrentPage( - webView: WebView, - url: String?, - ) { - coroutineScope.launch(dispatchers.io()) { - if (canJsBeInjected(url)) { - logcat(VERBOSE) { "Injecting autofill JS into WebView for $url" } - - val rawJs = autofillJavascriptLoader.getAutofillJavascript() - val formatted = autofillRuntimeConfigProvider.getRuntimeConfiguration(rawJs, url) - - withContext(dispatchers.main()) { - webView.evaluateJavascript("javascript:$formatted", null) - } - } else { - logcat(VERBOSE) { "Won't inject autofill JS into WebView for: $url" } - } - } - } - - private suspend fun canJsBeInjected(url: String?): Boolean { - url?.let { - // note, we don't check for autofillEnabledByUser here, as the user-facing preference doesn't cover email - return autofillCapabilityChecker.isAutofillEnabledByConfiguration(it) - } - return false - } -} + private val configurator: InternalBrowserAutofillConfigurator, +) : Configurator by configurator diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/InternalBrowserAutofillConfigurator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/InternalBrowserAutofillConfigurator.kt new file mode 100644 index 000000000000..18787a16b188 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/InternalBrowserAutofillConfigurator.kt @@ -0,0 +1,37 @@ +/* + * 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 android.webkit.WebView +import com.duckduckgo.autofill.api.BrowserAutofill.Configurator +import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails + +interface InternalBrowserAutofillConfigurator : Configurator { + /** + * Configures autofill for the current webpage with optional automatic re-authentication support. + * This should be called once per page load (e.g., onPageStarted()) + * + * @param webView The WebView to configure + * @param url The URL of the current page + * @param reauthenticationDetails Whether to enable automatic re-authentication for this page + */ + fun configureAutofillForCurrentPage( + webView: WebView, + url: String?, + reauthenticationDetails: ReAuthenticationDetails, + ) +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RealInlineBrowserAutofillConfigurator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RealInlineBrowserAutofillConfigurator.kt new file mode 100644 index 000000000000..ad35016f1ad2 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/RealInlineBrowserAutofillConfigurator.kt @@ -0,0 +1,74 @@ +/* + * 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 android.webkit.WebView +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.AutofillCapabilityChecker +import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails +import com.duckduckgo.common.utils.DefaultDispatcherProvider +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.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.VERBOSE +import logcat.logcat + +@ContributesBinding(AppScope::class) +class RealInlineBrowserAutofillConfigurator @Inject constructor( + private val autofillRuntimeConfigProvider: AutofillRuntimeConfigProvider, + @AppCoroutineScope private val coroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), + private val autofillCapabilityChecker: AutofillCapabilityChecker, + private val autofillJavascriptLoader: AutofillJavascriptLoader, +) : InternalBrowserAutofillConfigurator { + override fun configureAutofillForCurrentPage( + webView: WebView, + url: String?, + ) { + configureAutofillForCurrentPage(webView, url, ReAuthenticationDetails()) + } + + override fun configureAutofillForCurrentPage( + webView: WebView, + url: String?, + reauthenticationDetails: ReAuthenticationDetails, + ) { + coroutineScope.launch(dispatchers.io()) { + if (canJsBeInjected(url)) { + val rawJs = autofillJavascriptLoader.getAutofillJavascript() + val formatted = autofillRuntimeConfigProvider.getRuntimeConfiguration(rawJs, url, reauthenticationDetails) + + withContext(dispatchers.main()) { + webView.evaluateJavascript("javascript:$formatted", null) + } + } else { + logcat(VERBOSE) { "Won't inject autofill JS into WebView for: $url" } + } + } + } + + private suspend fun canJsBeInjected(url: String?): Boolean { + url?.let { + // note, we don't check for autofillEnabledByUser here, as the user-facing preference doesn't cover email + return autofillCapabilityChecker.isAutofillEnabledByConfiguration(it) + } + return false + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt index 60956ec71787..bb6aab92e552 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordSettings.kt @@ -80,6 +80,7 @@ class AutofillImportPasswordConfigStoreImpl @Inject constructor( // order is important; first match wins so keep the most specific to start of the list internal val URL_MAPPINGS_DEFAULT = listOf( + UrlMapping(key = "webflow-signin-rejected", url = "https://accounts.google.com/v3/signin/rejected"), UrlMapping(key = "webflow-passphrase-encryption", url = "https://passwords.google.com/error/sync-passphrase"), UrlMapping(key = "webflow-pre-login", url = "https://passwords.google.com/intro"), UrlMapping(key = "webflow-export", url = "https://passwords.google.com/options?ep=1"), diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt index 6bee49171736..465dd3de4db9 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt @@ -33,19 +33,24 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.webkit.WebViewCompat import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin import com.duckduckgo.autofill.api.BrowserAutofill import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType.AUTOPROMPT +import com.duckduckgo.autofill.impl.InternalCallback import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.configuration.InternalBrowserAutofillConfigurator import com.duckduckgo.autofill.impl.databinding.FragmentImportGooglePasswordsWebflowBinding import com.duckduckgo.autofill.impl.importing.blob.GooglePasswordBlobConsumer import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Companion.RESULT_KEY_DETAILS +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.InjectCredentialsFromReauth +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.NoCredentialsAvailable +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.PromptUserToSelectFromStoredCredentials import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.Initializing import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.LoadStartPage @@ -55,10 +60,12 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsW import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedImportFlow import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.WebContentShowing import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowWebViewClient.NewPageCallback -import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillCallback import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillEventListener import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionInContextSignupFlowListener import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionUserPromptListener +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType +import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.PASSWORD +import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.FragmentViewModelFactory @@ -77,7 +84,7 @@ import logcat.logcat class ImportGooglePasswordsWebFlowFragment : DuckDuckGoFragment(R.layout.fragment_import_google_passwords_webflow), NewPageCallback, - NoOpAutofillCallback, + InternalCallback, NoOpEmailProtectionInContextSignupFlowListener, NoOpEmailProtectionUserPromptListener, NoOpAutofillEventListener, @@ -89,9 +96,6 @@ class ImportGooglePasswordsWebFlowFragment : @Inject lateinit var dispatchers: DispatcherProvider - @Inject - lateinit var pixel: Pixel - @Inject lateinit var viewModelFactory: FragmentViewModelFactory @@ -114,7 +118,7 @@ class ImportGooglePasswordsWebFlowFragment : lateinit var passwordImporterScriptLoader: PasswordImporterScriptLoader @Inject - lateinit var browserAutofillConfigurator: BrowserAutofill.Configurator + lateinit var browserAutofillConfigurator: InternalBrowserAutofillConfigurator @Inject lateinit var importPasswordConfig: AutofillImportPasswordConfigStore @@ -143,6 +147,7 @@ class ImportGooglePasswordsWebFlowFragment : configureWebView() configureBackButtonHandler() observeViewState() + observeCommands() viewModel.onViewCreated() } @@ -178,6 +183,52 @@ class ImportGooglePasswordsWebFlowFragment : .launchIn(lifecycleScope) } + private fun observeCommands() { + viewModel.commands + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { command -> + logcat { "Received command: ${command::class.simpleName}" } + when (command) { + is InjectCredentialsFromReauth -> { + injectReauthenticationCredentials(url = command.url, username = command.username, password = command.password) + } + is NoCredentialsAvailable -> { + // Inject null to indicate no credentials available + browserAutofill.injectCredentials(null) + } + + is PromptUserToSelectFromStoredCredentials -> { + showCredentialChooserDialog(command.originalUrl, command.credentials, command.triggerType) + } + } + } + .launchIn(lifecycleScope) + } + + private suspend fun injectReauthenticationCredentials( + url: String?, + username: String?, + password: String?, + ) { + withContext(dispatchers.main()) { + binding?.webView?.let { + if (it.url != url) { + logcat(WARN) { "WebView url has changed since autofill request; bailing" } + return@withContext + } + + val credentials = LoginCredentials( + domain = url, + username = username, + password = password, + ) + + logcat { "Injecting re-authentication credentials" } + browserAutofill.injectCredentials(credentials) + } + } + } + private fun exitFlowAsCancellation(stage: String) { (activity as ImportGooglePasswordsWebFlowActivity).exitUserCancelled(stage) } @@ -300,12 +351,15 @@ class ImportGooglePasswordsWebFlowFragment : private fun getToolbar() = (activity as ImportGooglePasswordsWebFlowActivity).binding.includeToolbar.toolbar override fun onPageStarted(url: String?) { - binding?.let { - browserAutofillConfigurator.configureAutofillForCurrentPage(it.webView, url) + lifecycleScope.launch(dispatchers.main()) { + binding?.let { + val reauthDetails = url?.let { viewModel.getReauthData(url) } ?: ReAuthenticationDetails() + browserAutofillConfigurator.configureAutofillForCurrentPage(it.webView, url, reauthDetails) + } } } - override suspend fun onCredentialsAvailableToInject( + private suspend fun showCredentialChooserDialog( originalUrl: String, credentials: List, triggerType: LoginTriggerType, @@ -327,8 +381,31 @@ class ImportGooglePasswordsWebFlowFragment : } } + override suspend fun onCredentialsAvailableToInject( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + ) { + viewModel.onStoredCredentialsAvailable(originalUrl, credentials, triggerType, scenarioAllowsReAuthentication = false) + } + + override suspend fun onCredentialsAvailableToInjectWithReauth( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + requestSubType: SupportedAutofillInputSubType, + ) { + val reauthAllowed = requestSubType == PASSWORD && triggerType == AUTOPROMPT + viewModel.onStoredCredentialsAvailable(originalUrl, credentials, triggerType, reauthAllowed) + } + + override fun noCredentialsAvailable(originalUrl: String) { + viewModel.onNoStoredCredentialsAvailable(originalUrl) + } + override suspend fun promptUserToImportPassword(originalUrl: String) { - // no-op, we don't prompt the user for anything in this flow + logcat { "Autofill-import: we don't prompt the user to import in this flow" } + viewModel.onNoStoredCredentialsAvailable(originalUrl) } override suspend fun onCsvAvailable(csv: String) { @@ -339,6 +416,13 @@ class ImportGooglePasswordsWebFlowFragment : viewModel.onCsvError() } + override suspend fun onCredentialsAvailableToSave( + currentUrl: String, + credentials: LoginCredentials, + ) { + viewModel.onCredentialsAvailableToSave(currentUrl, credentials) + } + override fun onShareCredentialsForAutofill( originalUrl: String, selectedCredentials: LoginCredentials, @@ -347,7 +431,9 @@ class ImportGooglePasswordsWebFlowFragment : logcat(WARN) { "WebView url has changed since autofill request; bailing" } return } + browserAutofill.injectCredentials(selectedCredentials) + viewModel.onCredentialsAutofilled(originalUrl, selectedCredentials.password) } override fun onNoCredentialsChosenForAutofill(originalUrl: String) { @@ -358,6 +444,18 @@ class ImportGooglePasswordsWebFlowFragment : browserAutofill.injectCredentials(null) } + override suspend fun onGeneratedPasswordAvailableToUse( + originalUrl: String, + username: String?, + generatedPassword: String, + ) { + // no-op, password generation not used in this flow + } + + override fun onCredentialsSaved(savedCredentials: LoginCredentials) { + // no-op, credentials are handled by the ViewModel + } + companion object { private const val CUSTOM_FLOW_TAB_ID = "import-passwords-webflow" private const val SELECT_CREDENTIALS_FRAGMENT_TAG = "autofillSelectCredentialsDialog" diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt index d0426d7ee105..46dadf2bff8b 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModel.kt @@ -20,19 +20,31 @@ import android.os.Parcelable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.autofill.api.AutofillFeature +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.impl.importing.CredentialImporter import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.InjectCredentialsFromReauth +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.NoCredentialsAvailable +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.PromptUserToSelectFromStoredCredentials import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason.ErrorParsingCsv import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.Initializing import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow +import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails +import com.duckduckgo.autofill.impl.store.ReauthenticationHandler import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.FragmentScope import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize import logcat.LogPriority.WARN import logcat.logcat @@ -44,11 +56,16 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( private val csvCredentialConverter: CsvCredentialConverter, private val autofillImportConfigStore: AutofillImportPasswordConfigStore, private val urlToStageMapper: ImportGooglePasswordUrlToStageMapper, + private val reauthenticationHandler: ReauthenticationHandler, + private val autofillFeature: AutofillFeature, ) : ViewModel() { private val _viewState = MutableStateFlow(Initializing) val viewState: StateFlow = _viewState + private val _commands = MutableSharedFlow() + val commands: SharedFlow = _commands.asSharedFlow() + fun onViewCreated() { viewModelScope.launch(dispatchers.io()) { _viewState.value = ViewState.LoadStartPage(autofillImportConfigStore.getConfig().launchUrlGooglePasswords) @@ -99,6 +116,104 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( _viewState.value = ViewState.WebContentShowing } + fun onCredentialsAvailableToSave( + currentUrl: String, + credentials: LoginCredentials, + ) { + storeReauthenticationDetails(currentUrl, credentials.password) + } + + fun onCredentialsAutofilled(url: String, password: String?) { + storeReauthenticationDetails(url, password) + } + + private fun storeReauthenticationDetails( + currentUrl: String, + password: String?, + ) { + viewModelScope.launch { + if (canReAuthenticate().not()) { + logcat { "Re-authentication feature unavailable, not storing credentials for re-authentication" } + return@launch + } + reauthenticationHandler.storeForReauthentication(currentUrl, password) + + logcat { + "Storing credentials for re-authentication: " + + "password[${if (password.isNullOrBlank()) "blank" else "provided"}]" + } + } + } + + fun onStoredCredentialsAvailable( + originalUrl: String, + credentials: List, + triggerType: LoginTriggerType, + scenarioAllowsReAuthentication: Boolean, + ) { + viewModelScope.launch { + logcat { "onStoredCredentialsAvailable. re-AuthAllowed=$scenarioAllowsReAuthentication, triggerType=$triggerType" } + + val reauthData = if (scenarioAllowsReAuthentication) getReauthData(originalUrl) else null + if (reauthData?.password != null) { + logcat { "Stored credentials available but using re-authentication details instead: $reauthData" } + _commands.emit( + InjectCredentialsFromReauth( + url = originalUrl, + password = reauthData.password, + ), + ) + } else { + logcat { "No re-auth data available or permitted, prompting user to select stored credentials" } + _commands.emit( + PromptUserToSelectFromStoredCredentials( + originalUrl = originalUrl, + credentials = credentials, + triggerType = triggerType, + ), + ) + } + } + } + + suspend fun getReauthData(originalUrl: String): ReAuthenticationDetails? { + return withContext(dispatchers.io()) { + if (canReAuthenticate()) { + reauthenticationHandler.retrieveReauthData(originalUrl) + } else { + null + } + } + } + + fun onNoStoredCredentialsAvailable(originalUrl: String) { + viewModelScope.launch { + val reauthData = getReauthData(originalUrl) + logcat { "No stored credentials available; checking re-authentication details: $reauthData" } + + if (reauthData?.password != null) { + _commands.emit( + InjectCredentialsFromReauth( + url = originalUrl, + password = reauthData.password, + ), + ) + } else { + _commands.emit(NoCredentialsAvailable) + } + } + } + + override fun onCleared() { + reauthenticationHandler.clearAll() + } + + private suspend fun canReAuthenticate(): Boolean { + return withContext(dispatchers.io()) { + autofillFeature.canReAuthenticateGoogleLoginsAutomatically().isEnabled() + } + } + sealed interface ViewState { data object Initializing : ViewState data object WebContentShowing : ViewState @@ -109,6 +224,16 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor( data object NavigatingBack : ViewState } + sealed interface Command { + data class InjectCredentialsFromReauth(val url: String? = null, val username: String = "", val password: String?) : Command + data class PromptUserToSelectFromStoredCredentials( + val originalUrl: String, + val credentials: List, + val triggerType: LoginTriggerType, + ) : Command + data object NoCredentialsAvailable : Command + } + sealed interface UserCannotImportReason : Parcelable { @Parcelize data object ErrorParsingCsv : UserCannotImportReason diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/ReAuthenticationDetails.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/ReAuthenticationDetails.kt new file mode 100644 index 000000000000..960253bc220a --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/ReAuthenticationDetails.kt @@ -0,0 +1,27 @@ +/* + * 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.store + +data class ReAuthenticationDetails( + val password: String? = null, +) { + override fun toString(): String { + return "ReAuthenticationDetails(password available: ${password != null})" + } +} + +fun emptyReAuthenticationDetails() = ReAuthenticationDetails() diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/ReauthenticationHandler.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/ReauthenticationHandler.kt new file mode 100644 index 000000000000..5ec1aed47c60 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/ReauthenticationHandler.kt @@ -0,0 +1,71 @@ +/* + * 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.store + +import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import logcat.LogPriority.WARN +import logcat.logcat + +interface ReauthenticationHandler { + fun storeForReauthentication(url: String, password: String? = null) + + fun retrieveReauthData(url: String): ReAuthenticationDetails + + fun clearAll() +} + +@ContributesBinding(AppScope::class) +class InMemoryReauthenticationHandler @Inject constructor(private val urlMatcher: AutofillUrlMatcher) : ReauthenticationHandler { + + private val reauthDataByEtldPlus1 = ConcurrentHashMap() + + override fun storeForReauthentication( + url: String, + password: String?, + ) { + val eTldPlus1 = urlMatcher.extractUrlPartsForAutofill(url).eTldPlus1 ?: return + if (eTldPlus1 != PERMITTED_E_TLD_PLUS_1) { + logcat(WARN) { "Ignoring request to store re-auth password for $eTldPlus1" } + return + } + + reauthDataByEtldPlus1[eTldPlus1] = ReAuthenticationDetails(password = password).also { + logcat { "Stored re-auth password for $eTldPlus1" } + } + } + + override fun retrieveReauthData(url: String): ReAuthenticationDetails { + val urlParts = urlMatcher.extractUrlPartsForAutofill(url) + val eTldPlus1 = urlParts.eTldPlus1 ?: return noAuthenticationDetails + + return reauthDataByEtldPlus1[eTldPlus1] ?: noAuthenticationDetails + } + + override fun clearAll() { + reauthDataByEtldPlus1.clear() + logcat { "Cleared all re-authentication data" } + } + + companion object { + private val noAuthenticationDetails = ReAuthenticationDetails(password = null) + private const val PERMITTED_E_TLD_PLUS_1 = "google.com" + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfiguratorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfiguratorTest.kt index 9ac08289d8e8..c697a78af476 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfiguratorTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/InlineBrowserAutofillConfiguratorTest.kt @@ -44,15 +44,16 @@ class InlineBrowserAutofillConfiguratorTest { @Before fun before() = runTest { whenever(autofillJavascriptLoader.getAutofillJavascript()).thenReturn("") - whenever(autofillRuntimeConfigProvider.getRuntimeConfiguration(any(), any())).thenReturn("") + whenever(autofillRuntimeConfigProvider.getRuntimeConfiguration(any(), any(), any())).thenReturn("") - inlineBrowserAutofillConfigurator = InlineBrowserAutofillConfigurator( + val internalConfigurator = RealInlineBrowserAutofillConfigurator( autofillRuntimeConfigProvider, TestScope(), coroutineRule.testDispatcherProvider, autofillCapabilityChecker, autofillJavascriptLoader, ) + inlineBrowserAutofillConfigurator = InlineBrowserAutofillConfigurator(internalConfigurator) } @Test diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillAvailableInputTypesProviderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillAvailableInputTypesProviderTest.kt index ce4c419c0de1..45a4bcd3169e 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillAvailableInputTypesProviderTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillAvailableInputTypesProviderTest.kt @@ -23,6 +23,7 @@ import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.importing.InBrowserImportPromo import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse @@ -246,6 +247,56 @@ class RealAutofillAvailableInputTypesProviderTest { assertFalse(result.password) } + @Test + fun whenNoSavedPasswordButReAuthPasswordAvailableThenPasswordIsTrue() = runTest { + configureAutofillCapabilities(enabled = true) + whenever(autofillStore.getCredentials(EXAMPLE_URL)).thenReturn(emptyList()) + whenever(shareableCredentials.shareableCredentials(EXAMPLE_URL)).thenReturn(emptyList()) + + val result = testee.getTypes(EXAMPLE_URL, reAuthenticationDetails = ReAuthenticationDetails(password = "password")) + + assertFalse(result.username) + assertTrue(result.password) + } + + @Test + fun whenSavedPasswordAndReAuthPasswordBothAvailableThenPasswordIsTrue() = runTest { + configureAutofillCapabilities(enabled = true) + whenever(autofillStore.getCredentials(EXAMPLE_URL)).thenReturn( + listOf( + LoginCredentials( + id = 1, + domain = "example.com", + username = "username", + password = "password", + ), + ), + ) + whenever(shareableCredentials.shareableCredentials(EXAMPLE_URL)).thenReturn(emptyList()) + + val result = testee.getTypes(EXAMPLE_URL, reAuthenticationDetails = ReAuthenticationDetails(password = "password")) + assertTrue(result.password) + } + + @Test + fun whenSharablePasswordAndReAuthPasswordBothAvailableThenPasswordIsTrue() = runTest { + configureAutofillCapabilities(enabled = true) + whenever(autofillStore.getCredentials(EXAMPLE_URL)).thenReturn(emptyList()) + whenever(shareableCredentials.shareableCredentials(EXAMPLE_URL)).thenReturn( + listOf( + LoginCredentials( + id = 1, + domain = "example.com", + username = "username", + password = "password", + ), + ), + ) + + val result = testee.getTypes(EXAMPLE_URL, reAuthenticationDetails = ReAuthenticationDetails(password = "password")) + assertTrue(result.password) + } + @Test fun whenEmailIsSignedInThenEmailIsTrue() = runTest { configureAutofillCapabilities(enabled = true) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt index 89ce0ad63e59..c150728e5de9 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt @@ -23,6 +23,7 @@ import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.impl.configuration.AutofillAvailableInputTypesProvider.AvailableInputTypes import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextAvailabilityRules import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository +import com.duckduckgo.autofill.impl.store.emptyReAuthenticationDetails import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State import kotlinx.coroutines.test.runTest @@ -65,7 +66,7 @@ class RealAutofillRuntimeConfigProviderTest { runTest { whenever(neverSavedSiteRepository.isInNeverSaveList(any())).thenReturn(false) - whenever(availableInputTypesProvider.getTypes(any())).thenReturn( + whenever(availableInputTypesProvider.getTypes(any(), any())).thenReturn( AvailableInputTypes( username = false, password = false, @@ -103,14 +104,14 @@ class RealAutofillRuntimeConfigProviderTest { @Test fun whenAutofillNotEnabledThenConfigurationUserPrefsCredentialsIsFalse() = runTest { configureAutofillCapabilities(enabled = false) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration("", EXAMPLE_URL, emptyReAuthenticationDetails()) verifyAutofillCredentialsReturnedAs(false) } @Test fun whenAutofillEnabledThenConfigurationUserPrefsCredentialsIsTrue() = runTest { configureAutofillCapabilities(enabled = true) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration("", EXAMPLE_URL, emptyReAuthenticationDetails()) verifyAutofillCredentialsReturnedAs(true) } @@ -125,7 +126,7 @@ class RealAutofillRuntimeConfigProviderTest { credentialsImport = false, ), ) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration("", EXAMPLE_URL, emptyReAuthenticationDetails()) verifyKeyIconRequestedToShow() } @@ -133,7 +134,7 @@ class RealAutofillRuntimeConfigProviderTest { fun whenCanCategorizePasswordVariantEnabledThenConfigurationUserPrefsReflectsThat() = runTest { configureAutofillCapabilities(enabled = false) autofillFeature.passwordVariantCategorization().setRawStoredState(State(enable = true)) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration("", EXAMPLE_URL, emptyReAuthenticationDetails()) verifyCanCategorizePasswordVariant(true) } @@ -141,7 +142,7 @@ class RealAutofillRuntimeConfigProviderTest { fun whenCanCategorizePasswordVariantDisabledThenConfigurationUserPrefsReflectsThat() = runTest { configureAutofillCapabilities(enabled = false) autofillFeature.passwordVariantCategorization().setRawStoredState(State(enable = false)) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration("", EXAMPLE_URL, emptyReAuthenticationDetails()) verifyCanCategorizePasswordVariant(false) } @@ -156,7 +157,7 @@ class RealAutofillRuntimeConfigProviderTest { ) whenever(availableInputTypesProvider.getTypes(EXAMPLE_URL)).thenReturn(expectedInputTypes) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration("", EXAMPLE_URL, emptyReAuthenticationDetails()) verify(availableInputTypesProvider).getTypes(EXAMPLE_URL) verify(runtimeConfigurationWriter).generateResponseGetAvailableInputTypes(eq(expectedInputTypes)) @@ -165,7 +166,7 @@ class RealAutofillRuntimeConfigProviderTest { @Test fun whenSiteNotInNeverSaveListThenCanSaveCredentials() = runTest { configureAutofillCapabilities(enabled = true) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration("", EXAMPLE_URL, emptyReAuthenticationDetails()) verifyCanSaveCredentialsReturnedAs(true) } @@ -174,7 +175,7 @@ class RealAutofillRuntimeConfigProviderTest { configureAutofillCapabilities(enabled = true) whenever(neverSavedSiteRepository.isInNeverSaveList(EXAMPLE_URL)).thenReturn(true) - testee.getRuntimeConfiguration("", EXAMPLE_URL) + testee.getRuntimeConfiguration("", EXAMPLE_URL, emptyReAuthenticationDetails()) verifyCanSaveCredentialsReturnedAs(true) } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt index c387f9501cea..a23404b42c62 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/feature/AutofillImportPasswordConfigStoreImplTest.kt @@ -87,18 +87,19 @@ class AutofillImportPasswordConfigStoreImplTest { @Test fun whenUrlMappingsNotSpecifiedInConfigThenDefaultsUsed() = runTest { configureFeature(config = Config(urlMappings = null)) - assertEquals(5, testee.getConfig().urlMappings.size) + assertEquals(6, testee.getConfig().urlMappings.size) } @Test fun whenUrlMappingsNotSpecifiedInConfigThenCorrectOrderOfDefaultsReturned() = runTest { configureFeature(config = Config(urlMappings = null)) testee.getConfig().urlMappings.apply { - assertEquals("webflow-passphrase-encryption", get(0).key) - assertEquals("webflow-pre-login", get(1).key) - assertEquals("webflow-export", get(2).key) - assertEquals("webflow-authenticate", get(3).key) - assertEquals("webflow-post-login-landing", get(4).key) + assertEquals("webflow-signin-rejected", get(0).key) + assertEquals("webflow-passphrase-encryption", get(1).key) + assertEquals("webflow-pre-login", get(2).key) + assertEquals("webflow-export", get(3).key) + assertEquals("webflow-authenticate", get(4).key) + assertEquals("webflow-post-login-landing", get(5).key) } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt index a92bba2aa106..a21775de078a 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowViewModelTest.kt @@ -2,26 +2,38 @@ package com.duckduckgo.autofill.impl.importing.gpm.webflow import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test +import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType.AUTOPROMPT +import com.duckduckgo.autofill.api.domain.app.LoginTriggerType.USER_INITIATED import com.duckduckgo.autofill.impl.importing.CredentialImporter import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult.Error import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult.Success import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordSettings +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.InjectCredentialsFromReauth +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.NoCredentialsAvailable +import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.Command.PromptUserToSelectFromStoredCredentials import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.LoadStartPage import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.NavigatingBack import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedCannotImport import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserFinishedImportFlow +import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails +import com.duckduckgo.autofill.impl.store.ReauthenticationHandler import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.Toggle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) @@ -34,6 +46,8 @@ class ImportGooglePasswordsWebFlowViewModelTest { private val csvCredentialConverter: CsvCredentialConverter = mock() private val autofillImportConfigStore: AutofillImportPasswordConfigStore = mock() private val urlToStageMapper: ImportGooglePasswordUrlToStageMapper = mock() + private val reauthenticationHandler: ReauthenticationHandler = mock() + private val autofillFeature: AutofillFeature = mock() private val testee = ImportGooglePasswordsWebFlowViewModel( dispatchers = coroutineTestRule.testDispatcherProvider, @@ -41,6 +55,8 @@ class ImportGooglePasswordsWebFlowViewModelTest { csvCredentialConverter = csvCredentialConverter, autofillImportConfigStore = autofillImportConfigStore, urlToStageMapper = urlToStageMapper, + reauthenticationHandler = reauthenticationHandler, + autofillFeature = autofillFeature, ) @Test @@ -103,6 +119,222 @@ class ImportGooglePasswordsWebFlowViewModelTest { } } + @Test + fun whenStoredCredentialsAvailableWithReauthAllowedAndPasswordThenInjectCredentialsFromReauth() = runTest { + val url = "https://example.com" + val password = "test-password" + val credentials = listOf(creds()) + configureReAuthenticationFeatureFlagEnabled() + whenever(reauthenticationHandler.retrieveReauthData(url)).thenReturn(ReAuthenticationDetails(password = password)) + + testee.commands.test { + testee.onStoredCredentialsAvailable(url, credentials, USER_INITIATED, scenarioAllowsReAuthentication = true) + assertTrue(awaitItem() is InjectCredentialsFromReauth) + } + } + + @Test + fun whenStoredCredentialsAvailableWithReauthAllowedButNoPasswordThenPromptUser() = runTest { + val url = "https://example.com" + val credentials = listOf(creds()) + configureReAuthenticationFeatureFlagEnabled() + whenever(reauthenticationHandler.retrieveReauthData(url)).thenReturn(ReAuthenticationDetails(password = null)) + + testee.commands.test { + testee.onStoredCredentialsAvailable(url, credentials, USER_INITIATED, scenarioAllowsReAuthentication = true) + assertTrue(awaitItem() is PromptUserToSelectFromStoredCredentials) + } + } + + @Test + fun whenStoredCredentialsAvailableWithReauthNotAllowedThenPromptUser() = runTest { + val url = "https://example.com" + val credentials = listOf(creds()) + + testee.commands.test { + testee.onStoredCredentialsAvailable(url, credentials, AUTOPROMPT, scenarioAllowsReAuthentication = false) + assertTrue(awaitItem() is PromptUserToSelectFromStoredCredentials) + } + } + + @Test + fun whenReauthFeatureFlagDisabledAndCredentialsAvailableThenPromptUser() = runTest { + val url = "https://example.com" + val credentials = listOf(creds()) + configureReAuthenticationFeatureFlagDisabled() + configureReauthData(url, "test-password") // Even with reauth data, should prompt user + + testee.commands.test { + testee.onStoredCredentialsAvailable(url, credentials, AUTOPROMPT, scenarioAllowsReAuthentication = true) + assertTrue(awaitItem() is PromptUserToSelectFromStoredCredentials) + } + } + + @Test + fun whenReauthFeatureFlagDisabledAndNoStoredCredentialsThenNoCredentialsCommand() = runTest { + val url = "https://example.com" + val password = "test-password" + configureReAuthenticationFeatureFlagDisabled() + configureReauthData(url, password) // Even with reauth data, should return NoCredentialsAvailable when flag disabled + + testee.commands.test { + testee.onNoStoredCredentialsAvailable(url) + assertTrue(awaitItem() is NoCredentialsAvailable) + } + } + + @Test + fun whenReauthFeatureFlagEnabledButReauthDataHasNullPasswordThenPromptUser() = runTest { + val url = "https://example.com" + val credentials = listOf(creds()) + configureReAuthenticationFeatureFlagEnabled() + configureReauthData(url, null) // No reauth data available + + testee.commands.test { + testee.onStoredCredentialsAvailable(url, credentials, USER_INITIATED, scenarioAllowsReAuthentication = true) + assertTrue(awaitItem() is PromptUserToSelectFromStoredCredentials) + } + } + + @Test + fun whenReauthFeatureFlagEnabledAndReauthDataValidThenInjectCredentials() = runTest { + val url = "https://example.com" + val password = "test-password" + val credentials = listOf(creds()) + configureReAuthenticationFeatureFlagEnabled() + configureReauthData(url, password) + + testee.commands.test { + testee.onStoredCredentialsAvailable(url, credentials, USER_INITIATED, scenarioAllowsReAuthentication = true) + val command = awaitItem() as InjectCredentialsFromReauth + assertEquals(url, command.url) + assertEquals(password, command.password) + } + } + + @Test + fun whenNoStoredCredentialsAndReauthFeatureFlagDisabledButNoReauthDataThenNoCredentialsCommand() = runTest { + val url = "https://example.com" + configureReAuthenticationFeatureFlagDisabled() + configureReauthData(url, null) // No reauth data available + + testee.commands.test { + testee.onNoStoredCredentialsAvailable(url) + assertTrue(awaitItem() is NoCredentialsAvailable) + } + } + + @Test + fun whenNoStoredCredentialsAndReauthFeatureFlagEnabledButNoReauthDataThenNoCredentialsCommand() = runTest { + val url = "https://example.com" + configureReAuthenticationFeatureFlagEnabled() + configureReauthData(url, null) + + testee.commands.test { + testee.onNoStoredCredentialsAvailable(url) + assertTrue(awaitItem() is NoCredentialsAvailable) + } + } + + @Test + fun whenNoStoredCredentialsAndReauthFeatureFlagEnabledWithReauthDataThenInjectCredentials() = runTest { + val url = "https://example.com" + val password = "test-password" + configureReAuthenticationFeatureFlagEnabled() + configureReauthData(url, password) + + testee.commands.test { + testee.onNoStoredCredentialsAvailable(url) + val command = awaitItem() as InjectCredentialsFromReauth + assertEquals(url, command.url) + assertEquals(password, command.password) + } + } + + @Test + fun whenAutofillCredentialsWithPasswordAndReauthFeatureFlagEnabledThenStoreReauthData() = runTest { + val url = "https://example.com" + val password = "test-password" + configureReAuthenticationFeatureFlagEnabled() + + testee.onCredentialsAutofilled(url, password) + + verify(reauthenticationHandler).storeForReauthentication(url, password) + } + + @Test + fun whenCredentialsAvailableToSaveWithPasswordAndReauthFeatureFlagEnabledThenStoreReauthData() = runTest { + val url = "https://example.com" + val password = "test-password" + val credentials = creds(password = password) + configureReAuthenticationFeatureFlagEnabled() + + testee.onCredentialsAvailableToSave(url, credentials) + + verify(reauthenticationHandler).storeForReauthentication(url, password) + } + + @Test + fun whenAutofillCredentialsWithPasswordAndReauthFeatureFlagDisabledThenDoNotStoreReauthData() = runTest { + val url = "https://example.com" + val password = "test-password" + configureReAuthenticationFeatureFlagDisabled() + + testee.onCredentialsAutofilled(url, password) + + verify(reauthenticationHandler, never()).storeForReauthentication(any(), any()) + } + + @Test + fun whenCredentialsAvailableToSaveWithPasswordAndReauthFeatureFlagDisabledThenDoNotStoreReauthData() = runTest { + val url = "https://example.com" + val password = "test-password" + val credentials = creds(password = password) + configureReAuthenticationFeatureFlagDisabled() + + testee.onCredentialsAvailableToSave(url, credentials) + + verify(reauthenticationHandler, never()).storeForReauthentication(any(), any()) + } + + @Test + fun whenGetReauthDataAndReauthFeatureFlagDisabledThenReturnNull() = runTest { + val url = "https://example.com" + configureReAuthenticationFeatureFlagDisabled() + + val result = testee.getReauthData(url) + + assertEquals(null, result) + } + + @Test + fun whenGetReauthDataAndReauthFeatureFlagEnabledThenReturnReauthData() = runTest { + val url = "https://example.com" + val expectedReauthData = ReAuthenticationDetails(password = "test-password") + configureReAuthenticationFeatureFlagEnabled() + whenever(reauthenticationHandler.retrieveReauthData(url)).thenReturn(expectedReauthData) + + val result = testee.getReauthData(url) + + assertEquals(expectedReauthData, result) + } + + private fun configureReAuthenticationFeatureFlagEnabled() { + val mockToggle: Toggle = mock() + whenever(mockToggle.isEnabled()).thenReturn(true) + whenever(autofillFeature.canReAuthenticateGoogleLoginsAutomatically()).thenReturn(mockToggle) + } + + private fun configureReAuthenticationFeatureFlagDisabled() { + val mockToggle: Toggle = mock() + whenever(mockToggle.isEnabled()).thenReturn(false) + whenever(autofillFeature.canReAuthenticateGoogleLoginsAutomatically()).thenReturn(mockToggle) + } + + private fun configureReauthData(url: String, password: String?) { + whenever(reauthenticationHandler.retrieveReauthData(url)).thenReturn(ReAuthenticationDetails(password = password)) + } + private suspend fun configureFeature( canImportFromGooglePasswords: Boolean = true, launchUrlGooglePasswords: String = "https://example.com", diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/store/InMemoryReauthenticationHandlerTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/store/InMemoryReauthenticationHandlerTest.kt new file mode 100644 index 000000000000..787c9f6cd451 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/store/InMemoryReauthenticationHandlerTest.kt @@ -0,0 +1,219 @@ +/* + * 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.store + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher +import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher.ExtractedUrlParts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class InMemoryReauthenticationHandlerTest { + + private lateinit var testee: InMemoryReauthenticationHandler + private val urlMatcher: AutofillUrlMatcher = mock() + + @Before + fun setUp() { + testee = InMemoryReauthenticationHandler(urlMatcher) + } + + @Test + fun whenStoreForReauthenticationWithValidUrlThenPasswordIsStored() { + val url = "https://accounts.google.com/signin" + val password = "testPassword123" + val eTldPlus1 = "google.com" + + whenever(urlMatcher.extractUrlPartsForAutofill(url)).thenReturn( + ExtractedUrlParts(eTldPlus1 = eTldPlus1, userFacingETldPlus1 = eTldPlus1, subdomain = null), + ) + + testee.storeForReauthentication(url, password) + + val result = testee.retrieveReauthData(url) + assertEquals(password, result.password) + } + + @Test + fun whenStoreForReauthenticationWithNullPasswordThenNullIsStored() { + val url = "https://accounts.google.com/signin" + val eTldPlus1 = "google.com" + + whenever(urlMatcher.extractUrlPartsForAutofill(url)).thenReturn( + ExtractedUrlParts(eTldPlus1 = eTldPlus1, userFacingETldPlus1 = eTldPlus1, subdomain = null), + ) + + testee.storeForReauthentication(url, null) + + val result = testee.retrieveReauthData(url) + assertNull(result.password) + } + + @Test + fun whenStoreForReauthenticationWithInvalidUrlThenNothingIsStored() { + val url = "invalid-url" + val password = "testPassword123" + + whenever(urlMatcher.extractUrlPartsForAutofill(url)).thenReturn( + ExtractedUrlParts(eTldPlus1 = null, userFacingETldPlus1 = null, subdomain = null), + ) + + testee.storeForReauthentication(url, password) + + val result = testee.retrieveReauthData(url) + assertNull(result.password) + } + + @Test + fun whenRetrieveReauthDataForUnknownUrlThenReturnsEmptyDetails() { + val url = "https://unknown.com/login" + val eTldPlus1 = "unknown.com" + + whenever(urlMatcher.extractUrlPartsForAutofill(url)).thenReturn( + ExtractedUrlParts(eTldPlus1 = eTldPlus1, userFacingETldPlus1 = eTldPlus1, subdomain = null), + ) + + val result = testee.retrieveReauthData(url) + + assertNull(result.password) + } + + @Test + fun whenRetrieveReauthDataWithInvalidUrlThenReturnsEmptyDetails() { + val url = "invalid-url" + + whenever(urlMatcher.extractUrlPartsForAutofill(url)).thenReturn( + ExtractedUrlParts(eTldPlus1 = null, userFacingETldPlus1 = null, subdomain = null), + ) + + val result = testee.retrieveReauthData(url) + + assertNull(result.password) + } + + @Test + fun whenMultipleUrlsWithSameETldPlus1ThenSamePasswordIsRetrieved() { + val url1 = "https://accounts.google.com/signin" + val url2 = "https://passwords.google.com/export" + val password = "testPassword123" + val eTldPlus1 = "google.com" + + whenever(urlMatcher.extractUrlPartsForAutofill(url1)).thenReturn( + ExtractedUrlParts(eTldPlus1 = eTldPlus1, userFacingETldPlus1 = eTldPlus1, subdomain = null), + ) + whenever(urlMatcher.extractUrlPartsForAutofill(url2)).thenReturn( + ExtractedUrlParts(eTldPlus1 = eTldPlus1, userFacingETldPlus1 = eTldPlus1, subdomain = null), + ) + + testee.storeForReauthentication(url1, password) + + val result = testee.retrieveReauthData(url2) + assertEquals(password, result.password) + } + + @Test + fun whenDifferentETldPlus1ThenOnlyGooglePasswordIsStored() { + val googleUrl = "https://accounts.google.com/signin" + val microsoftUrl = "https://login.microsoftonline.com/signin" + val googlePassword = "googlePassword123" + val microsoftPassword = "microsoftPassword456" + + whenever(urlMatcher.extractUrlPartsForAutofill(googleUrl)).thenReturn( + ExtractedUrlParts(eTldPlus1 = "google.com", userFacingETldPlus1 = "google.com", subdomain = null), + ) + whenever(urlMatcher.extractUrlPartsForAutofill(microsoftUrl)).thenReturn( + ExtractedUrlParts(eTldPlus1 = "microsoftonline.com", userFacingETldPlus1 = "microsoftonline.com", subdomain = null), + ) + + testee.storeForReauthentication(googleUrl, googlePassword) + testee.storeForReauthentication(microsoftUrl, microsoftPassword) + + val googleResult = testee.retrieveReauthData(googleUrl) + val microsoftResult = testee.retrieveReauthData(microsoftUrl) + + // Only Google password should be stored due to eTLD+1 guard + assertEquals(googlePassword, googleResult.password) + assertNull(microsoftResult.password) + } + + @Test + fun whenOverwritingExistingPasswordThenNewPasswordIsRetrieved() { + val url = "https://accounts.google.com/signin" + val oldPassword = "oldPassword123" + val newPassword = "newPassword456" + val eTldPlus1 = "google.com" + + whenever(urlMatcher.extractUrlPartsForAutofill(url)).thenReturn( + ExtractedUrlParts(eTldPlus1 = eTldPlus1, userFacingETldPlus1 = eTldPlus1, subdomain = null), + ) + + testee.storeForReauthentication(url, oldPassword) + testee.storeForReauthentication(url, newPassword) + + val result = testee.retrieveReauthData(url) + assertEquals(newPassword, result.password) + } + + @Test + fun whenClearAllThenAllStoredPasswordsAreRemoved() { + val googleUrl = "https://accounts.google.com/signin" + val microsoftUrl = "https://login.microsoftonline.com/signin" + val googlePassword = "googlePassword123" + val microsoftPassword = "microsoftPassword456" + + whenever(urlMatcher.extractUrlPartsForAutofill(googleUrl)).thenReturn( + ExtractedUrlParts(eTldPlus1 = "google.com", userFacingETldPlus1 = "google.com", subdomain = null), + ) + whenever(urlMatcher.extractUrlPartsForAutofill(microsoftUrl)).thenReturn( + ExtractedUrlParts(eTldPlus1 = "microsoftonline.com", userFacingETldPlus1 = "microsoftonline.com", subdomain = null), + ) + + testee.storeForReauthentication(googleUrl, googlePassword) + testee.storeForReauthentication(microsoftUrl, microsoftPassword) // This won't actually store due to eTLD+1 guard + + testee.clearAll() + + val googleResult = testee.retrieveReauthData(googleUrl) + val microsoftResult = testee.retrieveReauthData(microsoftUrl) + + // Both should return null - Google because it was cleared, Microsoft because it was never stored + assertNull(googleResult.password) + assertNull(microsoftResult.password) + } + + @Test + fun whenStoreForReauthenticationWithNonGoogleDomainThenPasswordIsNotStored() { + val exampleUrl = "https://example.com/login" + val password = "testPassword123" + val eTldPlus1 = "example.com" + + whenever(urlMatcher.extractUrlPartsForAutofill(exampleUrl)).thenReturn( + ExtractedUrlParts(eTldPlus1 = eTldPlus1, userFacingETldPlus1 = eTldPlus1, subdomain = null), + ) + + testee.storeForReauthentication(exampleUrl, password) + + val result = testee.retrieveReauthData(exampleUrl) + assertNull(result.password) + } +}