Skip to content

Commit

Permalink
Launch import flow from Password management screen (#5098)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/608920331025315/1208434183978073/f 

### Description
Adds UI which allows users to access the flow for importing passwords directly from Google Password Manager. 

<img src="https://github.com/user-attachments/assets/a99a29f2-5a34-4622-823f-5bb1e534d511" width="50%" />

![output](https://github.com/user-attachments/assets/0f8c6eb4-daea-4d93-96a5-400b63f736ef)

### Steps to test this PR

**Empty state**
- [ ] Fresh install
- [ ] Visit `Passwords` screen and verify you see `Import Passwords From Google` button in the empty state (if `WebView` version supports it)

**Pre-import dialog**
- [ ] Tap the button, and verify the bottom sheet looks good

**Success results dialog**
- [ ] Tap `Open Google Passwords` button and complete the flow
- [ ] Verify that you see the success dialog confirming how many passwords were imported. 

**Success results dialog (with duplicates detected)**
- [ ] [If you didn't see any duplicates in the last step] repeat previous step and verify duplicates are mentioned but not imported

**Failure dialog**
It's possible the flow couldn't be completed if the user enabled an old setting in GPM to encrypt passwords with a passphrase. Expecting this to be uncommon, but here's how to test the UI if this happens:

- [ ] Apply [Patch for testing: encrypted with passphrase](https://app.asana.com/0/1208775021175583/1208775021175583/f)
- [ ] Start the import flow, and you'll see the error page
- [ ] Tap the ✖️ button 
- [ ] Verify the results dialog shows an error state
  • Loading branch information
CDRussell authored Nov 21, 2024
1 parent c787203 commit 1a6c073
Show file tree
Hide file tree
Showing 28 changed files with 1,468 additions and 53 deletions.
1 change: 1 addition & 0 deletions autofill/autofill-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ dependencies {
implementation AndroidX.biometric

implementation "net.zetetic:android-database-sqlcipher:_"
implementation "com.facebook.shimmer:shimmer:_"

// Testing dependencies
testImplementation project(':common-test')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ interface AutofillAuthorizationGracePeriod {
*/
fun recordSuccessfulAuthorization()

/**
* Requests an extended grace period. This may extend the grace period to a longer duration.
*/
fun requestExtendedGracePeriod()

/**
* Removes the request for an extended grace period
*/
fun removeRequestForExtendedGracePeriod()

/**
* Invalidates the grace period, so that the next call to [isAuthRequired] will return true
*/
Expand All @@ -53,12 +63,21 @@ class AutofillTimeBasedAuthorizationGracePeriod @Inject constructor(
) : AutofillAuthorizationGracePeriod {

private var lastSuccessfulAuthTime: Long? = null
private var extendedGraceTimeRequested: Long? = null

override fun recordSuccessfulAuthorization() {
lastSuccessfulAuthTime = timeProvider.currentTimeMillis()
Timber.v("Recording timestamp of successful auth")
}

override fun requestExtendedGracePeriod() {
extendedGraceTimeRequested = timeProvider.currentTimeMillis()
}

override fun removeRequestForExtendedGracePeriod() {
extendedGraceTimeRequested = null
}

override fun isAuthRequired(): Boolean {
lastSuccessfulAuthTime?.let { lastAuthTime ->
val timeSinceLastAuth = timeProvider.currentTimeMillis() - lastAuthTime
Expand All @@ -68,16 +87,33 @@ class AutofillTimeBasedAuthorizationGracePeriod @Inject constructor(
return false
}
}
Timber.v("No last auth time recorded or outside grace period; auth required")

if (inExtendedGracePeriod()) {
Timber.v("Within extended grace period; auth not required")
return false
}

Timber.v("No last auth time recorded or outside grace period; auth required")
return true
}

private fun inExtendedGracePeriod(): Boolean {
val extendedRequest = extendedGraceTimeRequested
if (extendedRequest == null) {
return false
} else {
val timeSinceExtendedGrace = timeProvider.currentTimeMillis() - extendedRequest
return timeSinceExtendedGrace <= AUTH_GRACE_EXTENDED_PERIOD_MS
}
}

override fun invalidate() {
lastSuccessfulAuthTime = null
removeRequestForExtendedGracePeriod()
}

companion object {
private const val AUTH_GRACE_PERIOD_MS = 15_000
private const val AUTH_GRACE_EXTENDED_PERIOD_MS = 180_000
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.duckduckgo.autofill.impl.importing.gpm.webflow

import android.os.Parcelable
import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.UserCannotImportReason
import kotlinx.parcelize.Parcelize

sealed interface ImportGooglePasswordResult : Parcelable {
Expand All @@ -28,7 +29,7 @@ sealed interface ImportGooglePasswordResult : Parcelable {
data class UserCancelled(val stage: String) : ImportGooglePasswordResult

@Parcelize
data object Error : ImportGooglePasswordResult
data class Error(val reason: UserCannotImportReason) : ImportGooglePasswordResult

companion object {
const val RESULT_KEY = "importResult"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ import com.duckduckgo.autofill.impl.databinding.FragmentImportGooglePasswordsWeb
import com.duckduckgo.autofill.impl.importing.blob.GooglePasswordBlobConsumer
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.ViewState.*
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
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.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
Expand Down Expand Up @@ -155,7 +162,7 @@ class ImportGooglePasswordsWebFlowFragment :
when (viewState) {
is UserFinishedImportFlow -> exitFlowAsSuccess()
is UserCancelledImportFlow -> exitFlowAsCancellation(viewState.stage)
is UserFinishedCannotImport -> exitFlowAsImpossibleToImport()
is UserFinishedCannotImport -> exitFlowAsImpossibleToImport(viewState.reason)
is NavigatingBack -> binding?.webView?.goBack()
is LoadStartPage -> loadFirstWebpage(viewState.initialLaunchUrl)
is WebContentShowing, Initializing -> {
Expand All @@ -177,9 +184,9 @@ class ImportGooglePasswordsWebFlowFragment :
setFragmentResult(RESULT_KEY, resultBundle)
}

private fun exitFlowAsImpossibleToImport() {
private fun exitFlowAsImpossibleToImport(reason: UserCannotImportReason) {
val resultBundle = Bundle().also {
it.putParcelable(RESULT_KEY_DETAILS, ImportGooglePasswordResult.Error)
it.putParcelable(RESULT_KEY_DETAILS, ImportGooglePasswordResult.Error(reason))
}
setFragmentResult(RESULT_KEY, resultBundle)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,24 @@

package com.duckduckgo.autofill.impl.importing.gpm.webflow

import android.os.Parcelable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.duckduckgo.anvil.annotations.ContributesViewModel
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.UserCannotImportReason.EncryptedPassphrase
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.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ActivityScope
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber

@ContributesViewModel(ActivityScope::class)
Expand Down Expand Up @@ -63,12 +67,12 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor(

fun onCsvError() {
Timber.w("Error decoding CSV")
_viewState.value = ViewState.UserFinishedCannotImport
_viewState.value = ViewState.UserFinishedCannotImport(ErrorParsingCsv)
}

fun onCloseButtonPressed(url: String?) {
if (url?.startsWith(ENCRYPTED_PASSPHRASE_ERROR_URL) == true) {
_viewState.value = ViewState.UserFinishedCannotImport
_viewState.value = ViewState.UserFinishedCannotImport(EncryptedPassphrase)
} else {
terminateFlowAsCancellation(url ?: "unknown")
}
Expand Down Expand Up @@ -101,10 +105,18 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor(
data class LoadStartPage(val initialLaunchUrl: String) : ViewState
data class UserCancelledImportFlow(val stage: String) : ViewState
data object UserFinishedImportFlow : ViewState
data object UserFinishedCannotImport : ViewState
data class UserFinishedCannotImport(val reason: UserCannotImportReason) : ViewState
data object NavigatingBack : ViewState
}

sealed interface UserCannotImportReason : Parcelable {
@Parcelize
data object ErrorParsingCsv : UserCannotImportReason

@Parcelize
data object EncryptedPassphrase : UserCannotImportReason
}

sealed interface BackButtonAction {
data object NavigateBack : BackButtonAction
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT_ONBOARDED_USER
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENGAGEMENT_STACKED_LOGINS
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_COPIED_DESKTOP_LINK
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_GET_DESKTOP_BROWSER
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_SHARED_DESKTOP_LINK
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_SYNC_WITH_DESKTOP
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_PASSWORDS_USER_JOURNEY_RESTARTED
Expand All @@ -43,6 +41,8 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAK
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_CONFIRMED
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_DISMISSED
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SITE_BREAKAGE_REPORT_CONFIRMATION_DISPLAYED
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_TOOLTIP_DISMISSED
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ADDRESS
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ALIAS
Expand Down Expand Up @@ -142,8 +142,8 @@ enum class AutofillPixelNames(override val pixelName: String) : Pixel.PixelName
AUTOFILL_TOGGLED_ON_SEARCH("m_autofill_toggled_on"),
AUTOFILL_TOGGLED_OFF_SEARCH("m_autofill_toggled_off"),

AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON("m_autofill_logins_import_no_passwords"),
AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU("m_autofill_logins_import"),
AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON("m_autofill_logins_import_no_passwords"),
AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU("m_autofill_logins_import"),
AUTOFILL_IMPORT_PASSWORDS_GET_DESKTOP_BROWSER("m_autofill_logins_import_get_desktop"),
AUTOFILL_IMPORT_PASSWORDS_SYNC_WITH_DESKTOP("m_autofill_logins_import_sync"),
AUTOFILL_IMPORT_PASSWORDS_USER_TOOK_NO_ACTION("m_autofill_logins_import_no-action"),
Expand Down Expand Up @@ -177,8 +177,8 @@ object AutofillPixelsRequiringDataCleaning : PixelParamRemovalPlugin {
AUTOFILL_ENGAGEMENT_ONBOARDED_USER.pixelName to PixelParameter.removeAtb(),
AUTOFILL_ENGAGEMENT_STACKED_LOGINS.pixelName to PixelParameter.removeAtb(),

AUTOFILL_IMPORT_PASSWORDS_CTA_BUTTON.pixelName to PixelParameter.removeAtb(),
AUTOFILL_IMPORT_PASSWORDS_OVERFLOW_MENU.pixelName to PixelParameter.removeAtb(),
AUTOFILL_SYNC_DESKTOP_PASSWORDS_CTA_BUTTON.pixelName to PixelParameter.removeAtb(),
AUTOFILL_SYNC_DESKTOP_PASSWORDS_OVERFLOW_MENU.pixelName to PixelParameter.removeAtb(),
AUTOFILL_IMPORT_PASSWORDS_GET_DESKTOP_BROWSER.pixelName to PixelParameter.removeAtb(),
AUTOFILL_IMPORT_PASSWORDS_SYNC_WITH_DESKTOP.pixelName to PixelParameter.removeAtb(),
AUTOFILL_IMPORT_PASSWORDS_USER_TOOK_NO_ACTION.pixelName to PixelParameter.removeAtb(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ import android.util.Patterns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.app.browser.api.WebViewCapabilityChecker
import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.DocumentStartJavaScript
import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.WebMessageListener
import com.duckduckgo.app.browser.favicon.FaviconManager
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.autofill.api.AutofillFeature
import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource
import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserOverflow
import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.BrowserSnackbar
Expand Down Expand Up @@ -84,7 +88,7 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsVie
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.DuckAddressStatus.NotManageable
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.DuckAddressStatus.SettingActivationStatus
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchDeleteAllPasswordsConfirmation
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchImportPasswords
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchImportPasswordsFromGooglePasswordManager
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchReportAutofillBreakageConfirmation
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.LaunchResetNeverSaveListConfirmation
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.PromptUserToAuthenticateMassDeletion
Expand Down Expand Up @@ -133,6 +137,8 @@ class AutofillSettingsViewModel @Inject constructor(
private val autofillBreakageReportSender: AutofillBreakageReportSender,
private val autofillBreakageReportDataStore: AutofillSiteBreakageReportingDataStore,
private val autofillBreakageReportCanShowRules: AutofillBreakageReportCanShowRules,
private val autofillFeature: AutofillFeature,
private val webViewCapabilityChecker: WebViewCapabilityChecker,
) : ViewModel() {

private val _viewState = MutableStateFlow(ViewState())
Expand Down Expand Up @@ -160,6 +166,9 @@ class AutofillSettingsViewModel @Inject constructor(

private var combineJob: Job? = null

// we only want to send this once for this 'session' of being in the management screen
private var importGooglePasswordButtonShownPixelSent = false

fun onCopyUsername(username: String?) {
username?.let { clipboardInteractor.copyToClipboard(it, isSensitive = false) }
pixel.fire(AutofillPixelNames.AUTOFILL_COPY_USERNAME)
Expand Down Expand Up @@ -431,6 +440,14 @@ class AutofillSettingsViewModel @Inject constructor(
_neverSavedSitesViewState.value = NeverSavedSitesViewState(showOptionToReset = count > 0)
}
}

viewModelScope.launch(dispatchers.io()) {
val gpmImport = autofillFeature.self().isEnabled() && autofillFeature.canImportFromGooglePasswordManager().isEnabled()
val webViewWebMessageSupport = webViewCapabilityChecker.isSupported(WebMessageListener)
val webViewDocumentStartJavascript = webViewCapabilityChecker.isSupported(DocumentStartJavaScript)
val canImport = gpmImport && webViewWebMessageSupport && webViewDocumentStartJavascript
_viewState.value = _viewState.value.copy(canImportFromGooglePasswords = canImport)
}
}

private suspend fun isBreakageReportingAllowed(): Boolean {
Expand Down Expand Up @@ -689,8 +706,10 @@ class AutofillSettingsViewModel @Inject constructor(
}
}

fun onImportPasswords() {
addCommand(LaunchImportPasswords)
fun onImportPasswordsFromGooglePasswordManager() {
viewModelScope.launch(dispatchers.io()) {
addCommand(LaunchImportPasswordsFromGooglePasswordManager)
}
}

fun onReportBreakageClicked() {
Expand All @@ -702,7 +721,10 @@ class AutofillSettingsViewModel @Inject constructor(
}
}

fun updateCurrentSite(currentUrl: String?, privacyProtectionEnabled: Boolean?) {
fun updateCurrentSite(
currentUrl: String?,
privacyProtectionEnabled: Boolean?,
) {
val updatedReportBreakageState = _viewState.value.reportBreakageState.copy(
currentUrl = currentUrl,
privacyProtectionEnabled = privacyProtectionEnabled,
Expand Down Expand Up @@ -771,6 +793,14 @@ class AutofillSettingsViewModel @Inject constructor(
}
}

fun recordImportGooglePasswordButtonShown() {
if (!importGooglePasswordButtonShownPixelSent) {
importGooglePasswordButtonShownPixelSent = true

// pixel to show import button would fire here
}
}

data class ViewState(
val autofillEnabled: Boolean = true,
val showAutofillEnabledToggle: Boolean = true,
Expand All @@ -779,6 +809,7 @@ class AutofillSettingsViewModel @Inject constructor(
val credentialSearchQuery: String = "",
val reportBreakageState: ReportBreakageState = ReportBreakageState(),
val canShowPromo: Boolean = false,
val canImportFromGooglePasswords: Boolean = false,
)

data class ReportBreakageState(
Expand Down Expand Up @@ -854,7 +885,7 @@ class AutofillSettingsViewModel @Inject constructor(
data object LaunchResetNeverSaveListConfirmation : ListModeCommand()
data class LaunchDeleteAllPasswordsConfirmation(val numberToDelete: Int) : ListModeCommand()
data class PromptUserToAuthenticateMassDeletion(val authConfiguration: AuthConfiguration) : ListModeCommand()
data object LaunchImportPasswords : ListModeCommand()
data object LaunchImportPasswordsFromGooglePasswordManager : ListModeCommand()
data class LaunchReportAutofillBreakageConfirmation(val eTldPlusOne: String) : ListModeCommand()
data object ShowUserReportSentMessage : ListModeCommand()
data object ReevalutePromotions : ListModeCommand()
Expand Down
Loading

0 comments on commit 1a6c073

Please sign in to comment.