[Password Manager] Add "exporting..." and "error" states to the export passwords dialog

The different states of the export flow are implemented as separate dialog instances
which replace each other.

Mocks:
https://docs.google.com/presentation/d/1nIm5OmaOnb85ZAwMZPSVqHT0vkFbsRYZhOzbmznWU_c/edit#slide=id.g289b1efcd8_0_4

Bug: 789561
Cq-Include-Trybots: master.tryserver.chromium.linux:closure_compilation
Change-Id: Id25af371164196ee8fffec723f080be2320e5b22
Reviewed-on: https://chromium-review.googlesource.com/800611
Reviewed-by: Hector Carmona <hcarmona@chromium.org>
Commit-Queue: Christos Froussios <cfroussios@chromium.org>
Cr-Commit-Position: refs/heads/master@{#532811}
diff --git a/chrome/app/settings_strings.grdp b/chrome/app/settings_strings.grdp
index abbbdf5..49e775c3 100644
--- a/chrome/app/settings_strings.grdp
+++ b/chrome/app/settings_strings.grdp
@@ -620,6 +620,25 @@
   <message name="IDS_SETTINGS_PASSWORDS_EXPORT" desc="A button in the dialog for exporting passwords from Chrome. A password list will be written to a destination, which the user will be asked to choose after initiating this action.">
     Export passwords...
   </message>
+  <message name="IDS_SETTINGS_PASSWORDS_EXPORT_TRY_AGAIN" desc="A button in the dialog for exporting passwords from Chrome. The action is to attempt to export the passwords again. A password list will be written to a destination, which the user will be asked to choose after initiating this action.">
+    Try again...
+  </message>
+  <message name="IDS_SETTINGS_PASSWORDS_EXPORTING_TITLE" desc="The title of a dialog for exporting passwords. This title is shown while Chrome is performing the export and the user should wait.">
+    Exporting passwords...
+  </message>
+  <message name="IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TITLE" desc="The title to a dialog, which is shown if exporting passwords to a folder has failed.">
+    Can't export passwords to "<ph name="FOLDER">$1<ex>Documents</ex></ph>"
+  </message>
+  <message name="IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIPS" desc="Message that is shown when exporting passwords has failed. Below it is a list of things the user can try to resolve the problem.">
+    Try the following tips
+  </message>
+  <message name="IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIP_ENOUGH_SPACE" desc="Message that is shown when exporting passwords has failed. This is part of a list of things the user can try to resolve the problem. This advice implies that there isn't enough space on the disk for the new file to be written.">
+    Make sure there is enough space on your device
+  </message>
+  <message name="IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIP_ANOTHER_FOLDER" desc="Message that is shown when exporting passwords has failed. This is part of a list of things the user can try to resolve the problem. This advice implies that Chrome couldn't write into the specified folder.">
+    Export your passwords to another folder
+  </message>
+
 
   <!-- Default Browser Page -->
   <if expr="not chromeos">
diff --git a/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html b/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html
index 9353ae7e..40e3a155 100644
--- a/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html
+++ b/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html
@@ -8,8 +8,12 @@
 <dom-module id="passwords-export-dialog">
   <template>
     <style include="settings-shared iron-flex">
+      paper-progress {
+        width: 100%;
+        --paper-progress-active-color: var(--google-blue-500);
+      }
     </style>
-    <dialog is="cr-dialog" id="dialog" close-text="$i18n{close}">
+    <dialog is="cr-dialog" id="dialog_start" close-text="$i18n{close}">
       <div slot="title">$i18n{exportPasswordsTitle}</div>
       <div slot="body">
         <div class="layout horizontal center">
@@ -27,6 +31,41 @@
         </paper-button>
       </div>
     </dialog>
+
+    <dialog is="cr-dialog" id="dialog_progress" close-text="$i18n{close}">
+      <div slot="title">$i18n{exportingPasswordsTitle}</div>
+      <div slot="body">
+        <paper-progress indeterminate class="blue"></paper-progress>
+      </div>
+      <div slot="button-container">
+        <paper-button class="secondary-button header-aligned-button"
+            on-tap="onCancelButtonTap_">
+          $i18n{cancel}
+        </paper-button>
+      </div>
+    </dialog>
+
+    <dialog is="cr-dialog" id="dialog_error" close-text="$i18n{close}">
+      <div slot="title">[[exportErrorMessage]]</div>
+      <div slot="body">
+        $i18n{exportPasswordsFailTips}
+        <ul>
+          <li>$i18n{exportPasswordsFailTipsEnoughSpace}</li>
+          <li>$i18n{exportPasswordsFailTipsAnotherFolder}</li>
+        </ul>
+      </div>
+      <div slot="button-container">
+        <paper-button class="secondary-button header-aligned-button"
+            on-tap="onCancelButtonTap_">
+          $i18n{cancel}
+        </paper-button>
+        <paper-button class="action-button header-aligned-button"
+            on-tap="onExportTap_">
+          $i18n{exportPasswordsTryAgain}
+        </paper-button>
+      </div>
+    </dialog>
+
   </template>
   <script src="passwords_export_dialog.js"></script>
-</dom-module>
\ No newline at end of file
+</dom-module>
diff --git a/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js b/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js
index 9730cb04..3a1d087 100644
--- a/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js
+++ b/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js
@@ -10,9 +10,43 @@
 (function() {
 'use strict';
 
+/**
+ * The states of the export passwords dialog.
+ * @enum {string}
+ */
+const States = {
+  START: 'START',
+  IN_PROGRESS: 'IN_PROGRESS',
+  ERROR: 'ERROR',
+};
+
+const ProgressStatus = chrome.passwordsPrivate.ExportProgressStatus;
+
+/**
+ * The amount of time (ms) between the start of the export and the moment we
+ * start showing the progress bar.
+ * @type {number}
+ */
+const progressBarDelayMs = 100;
+
+/**
+ * The minimum amount of time (ms) that the progress bar will be visible.
+ * @type {number}
+ */
+const progressBarBlockMs = 2000;
+
 Polymer({
   is: 'passwords-export-dialog',
 
+  behaviors: [I18nBehavior],
+
+  properties: {
+    /** The error that occurred while exporting. */
+    exportErrorMessage: String,
+  },
+
+  listeners: {'cancel': 'close'},
+
   /**
    * The interface for callbacks to the browser.
    * Defined in passwords_section.js
@@ -21,16 +55,114 @@
    */
   passwordManager_: null,
 
+  /** @private {function(!PasswordManager.PasswordExportProgress):void} */
+  onPasswordsFileExportProgressListener_: null,
+
+  /**
+   * The task that will display the progress bar, if the export doesn't finish
+   * quickly. This is null, unless the task is currently scheduled.
+   * @private {?number}
+   */
+  progressTaskToken_: null,
+
+  /**
+   * The task that will display the completion of the export, if any. We display
+   * the progress bar for at least 2000ms, therefore, if export finishes
+   * earlier, we cache the result in |delayedProgress_| and this task will
+   * consume it. This is null, unless the task is currently scheduled.
+   * @private {?number}
+   */
+  delayedCompletionToken_: null,
+
+  /**
+   * We display the progress bar for at least 2000ms. If progress is achieved
+   * earlier, we store the update here and consume it later.
+   * @private {?PasswordManager.PasswordExportProgress}
+   */
+  delayedProgress_: null,
+
   /** @override */
   attached: function() {
-    this.$.dialog.showModal();
-
     this.passwordManager_ = PasswordManagerImpl.getInstance();
+
+    this.switchToDialog_(States.START);
+
+    this.onPasswordsFileExportProgressListener_ =
+        this.onPasswordsFileExportProgress_.bind(this);
+
+    // If export started on a different tab and is still in progress, display a
+    // busy UI.
+    this.passwordManager_.requestExportProgressStatus(status => {
+      if (status == ProgressStatus.IN_PROGRESS)
+        this.switchToDialog_(States.IN_PROGRESS);
+    });
+
+    this.passwordManager_.addPasswordsFileExportProgressListener(
+        this.onPasswordsFileExportProgressListener_);
+  },
+
+  /**
+   * Handles an export progress event by changing the visible dialog or caching
+   * the event for later consumption.
+   * @param {!PasswordManager.PasswordExportProgress} progress
+   * @private
+   */
+  onPasswordsFileExportProgress_(progress) {
+    // If Chrome has already started displaying the progress bar
+    // (|progressTaskToken_ is null) and hasn't completed its minimum display
+    // time (|delayedCompletionToken_| is not null) progress should be cached
+    // for consumption when the blocking time ends.
+    const progressBlocked =
+        !this.progressTaskToken_ && !!this.delayedCompletionToken_;
+    if (!progressBlocked) {
+      clearTimeout(this.progressTaskToken_);
+      this.progressTaskToken_ = null;
+      this.processProgress_(progress);
+    } else {
+      this.delayedProgress_ = progress;
+    }
+  },
+
+  /**
+   * Displays the progress bar and suspends further UI updates for
+   * |progressBarBlockMs|.
+   * @private
+   */
+  progressTask_() {
+    this.progressTaskToken_ = null;
+    this.switchToDialog_(States.IN_PROGRESS);
+
+    this.delayedCompletionToken_ =
+        setTimeout(this.delayedCompletionTask_.bind(this), progressBarBlockMs);
+  },
+
+  /**
+   * Unblocks progress after showing the progress bar for |progressBarBlock|ms
+   * and processes any progress that was delayed.
+   * @private
+   */
+  delayedCompletionTask_() {
+    this.delayedCompletionToken_ = null;
+    if (this.delayedProgress_) {
+      this.processProgress_(this.delayedProgress_);
+      this.delayedProgress_ = null;
+    }
   },
 
   /** Closes the dialog. */
   close: function() {
-    this.$.dialog.close();
+    clearTimeout(this.progressTaskToken_);
+    clearTimeout(this.delayedCompletionToken_);
+    this.progressTaskToken_ = null;
+    this.delayedCompletionToken_ = null;
+    this.passwordManager_.removePasswordsFileExportProgressListener(
+        this.onPasswordsFileExportProgressListener_);
+    if (this.$.dialog_start.open)
+      this.$.dialog_start.close();
+    if (this.$.dialog_progress.open)
+      this.$.dialog_progress.close();
+    if (this.$.dialog_error.open)
+      this.$.dialog_error.close();
   },
 
   /**
@@ -38,15 +170,56 @@
    * @private
    */
   onExportTap_: function() {
-    this.passwordManager_.exportPasswords(this.onExportRequested_);
+    this.passwordManager_.exportPasswords(() => {
+      if (chrome.runtime.lastError &&
+          chrome.runtime.lastError.message == 'in-progress') {
+        // Exporting was started by a different call to exportPasswords() and is
+        // is still in progress. This UI needs to be updated to the current
+        // status.
+        this.switchToDialog_(States.IN_PROGRESS);
+      }
+    });
   },
 
   /**
-   * Callback to let us know whether our request for exporting was accepted.
+   * Prepares and displays the appropriate view (with delay, if necessary).
+   * @param {!PasswordManager.PasswordExportProgress} progress
    * @private
    */
-  onExportRequested_: function(accepted) {
-    // TODO(http://crbug/789561) Jump to "export in progress" UI.
+  processProgress_(progress) {
+    if (progress.status == ProgressStatus.IN_PROGRESS) {
+      this.progressTaskToken_ =
+          setTimeout(this.progressTask_.bind(this), progressBarDelayMs);
+      return;
+    }
+    if (progress.status == ProgressStatus.SUCCEEDED) {
+      this.close();
+      return;
+    }
+    if (progress.status == ProgressStatus.FAILED_WRITE_FAILED) {
+      this.exportErrorMessage =
+          this.i18n('exportPasswordsFailTitle', progress.folderName);
+      this.switchToDialog_(States.ERROR);
+      return;
+    }
+  },
+
+  /**
+   * Opens the specified dialog and hides the others.
+   * @param {!States} state the dialog to open.
+   * @private
+   */
+  switchToDialog_(state) {
+    this.$.dialog_start.open = false;
+    this.$.dialog_error.open = false;
+    this.$.dialog_progress.open = false;
+
+    if (state == States.START)
+      this.$.dialog_start.showModal();
+    if (state == States.ERROR)
+      this.$.dialog_error.showModal();
+    if (state == States.IN_PROGRESS)
+      this.$.dialog_progress.showModal();
   },
 
   /**
diff --git a/chrome/browser/ui/webui/settings/md_settings_localized_strings_provider.cc b/chrome/browser/ui/webui/settings/md_settings_localized_strings_provider.cc
index f9bfd0f..cff34e16 100644
--- a/chrome/browser/ui/webui/settings/md_settings_localized_strings_provider.cc
+++ b/chrome/browser/ui/webui/settings/md_settings_localized_strings_provider.cc
@@ -1304,7 +1304,17 @@
       {"passwordDeleted", IDS_SETTINGS_PASSWORD_DELETED_PASSWORD},
       {"exportPasswordsTitle", IDS_SETTINGS_PASSWORDS_EXPORT_TITLE},
       {"exportPasswordsDescription", IDS_SETTINGS_PASSWORDS_EXPORT_DESCRIPTION},
-      {"exportPasswords", IDS_SETTINGS_PASSWORDS_EXPORT}};
+      {"exportPasswords", IDS_SETTINGS_PASSWORDS_EXPORT},
+      {"exportingPasswordsTitle", IDS_SETTINGS_PASSWORDS_EXPORTING_TITLE},
+      {"exportPasswordsTryAgain", IDS_SETTINGS_PASSWORDS_EXPORT_TRY_AGAIN},
+      {"exportPasswordsFailTitle",
+       IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TITLE},
+      {"exportPasswordsFailTips",
+       IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIPS},
+      {"exportPasswordsFailTipsEnoughSpace",
+       IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIP_ENOUGH_SPACE},
+      {"exportPasswordsFailTipsAnotherFolder",
+       IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIP_ANOTHER_FOLDER}};
 
   html_source->AddString(
       "managePasswordsLabel",
diff --git a/chrome/test/data/webui/settings/settings_passwords_section_browsertest.js b/chrome/test/data/webui/settings/settings_passwords_section_browsertest.js
index ee43d9f..8227f976 100644
--- a/chrome/test/data/webui/settings/settings_passwords_section_browsertest.js
+++ b/chrome/test/data/webui/settings/settings_passwords_section_browsertest.js
@@ -15,6 +15,9 @@
 GEN_INCLUDE([ROOT_PATH +
     'chrome/test/data/webui/settings/passwords_and_autofill_fake_data.js']);
 
+// Mock timer.
+GEN_INCLUDE([ROOT_PATH + 'chrome/test/data/webui/mock_timer.js']);
+
 /**
  * @constructor
  * @extends {PolymerTest}
@@ -169,7 +172,18 @@
    * @return {!Object}
    * @private
    */
-  function createExportPasswordsDialog() {
+  function createExportPasswordsDialog(passwordManager) {
+    passwordManager.requestExportProgressStatus = callback => {
+      callback(chrome.passwordsPrivate.ExportProgressStatus.NOT_STARTED);
+    };
+    passwordManager.addPasswordsFileExportProgressListener = callback => {
+      passwordManager.progressCallback = callback;
+    };
+    passwordManager.removePasswordsFileExportProgressListener = () => {};
+    passwordManager.exportPasswords = (callback) => {
+      callback();
+    };
+
     const dialog = document.createElement('passwords-export-dialog');
     document.body.appendChild(dialog);
     Polymer.dom.flush();
@@ -571,17 +585,6 @@
       MockInteractions.tap(passwordListItem.$$('#showPasswordButton'));
     });
 
-    // Test that tapping "Export passwords..." notifies the browser accordingly
-    test('startExport', function(done) {
-      const exportDialog = createExportPasswordsDialog();
-
-      passwordManager.exportPasswords = () => {
-        done();
-      };
-
-      MockInteractions.tap(exportDialog.$.exportPasswordsButton);
-    });
-
     test('closingPasswordsSectionHidesUndoToast', function(done) {
       const passwordEntry = FakeDataMaker.passwordEntry('goo.gl', 'bart', 1);
       const passwordsSection =
@@ -602,6 +605,113 @@
 
       done();
     });
+
+    // Test that tapping "Export passwords..." notifies the browser accordingly
+    test('startExport', function(done) {
+      const exportDialog = createExportPasswordsDialog(passwordManager);
+
+      passwordManager.exportPasswords = (callback) => {
+        callback();
+        done();
+      };
+
+      MockInteractions.tap(exportDialog.$.exportPasswordsButton);
+    });
+
+    // Test the export flow. If exporting is fast, we should skip the
+    // in-progress view altogether.
+    test('exportFlowFast', function(done) {
+      const exportDialog = createExportPasswordsDialog(passwordManager);
+      const progressCallback = passwordManager.progressCallback;
+
+      // Use this to freeze the delayed progress bar and avoid flakiness.
+      let mockTimer = new MockTimer();
+      mockTimer.install();
+
+      assertTrue(exportDialog.$.dialog_start.open);
+      MockInteractions.tap(exportDialog.$.exportPasswordsButton);
+      assertTrue(exportDialog.$.dialog_start.open);
+      progressCallback(
+          {status: chrome.passwordsPrivate.ExportProgressStatus.IN_PROGRESS});
+      progressCallback(
+          {status: chrome.passwordsPrivate.ExportProgressStatus.SUCCEEDED});
+
+      // When we are done, the export dialog closes completely.
+      assertFalse(exportDialog.$.dialog_start.open);
+      assertFalse(exportDialog.$.dialog_error.open);
+      assertFalse(exportDialog.$.dialog_progress.open);
+      done();
+
+      mockTimer.uninstall();
+    });
+
+    // The error view is shown when an error occurs.
+    test('exportFlowError', function(done) {
+      const exportDialog = createExportPasswordsDialog(passwordManager);
+      const progressCallback = passwordManager.progressCallback;
+
+      // Use this to freeze the delayed progress bar and avoid flakiness.
+      let mockTimer = new MockTimer();
+      mockTimer.install();
+
+      assertTrue(exportDialog.$.dialog_start.open);
+      MockInteractions.tap(exportDialog.$.exportPasswordsButton);
+      assertTrue(exportDialog.$.dialog_start.open);
+      progressCallback(
+          {status: chrome.passwordsPrivate.ExportProgressStatus.IN_PROGRESS});
+      progressCallback({
+        status:
+            chrome.passwordsPrivate.ExportProgressStatus.FAILED_WRITE_FAILED,
+        folderName: 'tmp',
+      });
+
+      assertTrue(exportDialog.$.dialog_error.open);
+      done();
+
+      mockTimer.uninstall();
+    });
+
+    // Test the export flow. If exporting is slow, Chrome should show the
+    // in-progress dialog for at least 2000ms.
+    test('exportFlowSlow', function(done) {
+      const exportDialog = createExportPasswordsDialog(passwordManager);
+      const progressCallback = passwordManager.progressCallback;
+
+      let mockTimer = new MockTimer();
+      mockTimer.install();
+
+      // The initial dialog remains open for 100ms after export enters the
+      // in-progress state.
+      assertTrue(exportDialog.$.dialog_start.open);
+      MockInteractions.tap(exportDialog.$.exportPasswordsButton);
+      assertTrue(exportDialog.$.dialog_start.open);
+      progressCallback(
+          {status: chrome.passwordsPrivate.ExportProgressStatus.IN_PROGRESS});
+      assertTrue(exportDialog.$.dialog_start.open);
+
+      // After 100ms of not having completed, the dialog switches to the
+      // progress bar. Chrome will continue to show the progress bar for 2000ms,
+      // despite a completion event.
+      mockTimer.tick(99);
+      assertTrue(exportDialog.$.dialog_start.open);
+      mockTimer.tick(1);
+      assertTrue(exportDialog.$.dialog_progress.open);
+      progressCallback(
+          {status: chrome.passwordsPrivate.ExportProgressStatus.SUCCEEDED});
+      assertTrue(exportDialog.$.dialog_progress.open);
+
+      // After 2000ms, Chrome will display the completion event.
+      mockTimer.tick(1999);
+      assertTrue(exportDialog.$.dialog_progress.open);
+      mockTimer.tick(1);
+      // On SUCCEEDED the dialog closes completely.
+      assertFalse(exportDialog.$.dialog_progress.open);
+      assertFalse(exportDialog.$.dialog_start.open);
+      assertFalse(exportDialog.$.dialog_error.open);
+      done();
+
+      mockTimer.uninstall();
+    });
   });
 
   mocha.run();