From 83ffcac57ed4b387a9b0292795c431fc95899e15 Mon Sep 17 00:00:00 2001 From: ekager Date: Sat, 29 Aug 2020 16:39:38 -0400 Subject: [PATCH] For #13926 - MP migration --- app/metrics.yaml | 28 ++ .../mozilla/fenix/components/metrics/Event.kt | 3 + .../components/metrics/GleanMetricsService.kt | 8 + .../fenix/components/tips/TipManager.kt | 5 +- .../providers/MasterPasswordTipProvider.kt | 265 ++++++++++++++++++ .../org/mozilla/fenix/home/HomeFragment.kt | 41 ++- .../fenix/home/tips/ButtonTipViewHolder.kt | 3 + .../SavedLoginsStorageController.kt | 1 - .../java/org/mozilla/fenix/utils/Settings.kt | 5 + app/src/main/res/layout/button_tip_item.xml | 62 ++-- .../main/res/layout/mp_migration_dialog.xml | 71 +++++ .../res/layout/mp_migration_done_dialog.xml | 42 +++ app/src/main/res/values/preference_keys.xml | 1 + app/src/main/res/values/static_strings.xml | 16 ++ app/src/main/res/values/strings.xml | 2 +- docs/metrics.md | 4 +- 16 files changed, 520 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/components/tips/providers/MasterPasswordTipProvider.kt create mode 100644 app/src/main/res/layout/mp_migration_dialog.xml create mode 100644 app/src/main/res/layout/mp_migration_done_dialog.xml diff --git a/app/metrics.yaml b/app/metrics.yaml index 46572d058..ab3a3cddb 100644 --- a/app/metrics.yaml +++ b/app/metrics.yaml @@ -3946,3 +3946,31 @@ progressive_web_app: - fenix-core@mozilla.com - erichards@mozilla.com expires: "2021-03-01" + +master_password: + displayed: + type: event + description: | + The master password migration dialog was displayed + bugs: + - https://github.com/mozilla-mobile/fenix/pull/14468#issuecomment-684114534 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/14468#issuecomment-684114534 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-03-01" + migration: + type: event + description: | + Logins were successfully migrated using a master password. + bugs: + - https://github.com/mozilla-mobile/fenix/pull/14468#issuecomment-684114534 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/14468#issuecomment-684114534 + data_sensitivity: + - interaction + notification_emails: + - fenix-core@mozilla.com + expires: "2021-03-01" diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt index 1ca03a57c..db78aa466 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/Event.kt @@ -186,6 +186,9 @@ sealed class Event { object ProgressiveWebAppOpenFromHomescreenTap : Event() object ProgressiveWebAppInstallAsShortcut : Event() + object MasterPasswordMigrationSuccess : Event() + object MasterPasswordMigrationDisplayed : Event() + // Interaction events with extras data class ProgressiveWebAppForeground(val timeForegrounded: Long) : Event() { diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt index a89499fce..219a7fd19 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt @@ -27,6 +27,7 @@ import org.mozilla.fenix.GleanMetrics.FindInPage import org.mozilla.fenix.GleanMetrics.History import org.mozilla.fenix.GleanMetrics.LoginDialog import org.mozilla.fenix.GleanMetrics.Logins +import org.mozilla.fenix.GleanMetrics.MasterPassword import org.mozilla.fenix.GleanMetrics.MediaNotification import org.mozilla.fenix.GleanMetrics.MediaState import org.mozilla.fenix.GleanMetrics.Metrics @@ -684,6 +685,13 @@ private val Event.wrapper: EventWrapper<*>? { ProgressiveWebApp.backgroundKeys.valueOf(it) } ) + Event.MasterPasswordMigrationDisplayed -> EventWrapper( + { MasterPassword.displayed.record(it) } + ) + Event.MasterPasswordMigrationSuccess -> EventWrapper( + { MasterPassword.migration.record(it) } + ) + // Don't record other events in Glean: is Event.AddBookmark -> null is Event.OpenedBookmark -> null diff --git a/app/src/main/java/org/mozilla/fenix/components/tips/TipManager.kt b/app/src/main/java/org/mozilla/fenix/components/tips/TipManager.kt index 1cb82131f..9a58953d7 100644 --- a/app/src/main/java/org/mozilla/fenix/components/tips/TipManager.kt +++ b/app/src/main/java/org/mozilla/fenix/components/tips/TipManager.kt @@ -4,6 +4,8 @@ package org.mozilla.fenix.components.tips +import android.graphics.drawable.Drawable + sealed class TipType { data class Button(val text: String, val action: () -> Unit) : TipType() } @@ -13,7 +15,8 @@ open class Tip( val identifier: String, val title: String, val description: String, - val learnMoreURL: String? + val learnMoreURL: String?, + val titleDrawable: Drawable? = null ) interface TipProvider { diff --git a/app/src/main/java/org/mozilla/fenix/components/tips/providers/MasterPasswordTipProvider.kt b/app/src/main/java/org/mozilla/fenix/components/tips/providers/MasterPasswordTipProvider.kt new file mode 100644 index 000000000..e578f90fa --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/tips/providers/MasterPasswordTipProvider.kt @@ -0,0 +1,265 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.components.tips.providers + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import io.sentry.Sentry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.appservices.logins.IdCollisionException +import mozilla.appservices.logins.InvalidRecordException +import mozilla.appservices.logins.LoginsStorageException +import mozilla.appservices.logins.ServerPassword +import mozilla.components.concept.storage.Login +import mozilla.components.support.migration.FennecLoginsMPImporter +import mozilla.components.support.migration.FennecProfile +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.tips.Tip +import org.mozilla.fenix.components.tips.TipProvider +import org.mozilla.fenix.components.tips.TipType +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.metrics +import org.mozilla.fenix.ext.settings + +/** + * Tip explaining to master password users how to migrate their logins. + */ +class MasterPasswordTipProvider( + private val context: Context, + private val navigateToLogins: () -> Unit, + private val dismissTip: (Tip) -> Unit +) : TipProvider { + + private val fennecLoginsMPImporter: FennecLoginsMPImporter? by lazy { + FennecProfile.findDefault( + context, + context.components.analytics.crashReporter + )?.let { + FennecLoginsMPImporter( + it + ) + } + } + + override val tip: Tip? by lazy { masterPasswordMigrationTip() } + + override val shouldDisplay: Boolean by lazy { + context.settings().shouldDisplayMasterPasswordMigrationTip && + fennecLoginsMPImporter?.hasMasterPassword() == true + } + + private fun masterPasswordMigrationTip(): Tip = + Tip( + type = TipType.Button( + text = context.getString(R.string.mp_homescreen_button), + action = ::showMasterPasswordMigration + ), + identifier = context.getString(R.string.pref_key_master_password_tip), + title = context.getString(R.string.mp_homescreen_tip_title), + description = context.getString(R.string.mp_homescreen_tip_message), + learnMoreURL = null, + titleDrawable = ContextCompat.getDrawable(context, R.drawable.ic_login) + ) + + private fun showMasterPasswordMigration() { + val dialogView = LayoutInflater.from(context).inflate(R.layout.mp_migration_dialog, null) + + val dialogBuilder = AlertDialog.Builder(context).apply { + setTitle(context.getString(R.string.mp_dialog_title_recovery_transfer_saved_logins)) + setMessage(context.getString(R.string.mp_dialog_message_recovery_transfer_saved_logins)) + setView(dialogView) + create() + } + + val dialog = dialogBuilder.show() + + context.metrics.track(Event.MasterPasswordMigrationDisplayed) + + val passwordErrorText = context.getString(R.string.mp_dialog_error_transfer_saved_logins) + val migrationContinueButton = + dialogView.findViewById(R.id.migration_continue) + val passwordView = dialogView.findViewById(R.id.password_field) + val passwordLayout = + dialogView.findViewById(R.id.password_text_input_layout) + passwordView.addTextChangedListener( + object : TextWatcher { + var isValid = false + override fun afterTextChanged(p: Editable?) { + when { + p.toString().isEmpty() -> { + isValid = false + passwordLayout.error = passwordErrorText + } + else -> { + val possiblePassword = passwordView.text.toString() + isValid = + fennecLoginsMPImporter?.checkPassword(possiblePassword) == true + passwordLayout.error = if (isValid) null else passwordErrorText + } + } + migrationContinueButton.alpha = if (isValid) 1F else HALF_OPACITY + migrationContinueButton.isEnabled = isValid + } + + override fun beforeTextChanged( + p: CharSequence?, + start: Int, + count: Int, + after: Int + ) { + // NOOP + } + + override fun onTextChanged(p: CharSequence?, start: Int, before: Int, count: Int) { + // NOOP + } + }) + + migrationContinueButton.apply { + setOnClickListener { + // Step 1: Verify the password again before trying to use it + val possiblePassword = passwordView.text.toString() + val isValid = fennecLoginsMPImporter?.checkPassword(possiblePassword) == true + + // Step 2: With valid MP, get logins and complete the migration + if (isValid) { + val logins = fennecLoginsMPImporter?.getLoginRecords( + possiblePassword, + context.components.analytics.crashReporter + ) + + if (logins.isNullOrEmpty()) { + showFailureDialog() + dialog.dismiss() + } else { + saveLogins(logins, dialog) + } + } else { + passwordView.error = + context?.getString(R.string.mp_dialog_error_transfer_saved_logins) + } + } + } + + dialogView.findViewById(R.id.migration_cancel).apply { + setOnClickListener { + dialog.dismiss() + } + } + } + + private fun showFailureDialog() { + val dialogView = + LayoutInflater.from(context).inflate(R.layout.mp_migration_done_dialog, null) + + val dialogBuilder = AlertDialog.Builder(context).apply { + setTitle(context.getString(R.string.mp_dialog_title_transfer_failure)) + setMessage(context.getString(R.string.mp_dialog_message_transfer_failure)) + setView(dialogView) + create() + } + + val dialog = dialogBuilder.show() + + dialogView.findViewById(R.id.positive_button).apply { + text = context.getString(R.string.mp_dialog_close_transfer) + setOnClickListener { + tip?.let { dismissTip(it) } + dialog.dismiss() + } + } + dialogView.findViewById(R.id.negative_button).apply { + isVisible = false + } + } + + private fun saveLogins(logins: List, dialog: AlertDialog) { + CoroutineScope(IO).launch { + logins.map { it.toLogin() }.forEach { + try { + context.components.core.passwordsStorage.add(it) + } catch (e: InvalidRecordException) { + // This record was invalid and we couldn't save this login + Sentry.capture("Master Password migration add login error $e for reason ${e.reason}") + } catch (e: IdCollisionException) { + // Nonempty ID was provided + Sentry.capture("Master Password migration add login error $e") + } catch (e: LoginsStorageException) { + // Some other error occurred + Sentry.capture("Master Password migration add login error $e") + } + } + withContext(Dispatchers.Main) { + // Step 3: Dismiss this dialog and show the success dialog + showSuccessDialog() + dialog.dismiss() + } + } + } + + private fun showSuccessDialog() { + tip?.let { dismissTip(it) } + + context.metrics.track(Event.MasterPasswordMigrationSuccess) + + val dialogView = + LayoutInflater.from(context).inflate(R.layout.mp_migration_done_dialog, null) + + val dialogBuilder = AlertDialog.Builder(context).apply { + setTitle(context.getString(R.string.mp_dialog_title_transfer_success)) + setMessage(context.getString(R.string.mp_dialog_message_transfer_success)) + setView(dialogView) + create() + } + + val dialog = dialogBuilder.show() + + dialogView.findViewById(R.id.positive_button).apply { + setOnClickListener { + navigateToLogins() + dialog.dismiss() + } + } + dialogView.findViewById(R.id.negative_button).apply { + setOnClickListener { + dialog.dismiss() + } + } + } + + /** + * Converts an Application Services [ServerPassword] to an Android Components [Login] + */ + fun ServerPassword.toLogin() = Login( + origin = hostname, + formActionOrigin = formSubmitURL, + httpRealm = httpRealm, + username = username, + password = password, + timesUsed = timesUsed, + timeCreated = timeCreated, + timeLastUsed = timeLastUsed, + timePasswordChanged = timePasswordChanged, + usernameField = usernameField, + passwordField = passwordField + ) + + companion object { + private const val HALF_OPACITY = .5F + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index d9eb1fecb..26db79972 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -82,6 +82,8 @@ import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.tips.FenixTipManager +import org.mozilla.fenix.components.tips.Tip +import org.mozilla.fenix.components.tips.providers.MasterPasswordTipProvider import org.mozilla.fenix.components.tips.providers.MigrationTipProvider import org.mozilla.fenix.components.toolbar.TabCounterMenu import org.mozilla.fenix.components.toolbar.ToolbarPosition @@ -174,6 +176,7 @@ class HomeFragment : Fragment() { } } + @Suppress("LongMethod") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -197,7 +200,18 @@ class HomeFragment : Fragment() { expandedCollections = emptySet(), mode = currentMode.getCurrentMode(), topSites = components.core.topSiteStorage.cachedTopSites, - tip = FenixTipManager(listOf(MigrationTipProvider(requireContext()))).getTip(), + tip = StrictMode.allowThreadDiskReads().resetPoliciesAfter { + FenixTipManager( + listOf( + MasterPasswordTipProvider( + requireContext(), + ::navToSavedLogins, + ::dismissTip + ), + MigrationTipProvider(requireContext()) + ) + ).getTip() + }, showCollectionPlaceholder = components.settings.showCollectionsPlaceholderOnHome ) ) @@ -232,6 +246,7 @@ class HomeFragment : Fragment() { handleSwipedItemDeletionCancel = ::handleSwipedItemDeletionCancel ) ) + updateLayout(view) sessionControlView = SessionControlView( view.sessionControlRecyclerView, @@ -246,6 +261,10 @@ class HomeFragment : Fragment() { return view } + private fun dismissTip(tip: Tip) { + sessionControlInteractor.onCloseTip(tip) + } + /** * Returns a [TopSitesConfig] which specifies how many top sites to display and whether or * not frequently visited sites should be displayed. @@ -411,7 +430,8 @@ class HomeFragment : Fragment() { // We call this onLayout so that the bottom bar width is correctly set for us to center // the CFR in. view.toolbar_wrapper.doOnLayout { - val willNavigateToSearch = !bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR) && FeatureFlags.newSearchExperience + val willNavigateToSearch = + !bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR) && FeatureFlags.newSearchExperience if (!browsingModeManager.mode.isPrivate && !willNavigateToSearch) { SearchWidgetCFR( context = view.context, @@ -540,7 +560,18 @@ class HomeFragment : Fragment() { collections = components.core.tabCollectionStorage.cachedTabCollections, mode = currentMode.getCurrentMode(), topSites = components.core.topSiteStorage.cachedTopSites, - tip = FenixTipManager(listOf(MigrationTipProvider(requireContext()))).getTip(), + tip = StrictMode.allowThreadDiskReads().resetPoliciesAfter { + FenixTipManager( + listOf( + MasterPasswordTipProvider( + requireContext(), + ::navToSavedLogins, + ::dismissTip + ), + MigrationTipProvider(requireContext()) + ) + ).getTip() + }, showCollectionPlaceholder = components.settings.showCollectionsPlaceholderOnHome ) ) @@ -587,6 +618,10 @@ class HomeFragment : Fragment() { } } + private fun navToSavedLogins() { + findNavController().navigate(HomeFragmentDirections.actionGlobalSavedLoginsAuthFragment()) + } + private fun dispatchModeChanges(mode: Mode) { if (mode != Mode.fromBrowsingMode(browsingModeManager.mode)) { homeFragmentStore.dispatch(HomeFragmentAction.ModeChange(mode)) diff --git a/app/src/main/java/org/mozilla/fenix/home/tips/ButtonTipViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/tips/ButtonTipViewHolder.kt index 17b31b35b..9a9bdeb53 100644 --- a/app/src/main/java/org/mozilla/fenix/home/tips/ButtonTipViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/tips/ButtonTipViewHolder.kt @@ -37,6 +37,9 @@ class ButtonTipViewHolder( metrics.track(Event.TipDisplayed(tip.identifier)) tip_header_text.text = tip.title + tip.titleDrawable?.let { + tip_header_text.setCompoundDrawablesWithIntrinsicBounds(it, null, null, null) + } tip_description_text.text = tip.description tip_button.text = tip.type.text diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt index 754ddb688..bd1bbe96a 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt @@ -124,7 +124,6 @@ open class SavedLoginsStorageController( fun findPotentialDuplicates(loginId: String) { var deferredLogin: Deferred>? = null - // What scope should be used here? val fetchLoginJob = viewLifecycleScope.launch(ioDispatcher) { deferredLogin = async { val login = getLogin(loginId) diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index d4555f00f..536aab23e 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -163,6 +163,11 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = false ) + var shouldDisplayMasterPasswordMigrationTip by booleanPreference( + appContext.getString(R.string.pref_key_master_password_tip), + true + ) + // If any of the prefs have been modified, quit displaying the fenix moved tip fun shouldDisplayFenixMovingTip(): Boolean = preferences.getBoolean( diff --git a/app/src/main/res/layout/button_tip_item.xml b/app/src/main/res/layout/button_tip_item.xml index 53ce35925..6f52e18bf 100644 --- a/app/src/main/res/layout/button_tip_item.xml +++ b/app/src/main/res/layout/button_tip_item.xml @@ -2,56 +2,57 @@ - + android:layout_height="wrap_content" + android:background="@drawable/cfr_background_gradient" + android:padding="0dp"> + tools:text="Header text" /> + android:tint="@color/primary_text_dark_theme" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/ic_close" /> + app:layout_constraintTop_toBottomOf="@id/tip_header_text" + tools:text="Tip description" /> + tools:textColor="@color/accent_high_contrast_private_theme" />