Skip to content

Commit

Permalink
Add pixels for importing google passwords metrics (#5284)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/608920331025315/1208787586139232/f 

### Description
Adds metrics around the import password flow. To support this in a flexible way, it also allows for the url mappings to be defined remotely for which stage of the flow a user dropped out at if they didn't go all the way through; if we ever need to, can override the default url mappings.

Logcat filter: `message~:"Pixel sent: autofill_import_google_passwords"`

### Steps to test this PR

**Setup**
- [x] Fresh install

**Cancelling the user journey**
- [x] Visit `Passwords` screen, and verify `autofill_import_google_passwords_import_button_shown` in logs for the import password button being shown
- [x] Tap on `Import Passwords From Google` button; verify`autofill_import_google_passwords_import_button_tapped` in logs
- [x] Verify `autofill_import_google_passwords_preimport_prompt_displayed` in logs
- [x] Dismiss the import dialog; verify`autofill_import_google_passwords_result_user_cancelled` in logs
- [x] Tap on `Import Passwords From Google` button again. 
- [x] This time, tap on the `Open Google Passwords` button; verify`autofill_import_google_passwords_preimport_prompt_confirmed` in logs
- [x] Tap the ✖️ button to quit the import flow; verify`autofill_import_google_passwords_result_user_cancelled` in logs, with `stage=webflow-pre-login`
- [x] Dismiss the dialog that is still showing, and verify there is not a further cancellation pixel in logs for when the dialog closes, as this is already covered by the ✖️ button cancellation
- [x] Launch the import flow again. This time tap on the `Sign in` button. Then ✖️ to exit the flow; verify `autofill_import_google_passwords_result_user_cancelled` with `stage=webflow-authenticate`
- [x] Launch the import flow again. This time, actually sign in, then ✖️ to exit the flow; verify `autofill_import_google_passwords_result_user_cancelled` with `stage=webflow-post-login-landing`
- [x] Launch the import flow again, and you'll be on the screen with the export button. tap ✖️ to exit the flow; verify `autofill_import_google_passwords_result_user_cancelled` with `stage=webflow-export`
- [x] Launch the import flow again. Tap the export button, and agree on the dialog. Then when prompted to authenticate, tap ✖️ to exit the flow; verify `autofill_import_google_passwords_result_user_cancelled` with `stage=webflow-authenticate`

**Succeeding**
- [x] Launch the import flow and fully complete it. Verify `autofill_import_google_passwords_result_success`, with the correct _bucket_  for `saved_credentials` and `skipped_credentials` based on bucket rules [defined here](https://app.asana.com/0/72649045549333/1207437778421216/f)
- [x] Repeat that, and you should get duplicates this time. Verify again, ensuring that `skipped_credentials` bucket is accurate.

**Overflow menu**
- [x] Now that you have saved passwords, `Import Passwords From Google` will appear in the overflow menu. Tap on that. Verify `autofill_import_google_passwords_overflow_menu_tapped` and `autofill_import_google_passwords_preimport_prompt_displayed` in logs

**Encrypted passphrase scenario**
- [x] Apply [patch](https://app.asana.com/0/488551667048375/1208796814221367/f)
- [x] Launch import flow and you'll see the error screen. Tap ✖️ to exit flow. Verify `autofill_import_google_passwords_result_user_cancelled` with `stage=webflow-passphrase-encryption`

**CSV parsing scenario**
- [x] Discard local changes
- [x] Apply [patch](https://app.asana.com/0/488551667048375/1208796814221368/f)
- [x] Launch import flow and carry on until you've exported. The patch will simulate an error. Verify `autofill_import_google_passwords_result_parsing` in logs.

**URL mapping via remote config**
- [x] Discard local changes
- [x] Apply [patch](https://app.asana.com/0/488551667048375/1208799215631036/f) to override the URL mappings
- [x] Fresh install. Launch password import flow and then ✖️ to exit. Verify in logs with filter `urlMappings` that the mappings from the patched jsonblob are used and override the local defaults. 

**Selectively disabling JS injection**
- [x] Discard local changes
- [x] Apply [patch](https://app.asana.com/0/488551667048375/1208799215631037/f) which simulates disabling remote config for JS injection specifically. 
- [x] Fresh install
- [x] Launch password import flow; verify you don't see any UI hints on which button to press
- [x] Complete the flow; verify it still works as expected
  • Loading branch information
CDRussell authored Nov 21, 2024
1 parent 1a6c073 commit f213b31
Show file tree
Hide file tree
Showing 19 changed files with 397 additions and 93 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

interface AutofillEngagementBucketing {
fun bucketNumberOfSavedPasswords(savedPasswords: Int): String
fun bucketNumberOfCredentials(numberOfCredentials: Int): String

companion object {
const val NONE = "none"
Expand All @@ -40,12 +40,12 @@ interface AutofillEngagementBucketing {
@ContributesBinding(AppScope::class)
class DefaultAutofillEngagementBucketing @Inject constructor() : AutofillEngagementBucketing {

override fun bucketNumberOfSavedPasswords(savedPasswords: Int): String {
override fun bucketNumberOfCredentials(numberOfCredentials: Int): String {
return when {
savedPasswords == 0 -> NONE
savedPasswords < 4 -> FEW
savedPasswords < 11 -> SOME
savedPasswords < 50 -> MANY
numberOfCredentials == 0 -> NONE
numberOfCredentials < 4 -> FEW
numberOfCredentials < 11 -> SOME
numberOfCredentials < 50 -> MANY
else -> LOTS
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ class DefaultAutofillEngagementRepository @Inject constructor(

val numberStoredPasswords = getNumberStoredPasswords()
val togglePixel = if (autofillStore.autofillEnabled) AUTOFILL_TOGGLED_ON_SEARCH else AUTOFILL_TOGGLED_OFF_SEARCH
val bucket = engagementBucketing.bucketNumberOfSavedPasswords(numberStoredPasswords)
val bucket = engagementBucketing.bucketNumberOfCredentials(numberStoredPasswords)
pixel.fire(togglePixel, mapOf("count_bucket" to bucket), type = Daily())
}

Expand All @@ -113,7 +113,7 @@ class DefaultAutofillEngagementRepository @Inject constructor(
if (autofilled && searched) {
pixel.fire(AUTOFILL_ENGAGEMENT_ACTIVE_USER, type = Daily())

val bucket = engagementBucketing.bucketNumberOfSavedPasswords(numberStoredPasswords)
val bucket = engagementBucketing.bucketNumberOfCredentials(numberStoredPasswords)
pixel.fire(AUTOFILL_ENGAGEMENT_STACKED_LOGINS, mapOf("count_bucket" to bucket), type = Daily())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ interface AutofillImportPasswordConfigStore {
data class AutofillImportPasswordSettings(
val canImportFromGooglePasswords: Boolean,
val launchUrlGooglePasswords: String,
val canInjectJavascript: Boolean,
val javascriptConfigGooglePasswords: String,
val urlMappings: List<UrlMapping>,
)

data class UrlMapping(
val key: String,
val url: String,
)

@ContributesBinding(AppScope::class)
Expand All @@ -43,8 +50,8 @@ class AutofillImportPasswordConfigStoreImpl @Inject constructor(
private val moshi: Moshi,
) : AutofillImportPasswordConfigStore {

private val jsonAdapter: JsonAdapter<CanImportFromGooglePasswordManagerConfig> by lazy {
moshi.adapter(CanImportFromGooglePasswordManagerConfig::class.java)
private val jsonAdapter: JsonAdapter<ImportConfigJson> by lazy {
moshi.adapter(ImportConfigJson::class.java)
}

override suspend fun getConfig(): AutofillImportPasswordSettings {
Expand All @@ -54,24 +61,48 @@ class AutofillImportPasswordConfigStoreImpl @Inject constructor(
jsonAdapter.fromJson(it)
}.getOrNull()
}
val launchUrl = config?.launchUrl ?: LAUNCH_URL_DEFAULT
val javascriptConfig = config?.javascriptConfig?.toString() ?: JAVASCRIPT_CONFIG_DEFAULT

AutofillImportPasswordSettings(
canImportFromGooglePasswords = autofillFeature.canImportFromGooglePasswordManager().isEnabled(),
launchUrlGooglePasswords = launchUrl,
javascriptConfigGooglePasswords = javascriptConfig,
launchUrlGooglePasswords = config?.launchUrl ?: LAUNCH_URL_DEFAULT,
canInjectJavascript = config?.canInjectJavascript ?: CAN_INJECT_JAVASCRIPT_DEFAULT,
javascriptConfigGooglePasswords = config?.javascriptConfig?.toString() ?: JAVASCRIPT_CONFIG_DEFAULT,
urlMappings = config?.urlMappings.convertFromJsonModel(),
)
}
}

companion object {
internal const val JAVASCRIPT_CONFIG_DEFAULT = "\"{}\""
internal const val CAN_INJECT_JAVASCRIPT_DEFAULT = true

internal const val LAUNCH_URL_DEFAULT = "https://passwords.google.com/options?ep=1"

// 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-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"),
UrlMapping(key = "webflow-authenticate", url = "https://accounts.google.com/"),
UrlMapping(key = "webflow-post-login-landing", url = "https://passwords.google.com"),
)
}

private data class CanImportFromGooglePasswordManagerConfig(
private data class ImportConfigJson(
val launchUrl: String? = null,
val canInjectJavascript: Boolean = CAN_INJECT_JAVASCRIPT_DEFAULT,
val javascriptConfig: JSONObject? = null,
val urlMappings: List<UrlMappingJson>? = null,
)

private data class UrlMappingJson(
val key: String,
val url: String,
)

private fun List<UrlMappingJson>?.convertFromJsonModel(): List<UrlMapping> {
return this?.let { jsonList ->
jsonList.map { UrlMapping(key = it.key, url = it.url) }
} ?: URL_MAPPINGS_DEFAULT
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

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

import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore
import com.duckduckgo.di.scopes.FragmentScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import timber.log.Timber

interface ImportGooglePasswordUrlToStageMapper {
suspend fun getStage(url: String?): String
}

@ContributesBinding(FragmentScope::class)
class ImportGooglePasswordUrlToStageMapperImpl @Inject constructor(
private val importPasswordConfigStore: AutofillImportPasswordConfigStore,
) : ImportGooglePasswordUrlToStageMapper {

override suspend fun getStage(url: String?): String {
val config = importPasswordConfigStore.getConfig()
val stage = config.urlMappings.firstOrNull { url?.startsWith(it.url) == true }?.key ?: UNKNOWN
return stage.also { Timber.d("Mapped as stage $it for $url") }
}

companion object {
const val UNKNOWN = "webflow-unknown"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginTriggerType
import com.duckduckgo.autofill.impl.R
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.UserCannotImportReason
Expand Down Expand Up @@ -114,6 +115,9 @@ class ImportGooglePasswordsWebFlowFragment :
@Inject
lateinit var browserAutofillConfigurator: BrowserAutofill.Configurator

@Inject
lateinit var importPasswordConfig: AutofillImportPasswordConfigStore

private var binding: FragmentImportGooglePasswordsWebflowBinding? = null

private val viewModel by lazy {
Expand Down Expand Up @@ -283,8 +287,10 @@ class ImportGooglePasswordsWebFlowFragment :

@SuppressLint("RequiresFeature")
private suspend fun configurePasswordImportJavascript(webView: WebView) {
val script = passwordImporterScriptLoader.getScript()
WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*"))
if (importPasswordConfig.getConfig().canInjectJavascript) {
val script = passwordImporterScriptLoader.getScript()
WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*"))
}
}

private fun getToolbar() = (activity as ImportGooglePasswordsWebFlowActivity).binding.includeToolbar.toolbar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,25 @@ 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.autofill.impl.importing.gpm.webflow.ImportGooglePasswordsWebFlowViewModel.ViewState.UserCancelledImportFlow
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.di.scopes.FragmentScope
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)
@ContributesViewModel(FragmentScope::class)
class ImportGooglePasswordsWebFlowViewModel @Inject constructor(
private val dispatchers: DispatcherProvider,
private val credentialImporter: CredentialImporter,
private val csvCredentialConverter: CsvCredentialConverter,
private val autofillImportConfigStore: AutofillImportPasswordConfigStore,
private val urlToStageMapper: ImportGooglePasswordUrlToStageMapper,
) : ViewModel() {

private val _viewState = MutableStateFlow<ViewState>(Initializing)
Expand Down Expand Up @@ -71,11 +72,7 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor(
}

fun onCloseButtonPressed(url: String?) {
if (url?.startsWith(ENCRYPTED_PASSPHRASE_ERROR_URL) == true) {
_viewState.value = ViewState.UserFinishedCannotImport(EncryptedPassphrase)
} else {
terminateFlowAsCancellation(url ?: "unknown")
}
terminateFlowAsCancellation(url ?: "unknown")
}

fun onBackButtonPressed(
Expand All @@ -91,8 +88,10 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor(
_viewState.value = ViewState.NavigatingBack
}

private fun terminateFlowAsCancellation(stage: String) {
_viewState.value = ViewState.UserCancelledImportFlow(stage)
private fun terminateFlowAsCancellation(url: String) {
viewModelScope.launch {
_viewState.value = UserCancelledImportFlow(urlToStageMapper.getStage(url))
}
}

fun firstPageLoading() {
Expand All @@ -112,16 +111,9 @@ class ImportGooglePasswordsWebFlowViewModel @Inject constructor(
sealed interface UserCannotImportReason : Parcelable {
@Parcelize
data object ErrorParsingCsv : UserCannotImportReason

@Parcelize
data object EncryptedPassphrase : UserCannotImportReason
}

sealed interface BackButtonAction {
data object NavigateBack : BackButtonAction
}

companion object {
const val ENCRYPTED_PASSPHRASE_ERROR_URL = "https://passwords.google.com/error/sync-passphrase"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ 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_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_TAPPED("autofill_import_google_passwords_import_button_tapped"),
AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_SHOWN("autofill_import_google_passwords_import_button_shown"),
AUTOFILL_IMPORT_GOOGLE_PASSWORDS_OVERFLOW_MENU("autofill_import_google_passwords_overflow_menu_tapped"),
AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_DISPLAYED("autofill_import_google_passwords_preimport_prompt_displayed"),
AUTOFILL_IMPORT_GOOGLE_PASSWORDS_PREIMPORT_PROMPT_CONFIRMED("autofill_import_google_passwords_preimport_prompt_confirmed"),
AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_ERROR_PARSING("autofill_import_google_passwords_result_parsing"),
AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_FAILURE_USER_CANCELLED("autofill_import_google_passwords_result_user_cancelled"),
AUTOFILL_IMPORT_GOOGLE_PASSWORDS_RESULT_SUCCESS("autofill_import_google_passwords_result_success"),

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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_DELETE_LOGIN
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_DISABLED
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_ENABLED
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_SHOWN
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_MANAGEMENT_SCREEN_OPENED
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_MANUALLY_SAVE_CREDENTIAL
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE_FOR_THIS_SITE_CONFIRMATION_PROMPT_CONFIRMED
Expand Down Expand Up @@ -796,8 +797,7 @@ class AutofillSettingsViewModel @Inject constructor(
fun recordImportGooglePasswordButtonShown() {
if (!importGooglePasswordButtonShownPixelSent) {
importGooglePasswordButtonShownPixelSent = true

// pixel to show import button would fire here
pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_SHOWN)
}
}

Expand Down
Loading

0 comments on commit f213b31

Please sign in to comment.