Commit b76be05f authored by Vaclav Brozek's avatar Vaclav Brozek Committed by Commit Bot

[Android passwords settings] Add a progress bar

This CL adds a progress bar to the password export flow.

The progress bar is shown if the user already completed
reauthentication and export confirmation but the passwords
are still being serialized in the background. The user
can cancel the whole export with a Cancel button next
to the progress bar. The screenshot is at
https://crbug.com/788701#c22, the mocks at
go chrome-pwd-export-mocks-android (Google only).

This CL also merges the existing flags mExportOptionSuspended,
mExportRequested and mExportConfirmed into a single mExportState
to improve the clarity of the whole process.

Bug: 788701
Change-Id: I1857b135df255124a1507139095f36eca7c3dc3d
Reviewed-on: https://chromium-review.googlesource.com/881019Reviewed-by: default avatarTheresa <twellington@chromium.org>
Commit-Queue: Vaclav Brozek <vabr@chromium.org>
Cr-Commit-Position: refs/heads/master@{#533335}
parent 0d46f955
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2018 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file. -->
<!-- Used by ProgressBarDialogFragment of passwords preferences. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
style="@style/AlertDialogContent">
<org.chromium.chrome.browser.widget.MaterialProgressBar
android:id="@+id/passwords_progress_bar"
android:layout_marginTop="14dp"
android:layout_width="match_parent"
android:layout_height="4dp"/>
</LinearLayout>
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.preferences.password;
import android.app.Dialog;
import android.app.DialogFragment;
import android.content.DialogInterface;
import android.os.Bundle;
import android.support.v7.app.AlertDialog;
import android.view.View;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.widget.MaterialProgressBar;
/**
* Shows the dialog that informs the user about the progress of preparing passwords for export and
* allows the user to cancel that operation.
*/
public class ProgressBarDialogFragment extends DialogFragment {
// This handler is used to perform the user-triggered cancellation of the password preparation.
private DialogInterface.OnClickListener mHandler;
public void setCancelProgressHandler(DialogInterface.OnClickListener handler) {
mHandler = handler;
}
/**
* Opens the dialog with the progress bar, hooks up the cancel button handler and sets the
* progress indicator to being indeterminate, because the background operation does not easily
* allow to signal its own progress.
*/
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
View dialog =
getActivity().getLayoutInflater().inflate(R.layout.passwords_progress_dialog, null);
MaterialProgressBar bar =
(MaterialProgressBar) dialog.findViewById(R.id.passwords_progress_bar);
bar.setIndeterminate(true);
return new AlertDialog.Builder(getActivity(), R.style.SimpleDialog)
.setView(dialog)
.setNegativeButton(R.string.cancel, mHandler)
.setTitle(getActivity().getResources().getString(
R.string.settings_passwords_preparing_export))
.create();
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// If there is a |savedInstanceState|, then the dialog is being recreated
// by Android and will lack the necessary click handler. Dismiss
// immediately, the settings page will recreate it with the appropriate
// click handler.
if (savedInstanceState != null) {
dismiss();
return;
}
}
}
......@@ -18,6 +18,7 @@ import android.preference.Preference.OnPreferenceChangeListener;
import android.preference.PreferenceCategory;
import android.preference.PreferenceFragment;
import android.preference.PreferenceScreen;
import android.support.annotation.IntDef;
import android.support.annotation.Nullable;
import android.support.v7.app.AlertDialog;
import android.support.v7.widget.SearchView;
......@@ -52,6 +53,8 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.nio.charset.Charset;
import java.util.Locale;
......@@ -62,6 +65,27 @@ import java.util.Locale;
public class SavePasswordsPreferences
extends PreferenceFragment implements PasswordManagerHandler.PasswordListObserver,
Preference.OnPreferenceClickListener {
// ExportState describes at which state a password export is.
@Retention(RetentionPolicy.SOURCE)
@IntDef({EXPORT_STATE_INACTIVE, EXPORT_STATE_REQUESTED, EXPORT_STATE_CONFIRMED})
private @interface ExportState {}
/**
* EXPORT_STATE_INACTIVE: there is no currently running export. Either the user did not request
* one, or the last one completed (i.e., a share intent picker or an error message were
* displayed or the user cancelled it).
*/
private static final int EXPORT_STATE_INACTIVE = 0;
/**
* EXPORT_STATE_REQUESTED: the user requested the export in the menu but did not authenticate
* and confirm it yet.
*/
private static final int EXPORT_STATE_REQUESTED = 1;
/**
* EXPORT_STATE_CONFIRMED: the user confirmed the export and Chrome is still busy preparing the
* data for the share intent.
*/
private static final int EXPORT_STATE_CONFIRMED = 2;
// Keys for name/password dictionaries.
public static final String PASSWORD_LIST_URL = "url";
public static final String PASSWORD_LIST_NAME = "name";
......@@ -73,8 +97,8 @@ public class SavePasswordsPreferences
// The key for saving |mSearchQuery| to instance bundle.
private static final String SAVED_STATE_SEARCH_QUERY = "saved-state-search-query";
// The key for saving |mExportRequested| to instance bundle.
private static final String SAVED_STATE_EXPORT_REQUESTED = "saved-state-export-requested";
// The key for saving |mExportState| to instance bundle.
private static final String SAVED_STATE_EXPORT_STATE = "saved-state-export-state";
// The key for saving |mExportFileUri| to instance bundle.
private static final String SAVED_STATE_EXPORT_FILE_URI = "saved-state-export-file-uri";
......@@ -102,14 +126,10 @@ public class SavePasswordsPreferences
private boolean mNoPasswords;
private boolean mNoPasswordExceptions;
// True if the user triggered the password export flow and this fragment is waiting for the
// result of the user's reauthentication.
private boolean mExportRequested;
// True if the option to export passwords in the three-dots menu should be disabled due to an
// ongoing export.
private boolean mExportOptionSuspended = false;
// True if the user just finished the UI flow for confirming a password export.
private boolean mExportConfirmed;
@ExportState
private int mExportState;
// When the user requests that passwords are exported and once the passwords are sent over from
// native code and stored in a cache file, this variable contains the content:// URI for that
// cache file, or an empty URI if there was a problem with storing to that file. During all
......@@ -125,6 +145,12 @@ public class SavePasswordsPreferences
private TextMessagePreference mEmptyView;
private Menu mMenuForTesting;
// Contains the reference to the progress-bar dialog after the user confirms the password
// export and before the serialized passwords arrive, so that the dialog can be dismissed on the
// passwords' arrival. It is null during all other times.
@Nullable
private ProgressBarDialogFragment mProgressBarDialogFragment;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
......@@ -136,9 +162,12 @@ public class SavePasswordsPreferences
if (savedInstanceState == null) return;
if (savedInstanceState.containsKey(SAVED_STATE_EXPORT_REQUESTED)) {
mExportRequested =
savedInstanceState.getBoolean(SAVED_STATE_EXPORT_REQUESTED, mExportRequested);
if (savedInstanceState.containsKey(SAVED_STATE_EXPORT_STATE)) {
mExportState = savedInstanceState.getInt(SAVED_STATE_EXPORT_STATE);
if (mExportState == EXPORT_STATE_CONFIRMED) {
// If export is underway, ensure that the UI is updated.
tryExporting();
}
}
if (savedInstanceState.containsKey(SAVED_STATE_EXPORT_FILE_URI)) {
String uriString = savedInstanceState.getString(SAVED_STATE_EXPORT_FILE_URI);
......@@ -210,7 +239,8 @@ public class SavePasswordsPreferences
@Override
public void onPrepareOptionsMenu(Menu menu) {
menu.findItem(R.id.export_passwords).setEnabled(!mNoPasswords && !mExportOptionSuspended);
menu.findItem(R.id.export_passwords)
.setEnabled(!mNoPasswords && mExportState == EXPORT_STATE_INACTIVE);
super.onPrepareOptionsMenu(menu);
}
......@@ -252,6 +282,9 @@ public class SavePasswordsPreferences
@Override
protected void onPostExecute(ExportResult result) {
// Don't display any UI if the user cancelled the export in the meantime.
if (mExportState == EXPORT_STATE_INACTIVE) return;
if (result.mError != null) {
showExportErrorAndAbort(result.mError);
} else {
......@@ -267,8 +300,9 @@ public class SavePasswordsPreferences
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
if (id == R.id.export_passwords) {
assert mExportState == EXPORT_STATE_INACTIVE;
// Disable re-triggering exporting until the current exporting finishes.
mExportOptionSuspended = true;
mExportState = EXPORT_STATE_REQUESTED;
// Start fetching the serialized passwords now to use the time the user spends
// reauthenticating and reading the warning message. If the user cancels the export or
......@@ -287,12 +321,11 @@ public class SavePasswordsPreferences
R.string.password_export_set_lock_screen, Toast.LENGTH_LONG)
.show();
// Re-enable exporting, the current one was cancelled by Chrome.
mExportOptionSuspended = false;
mExportState = EXPORT_STATE_INACTIVE;
} else if (ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK)) {
exportAfterReauth();
} else {
mExportRequested = true;
ReauthenticationManager.displayReauthenticationFragment(
R.string.lockscreen_description_export, getView().getId(),
getFragmentManager(), ReauthenticationManager.REAUTH_SCOPE_BULK);
......@@ -310,11 +343,9 @@ public class SavePasswordsPreferences
@Override
public void onClick(DialogInterface dialog, int which) {
if (which == AlertDialog.BUTTON_POSITIVE) {
mExportConfirmed = true;
mExportState = EXPORT_STATE_CONFIRMED;
tryExporting();
}
// Re-enable exporting, the current one was either finished or dismissed.
mExportOptionSuspended = false;
}
});
exportWarningDialogFragment.show(getFragmentManager(), null);
......@@ -325,10 +356,28 @@ public class SavePasswordsPreferences
* confirmation flow.
*/
private void tryExporting() {
// TODO(crbug.com/788701): Display a progress indicator if user
// confirmed but serialising is not done yet and dismiss it once called
// again with serialising done.
if (mExportConfirmed && mExportFileUri != null) sendExportIntent();
if (mExportState != EXPORT_STATE_CONFIRMED) return;
if (mExportFileUri == null) {
// The serialization has not finished. Until this finishes, a progress bar is
// displayed with an option to cancel the export.
assert mProgressBarDialogFragment == null;
mProgressBarDialogFragment = new ProgressBarDialogFragment();
mProgressBarDialogFragment.setCancelProgressHandler(
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
if (which == AlertDialog.BUTTON_NEGATIVE) {
mExportState = EXPORT_STATE_INACTIVE;
}
}
});
mProgressBarDialogFragment.show(getFragmentManager(), null);
} else {
// Note: if the serialization is quicker than the user interacting with the
// confirmation dialog, then there is no progress bar shown.
if (mProgressBarDialogFragment != null) mProgressBarDialogFragment.dismiss();
sendExportIntent();
}
}
/**
......@@ -336,9 +385,11 @@ public class SavePasswordsPreferences
* @param description A string with a brief explanation of the error.
*/
private void showExportErrorAndAbort(String description) {
// TODO(crbug.com/788701): Implement.
// TODO(crbug.com/788701): Implement. Ensure that if ExportWarningDialogFragment is shown,
// then Chrome waits until the user closes it to avoid changing UI under their fingers.
if (mProgressBarDialogFragment != null) mProgressBarDialogFragment.dismiss();
// Re-enable exporting, the current one was just cancelled.
mExportOptionSuspended = false;
mExportState = EXPORT_STATE_INACTIVE;
}
/**
......@@ -385,7 +436,9 @@ public class SavePasswordsPreferences
* intent, so that the user can use a storage app to save the exported passwords.
*/
private void sendExportIntent() {
mExportConfirmed = false;
assert mExportState == EXPORT_STATE_CONFIRMED;
mExportState = EXPORT_STATE_INACTIVE;
if (mExportFileUri == Uri.EMPTY) return;
Intent send = new Intent(Intent.ACTION_SEND);
......@@ -558,15 +611,15 @@ public class SavePasswordsPreferences
@Override
public void onResume() {
super.onResume();
if (mExportRequested) {
mExportRequested = false;
// Depending on the authentication result, either carry on with exporting or re-enable
// the export menu for future attempts.
if (mExportState == EXPORT_STATE_REQUESTED) {
// Resuming in the "requested" state means that the user just returned from the
// reauthentication activity. Depending on the result, either carry on with exporting or
// re-enable the export menu for future attempts.
if (ReauthenticationManager.authenticationStillValid(
ReauthenticationManager.REAUTH_SCOPE_BULK)) {
exportAfterReauth();
} else {
mExportOptionSuspended = false;
mExportState = EXPORT_STATE_INACTIVE;
}
}
rebuildPasswordLists();
......@@ -575,7 +628,7 @@ public class SavePasswordsPreferences
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(SAVED_STATE_EXPORT_REQUESTED, mExportRequested);
outState.putInt(SAVED_STATE_EXPORT_STATE, mExportState);
if (mExportFileUri != null) {
outState.putString(SAVED_STATE_EXPORT_FILE_URI, mExportFileUri.toString());
}
......
......@@ -493,7 +493,7 @@ CHAR-LIMIT guidelines:
Showing password generation popup
</message>
<message name="IDS_SAVE_PASSWORD_PREFERENCES_EXPORT_ACTION_TITLE" desc="The title of a menu item to trigger exporting passwords from the password settings.">
Export passwords...
Export passwords
</message>
<message name="IDS_SAVE_PASSWORD_PREFERENCES_EXPORT_ACTION_DESCRIPTION" desc="The description of a menu item to trigger exporting passwords from the password settings.">
Export passwords stored with Chrome
......@@ -501,6 +501,9 @@ CHAR-LIMIT guidelines:
<message name="IDS_SETTINGS_PASSWORDS_EXPORT_DESCRIPTION" desc="Text shown to the user who initiated exporting passwords, as a warning before any passwords have been exported.">
Your passwords will be visible to anyone who can see the exported file.
</message>
<message name="IDS_SETTINGS_PASSWORDS_PREPARING_EXPORT" desc="Text shown to the user above a progress bar, informing the user that passwords are being prepared for export.">
Preparing passwords…
</message>
<!-- Lock Screen Fragment -->
<message name="IDS_LOCKSCREEN_DESCRIPTION_COPY" desc="When a user attempts to copy a password for a particular website into clipboard in Chrome's settings, Chrome launches a lock screen to verify the user's identity and displays the following explanation.">
......
......@@ -987,6 +987,7 @@ chrome_java_sources = [
"java/src/org/chromium/chrome/browser/preferences/password/PasswordManagerHandlerProvider.java",
"java/src/org/chromium/chrome/browser/preferences/password/PasswordReauthenticationFragment.java",
"java/src/org/chromium/chrome/browser/preferences/password/PasswordUIView.java",
"java/src/org/chromium/chrome/browser/preferences/password/ProgressBarDialogFragment.java",
"java/src/org/chromium/chrome/browser/preferences/password/ReauthenticationManager.java",
"java/src/org/chromium/chrome/browser/preferences/password/SavedPasswordEntry.java",
"java/src/org/chromium/chrome/browser/preferences/password/SavePasswordsPreferences.java",
......
......@@ -35,6 +35,7 @@ import static org.hamcrest.Matchers.startsWith;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;
import android.support.annotation.Nullable;
import android.support.test.InstrumentationRegistry;
import android.support.test.espresso.Espresso;
import android.support.test.espresso.intent.Intents;
......@@ -95,8 +96,9 @@ public class SavePasswordsPreferencesTest {
// The faked contents of the saves password exceptions to be displayed.
private ArrayList<String> mSavedPasswordExeptions = new ArrayList<>();
// This is set to true when serializePasswords is called.
private boolean mSerializePasswordsCalled;
// This is set once {@link #serializePasswords()} is called.
@Nullable
private Callback<String> mExportCallback;
public void setSavedPasswords(ArrayList<SavedPasswordEntry> savedPasswords) {
mSavedPasswords = savedPasswords;
......@@ -106,8 +108,8 @@ public class SavePasswordsPreferencesTest {
mSavedPasswordExeptions = savedPasswordExceptions;
}
public boolean getSerializePasswordsCalled() {
return mSerializePasswordsCalled;
public Callback<String> getExportCallback() {
return mExportCallback;
}
/**
......@@ -152,8 +154,7 @@ public class SavePasswordsPreferencesTest {
@Override
public void serializePasswords(Callback<String> callback) {
callback.onResult("serialized passwords");
mSerializePasswordsCalled = true;
mExportCallback = callback;
}
}
......@@ -236,6 +237,20 @@ public class SavePasswordsPreferencesTest {
}
}
/**
* Taps the menu item to trigger exporting and ensures that reauthentication passes.
*/
private void reauthenticateAndRequestExport() {
Espresso.openActionBarOverflowOrOptionsMenu(
InstrumentationRegistry.getInstrumentation().getTargetContext());
// Before exporting, pretend that the last successful reauthentication just
// happened. This will allow the export flow to continue.
ReauthenticationManager.recordLastReauth(
System.currentTimeMillis(), ReauthenticationManager.REAUTH_SCOPE_BULK);
Espresso.onView(withText(R.string.save_password_preferences_export_action_title))
.perform(click());
}
/**
* Ensure that resetting of empty passwords list works.
*/
......@@ -476,7 +491,7 @@ public class SavePasswordsPreferencesTest {
Espresso.onView(withText(R.string.save_password_preferences_export_action_title))
.perform(click());
Assert.assertTrue(mHandler.getSerializePasswordsCalled());
Assert.assertTrue(mHandler.getExportCallback() != null);
}
/**
......@@ -631,15 +646,50 @@ public class SavePasswordsPreferencesTest {
Intents.init();
Espresso.openActionBarOverflowOrOptionsMenu(
InstrumentationRegistry.getInstrumentation().getTargetContext());
// Before exporting, pretend that the last successful reauthentication just
// happened. This will allow the export flow to continue.
ReauthenticationManager.recordLastReauth(
System.currentTimeMillis(), ReauthenticationManager.REAUTH_SCOPE_BULK);
reauthenticateAndRequestExport();
// Pretend that passwords have been serialized to go directly to the intent.
mHandler.getExportCallback().onResult("serialized passwords");
// Before triggering the sharing intent chooser, stub it out to avoid leaving system UI open
// after the test is finished.
intending(hasAction(equalTo(Intent.ACTION_CHOOSER)))
.respondWith(new Instrumentation.ActivityResult(Activity.RESULT_OK, null));
// Confirm the export warning to fire the sharing intent.
Espresso.onView(withText(R.string.save_password_preferences_export_action_title))
.perform(click());
intended(allOf(hasAction(equalTo(Intent.ACTION_CHOOSER)),
hasExtras(hasEntry(equalTo(Intent.EXTRA_INTENT),
allOf(hasAction(equalTo(Intent.ACTION_SEND)), hasType("text/csv"))))));
Intents.release();
}
/**
* Check that a progressbar is displayed when the user confirms the export and the serialized
* passwords are not ready yet.
*/
@Test
@SmallTest
@Feature({"Preferences"})
@EnableFeatures("PasswordExport")
public void testExportProgress() throws Exception {
setPasswordSource(new SavedPasswordEntry("https://example.com", "test user", "password"));
ReauthenticationManager.setApiOverride(ReauthenticationManager.OVERRIDE_STATE_AVAILABLE);
ReauthenticationManager.setScreenLockSetUpOverride(
ReauthenticationManager.OVERRIDE_STATE_AVAILABLE);
final Preferences preferences =
PreferencesTest.startPreferences(InstrumentationRegistry.getInstrumentation(),
SavePasswordsPreferences.class.getName());
Intents.init();
reauthenticateAndRequestExport();
// Before triggering the sharing intent chooser, stub it out to avoid leaving system UI open
// after the test is finished.
intending(hasAction(equalTo(Intent.ACTION_CHOOSER)))
......@@ -649,6 +699,19 @@ public class SavePasswordsPreferencesTest {
Espresso.onView(withText(R.string.save_password_preferences_export_action_title))
.perform(click());
// Before simulating the serialized passwords being received, check that the progress bar is
// shown.
Espresso.onView(withText(R.string.settings_passwords_preparing_export))
.check(matches(isDisplayed()));
// Now pretend that passwords have been serialized.
mHandler.getExportCallback().onResult("serialized passwords");
// Before simulating the serialized passwords being received, check that the progress bar is
// hidden.
Espresso.onView(withText(R.string.settings_passwords_preparing_export))
.check(doesNotExist());
intended(allOf(hasAction(equalTo(Intent.ACTION_CHOOSER)),
hasExtras(hasEntry(equalTo(Intent.EXTRA_INTENT),
allOf(hasAction(equalTo(Intent.ACTION_SEND)), hasType("text/csv"))))));
......@@ -656,6 +719,48 @@ public class SavePasswordsPreferencesTest {
Intents.release();
}
/**
* Check that the user can cancel exporting with the "Cancel" button on the progressbar.
*/
@Test
@SmallTest
@Feature({"Preferences"})
@EnableFeatures("PasswordExport")
public void testExportCancelOnProgress() throws Exception {
setPasswordSource(new SavedPasswordEntry("https://example.com", "test user", "password"));
ReauthenticationManager.setApiOverride(ReauthenticationManager.OVERRIDE_STATE_AVAILABLE);
ReauthenticationManager.setScreenLockSetUpOverride(
ReauthenticationManager.OVERRIDE_STATE_AVAILABLE);
final Preferences preferences =
PreferencesTest.startPreferences(InstrumentationRegistry.getInstrumentation(),
SavePasswordsPreferences.class.getName());
reauthenticateAndRequestExport();
// Confirm the export warning to fire the sharing intent.
Espresso.onView(withText(R.string.save_password_preferences_export_action_title))
.perform(click());
// Check that the progress bar is shown.
Espresso.onView(withText(R.string.settings_passwords_preparing_export))
.check(matches(isDisplayed()));
// Hit the Cancel button.
Espresso.onView(withText(R.string.cancel)).perform(click());
// Check that the cancellation succeeded by checking that the export menu is available and
// enabled.
openActionBarOverflowOrOptionsMenu(
InstrumentationRegistry.getInstrumentation().getTargetContext());
// The text matches a text view, but the potentially disabled entity is some wrapper two
// levels up in the view hierarchy, hence the two withParent matchers.
Espresso.onView(allOf(withText(R.string.save_password_preferences_export_action_title),
withParent(withParent(isEnabled()))))
.check(matches(isDisplayed()));
}
/**
* Check whether the user is asked to set up a screen lock if attempting to view passwords.
*/
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment