1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7var EXPORTED_SYMBOLS = ["AutoMigrate"];
8
9const kAutoMigrateEnabledPref = "browser.migrate.automigrate.enabled";
10const kUndoUIEnabledPref = "browser.migrate.automigrate.ui.enabled";
11
12const kInPageUIEnabledPref = "browser.migrate.automigrate.inpage.ui.enabled";
13
14const kAutoMigrateBrowserPref = "browser.migrate.automigrate.browser";
15const kAutoMigrateImportedItemIds = "browser.migrate.automigrate.imported-items";
16
17const kAutoMigrateLastUndoPromptDateMsPref = "browser.migrate.automigrate.lastUndoPromptDateMs";
18const kAutoMigrateDaysToOfferUndoPref = "browser.migrate.automigrate.daysToOfferUndo";
19
20const kAutoMigrateUndoSurveyPref = "browser.migrate.automigrate.undo-survey";
21const kAutoMigrateUndoSurveyLocalePref = "browser.migrate.automigrate.undo-survey-locales";
22
23const kNotificationId = "automigration-undo";
24
25ChromeUtils.import("resource:///modules/MigrationUtils.jsm");
26ChromeUtils.import("resource://gre/modules/Preferences.jsm");
27ChromeUtils.import("resource://gre/modules/Services.jsm");
28ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
29
30ChromeUtils.defineModuleGetter(this, "AsyncShutdown",
31                               "resource://gre/modules/AsyncShutdown.jsm");
32ChromeUtils.defineModuleGetter(this, "LoginHelper",
33                               "resource://gre/modules/LoginHelper.jsm");
34ChromeUtils.defineModuleGetter(this, "NewTabUtils",
35                               "resource://gre/modules/NewTabUtils.jsm");
36ChromeUtils.defineModuleGetter(this, "OS",
37                               "resource://gre/modules/osfile.jsm");
38ChromeUtils.defineModuleGetter(this, "PlacesUtils",
39                               "resource://gre/modules/PlacesUtils.jsm");
40ChromeUtils.defineModuleGetter(this, "TelemetryStopwatch",
41                               "resource://gre/modules/TelemetryStopwatch.jsm");
42
43XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() {
44  const kBrandBundle = "chrome://branding/locale/brand.properties";
45  return Services.strings.createBundle(kBrandBundle);
46});
47
48Cu.importGlobalProperties(["URL"]);
49
50XPCOMUtils.defineLazyGetter(this, "kUndoStateFullPath", function() {
51  return OS.Path.join(OS.Constants.Path.profileDir, "initialMigrationMetadata.jsonlz4");
52});
53
54const AutoMigrate = {
55  get resourceTypesToUse() {
56    let {BOOKMARKS, HISTORY, PASSWORDS} = Ci.nsIBrowserProfileMigrator;
57    return BOOKMARKS | HISTORY | PASSWORDS;
58  },
59
60  _checkIfEnabled() {
61    let pref = Preferences.get(kAutoMigrateEnabledPref, false);
62    // User-set values should take precedence:
63    if (Services.prefs.prefHasUserValue(kAutoMigrateEnabledPref)) {
64      return pref;
65    }
66    // If we're using the default value, make sure the distribution.ini
67    // value is taken into account even early on startup.
68    try {
69      let distributionFile = Services.dirsvc.get("XREAppDist", Ci.nsIFile);
70      distributionFile.append("distribution.ini");
71      let parser = Cc["@mozilla.org/xpcom/ini-parser-factory;1"].
72                 getService(Ci.nsIINIParserFactory).
73                 createINIParser(distributionFile);
74      return JSON.parse(parser.getString("Preferences", kAutoMigrateEnabledPref));
75    } catch (ex) { /* ignore exceptions (file doesn't exist, invalid value, etc.) */ }
76
77    return pref;
78  },
79
80  init() {
81    this.enabled = this._checkIfEnabled();
82  },
83
84  /**
85   * Automatically pick a migrator and resources to migrate,
86   * then migrate those and start up.
87   *
88   * @throws if automatically deciding on migrators/data
89   *         failed for some reason.
90   */
91  async migrate(profileStartup, migratorKey, profileToMigrate) {
92    let histogram = Services.telemetry.getHistogramById(
93      "FX_STARTUP_MIGRATION_AUTOMATED_IMPORT_PROCESS_SUCCESS");
94    histogram.add(0);
95    let {migrator, pickedKey} = await this.pickMigrator(migratorKey);
96    histogram.add(5);
97
98    profileToMigrate = await this.pickProfile(migrator, profileToMigrate);
99    histogram.add(10);
100
101    let resourceTypes = await migrator.getMigrateData(profileToMigrate, profileStartup);
102    if (!(resourceTypes & this.resourceTypesToUse)) {
103      throw new Error("No usable resources were found for the selected browser!");
104    }
105    histogram.add(15);
106
107    let sawErrors = false;
108    let migrationObserver = (subject, topic) => {
109      if (topic == "Migration:ItemError") {
110        sawErrors = true;
111      } else if (topic == "Migration:Ended") {
112        histogram.add(25);
113        if (sawErrors) {
114          histogram.add(26);
115        }
116        Services.obs.removeObserver(migrationObserver, "Migration:Ended");
117        Services.obs.removeObserver(migrationObserver, "Migration:ItemError");
118        Services.prefs.setCharPref(kAutoMigrateBrowserPref, pickedKey);
119        // Save the undo history and block shutdown on that save completing.
120        AsyncShutdown.profileBeforeChange.addBlocker(
121          "AutoMigrate Undo saving", this.saveUndoState(), () => {
122            return {state: this._saveUndoStateTrackerForShutdown};
123          });
124      }
125    };
126
127    MigrationUtils.initializeUndoData();
128    Services.obs.addObserver(migrationObserver, "Migration:Ended");
129    Services.obs.addObserver(migrationObserver, "Migration:ItemError");
130    await migrator.migrate(this.resourceTypesToUse, profileStartup, profileToMigrate);
131    histogram.add(20);
132  },
133
134  /**
135   * Pick and return a migrator to use for automatically migrating.
136   *
137   * @param {String} migratorKey   optional, a migrator key to prefer/pick.
138   * @returns {Object}             an object with the migrator to use for migrating, as
139   *                               well as the key we eventually ended up using to obtain it.
140   */
141  async pickMigrator(migratorKey) {
142    if (!migratorKey) {
143      let defaultKey = MigrationUtils.getMigratorKeyForDefaultBrowser();
144      if (!defaultKey) {
145        throw new Error("Could not determine default browser key to migrate from");
146      }
147      migratorKey = defaultKey;
148    }
149    if (migratorKey == "firefox") {
150      throw new Error("Can't automatically migrate from Firefox.");
151    }
152
153    let migrator = await MigrationUtils.getMigrator(migratorKey);
154    if (!migrator) {
155      throw new Error("Migrator specified or a default was found, but the migrator object is not available (or has no data).");
156    }
157    return {migrator, pickedKey: migratorKey};
158  },
159
160  /**
161   * Pick a source profile (from the original browser) to use.
162   *
163   * @param {Migrator} migrator     the migrator object to use
164   * @param {String}   suggestedId  the id of the profile to migrate, if pre-specified, or null
165   * @returns                       the profile to migrate, or null if migrating
166   *                                from the default profile.
167   */
168  async pickProfile(migrator, suggestedId) {
169    let profiles = await migrator.getSourceProfiles();
170    if (profiles && !profiles.length) {
171      throw new Error("No profile data found to migrate.");
172    }
173    if (suggestedId) {
174      if (!profiles) {
175        throw new Error("Profile specified but only a default profile found.");
176      }
177      let suggestedProfile = profiles.find(profile => profile.id == suggestedId);
178      if (!suggestedProfile) {
179        throw new Error("Profile specified was not found.");
180      }
181      return suggestedProfile;
182    }
183    if (profiles && profiles.length > 1) {
184      throw new Error("Don't know how to pick a profile when more than 1 profile is present.");
185    }
186    return profiles ? profiles[0] : null;
187  },
188
189  _pendingUndoTasks: false,
190  async canUndo() {
191    if (this._savingPromise) {
192      await this._savingPromise;
193    }
194    if (this._pendingUndoTasks) {
195      return false;
196    }
197    let fileExists = false;
198    try {
199      fileExists = await OS.File.exists(kUndoStateFullPath);
200    } catch (ex) {
201      Cu.reportError(ex);
202    }
203    return fileExists;
204  },
205
206  async undo() {
207    let browserId = Preferences.get(kAutoMigrateBrowserPref, "unknown");
208    TelemetryStopwatch.startKeyed("FX_STARTUP_MIGRATION_UNDO_TOTAL_MS", browserId);
209    let histogram = Services.telemetry.getHistogramById("FX_STARTUP_MIGRATION_AUTOMATED_IMPORT_UNDO");
210    histogram.add(0);
211    if (!(await this.canUndo())) {
212      histogram.add(5);
213      throw new Error("Can't undo!");
214    }
215
216    this._pendingUndoTasks = true;
217    this._removeNotificationBars();
218    histogram.add(10);
219
220    let readPromise = OS.File.read(kUndoStateFullPath, {
221      encoding: "utf-8",
222      compression: "lz4",
223    });
224    let stateData = this._dejsonifyUndoState(await readPromise);
225    histogram.add(12);
226
227    this._errorMap = {bookmarks: 0, visits: 0, logins: 0};
228    let reportErrorTelemetry = (type) => {
229      let histogramId = `FX_STARTUP_MIGRATION_UNDO_${type.toUpperCase()}_ERRORCOUNT`;
230      Services.telemetry.getKeyedHistogramById(histogramId).add(browserId, this._errorMap[type]);
231    };
232
233    let startTelemetryStopwatch = resourceType => {
234      let histogramId = `FX_STARTUP_MIGRATION_UNDO_${resourceType.toUpperCase()}_MS`;
235      TelemetryStopwatch.startKeyed(histogramId, browserId);
236    };
237    let stopTelemetryStopwatch = resourceType => {
238      let histogramId = `FX_STARTUP_MIGRATION_UNDO_${resourceType.toUpperCase()}_MS`;
239      TelemetryStopwatch.finishKeyed(histogramId, browserId);
240    };
241    startTelemetryStopwatch("bookmarks");
242    await this._removeUnchangedBookmarks(stateData.get("bookmarks")).catch(ex => {
243      Cu.reportError("Uncaught exception when removing unchanged bookmarks!");
244      Cu.reportError(ex);
245    });
246    stopTelemetryStopwatch("bookmarks");
247    reportErrorTelemetry("bookmarks");
248    histogram.add(15);
249
250    startTelemetryStopwatch("visits");
251    await this._removeSomeVisits(stateData.get("visits")).catch(ex => {
252      Cu.reportError("Uncaught exception when removing history visits!");
253      Cu.reportError(ex);
254    });
255    stopTelemetryStopwatch("visits");
256    reportErrorTelemetry("visits");
257    histogram.add(20);
258
259    startTelemetryStopwatch("logins");
260    await this._removeUnchangedLogins(stateData.get("logins")).catch(ex => {
261      Cu.reportError("Uncaught exception when removing unchanged logins!");
262      Cu.reportError(ex);
263    });
264    stopTelemetryStopwatch("logins");
265    reportErrorTelemetry("logins");
266    histogram.add(25);
267
268    // This is async, but no need to wait for it.
269    NewTabUtils.links.populateCache(() => {
270      NewTabUtils.allPages.update();
271    }, true);
272
273    this._purgeUndoState(this.UNDO_REMOVED_REASON_UNDO_USED);
274    histogram.add(30);
275    TelemetryStopwatch.finishKeyed("FX_STARTUP_MIGRATION_UNDO_TOTAL_MS", browserId);
276  },
277
278  _removeNotificationBars() {
279    let browserWindows = Services.wm.getEnumerator("navigator:browser");
280    while (browserWindows.hasMoreElements()) {
281      let win = browserWindows.getNext();
282      if (!win.closed) {
283        for (let browser of win.gBrowser.browsers) {
284          let nb = win.gBrowser.getNotificationBox(browser);
285          let notification = nb.getNotificationWithValue(kNotificationId);
286          if (notification) {
287            nb.removeNotification(notification);
288          }
289        }
290      }
291    }
292  },
293
294  _purgeUndoState(reason) {
295    // We don't wait for the off-main-thread removal to complete. OS.File will
296    // ensure it happens before shutdown.
297    OS.File.remove(kUndoStateFullPath, {ignoreAbsent: true}).then(() => {
298      this._pendingUndoTasks = false;
299    });
300
301    let migrationBrowser = Preferences.get(kAutoMigrateBrowserPref, "unknown");
302    Services.prefs.clearUserPref(kAutoMigrateBrowserPref);
303
304    let histogram =
305      Services.telemetry.getKeyedHistogramById("FX_STARTUP_MIGRATION_UNDO_REASON");
306    histogram.add(migrationBrowser, reason);
307  },
308
309  getBrowserUsedForMigration() {
310    let browserId = Services.prefs.getCharPref(kAutoMigrateBrowserPref);
311    if (browserId) {
312      return MigrationUtils.getBrowserName(browserId);
313    }
314    return null;
315  },
316
317  /**
318   * Decide if we need to show [the user] a prompt indicating we automatically
319   * imported their data.
320   * @param target (xul:browser)
321   *        The browser in which we should show the notification.
322   * @returns {Boolean} return true when need to show the prompt.
323   */
324  async shouldShowMigratePrompt(target) {
325    if (!(await this.canUndo())) {
326      return false;
327    }
328
329    // The tab might have navigated since we requested the undo state:
330    let canUndoFromThisPage = ["about:home", "about:newtab"].includes(target.currentURI.spec);
331    if (!canUndoFromThisPage ||
332        !Preferences.get(kUndoUIEnabledPref, false)) {
333      return false;
334    }
335
336    // At this stage we're committed to show the prompt - unless we shouldn't,
337    // in which case we remove the undo prefs (which will cause canUndo() to
338    // return false from now on.):
339    if (this.isMigratePromptExpired()) {
340      this._purgeUndoState(this.UNDO_REMOVED_REASON_OFFER_EXPIRED);
341      this._removeNotificationBars();
342      return false;
343    }
344
345    let remainingDays = Preferences.get(kAutoMigrateDaysToOfferUndoPref, 0);
346    Services.telemetry.getHistogramById("FX_STARTUP_MIGRATION_UNDO_OFFERED").add(4 - remainingDays);
347
348    return true;
349  },
350
351  /**
352   * Return the message that denotes the user data is migrated from the other browser.
353   * @returns {String} imported message with the brand and the browser name
354   */
355  getUndoMigrationMessage() {
356    let browserName = this.getBrowserUsedForMigration();
357    if (!browserName) {
358      browserName = MigrationUtils.getLocalizedString("automigration.undo.unknownbrowser");
359    }
360    const kMessageId = "automigration.undo.message2." +
361                      Preferences.get(kAutoMigrateImportedItemIds, "all");
362    const kBrandShortName = gBrandBundle.GetStringFromName("brandShortName");
363    return MigrationUtils.getLocalizedString(kMessageId,
364                                             [kBrandShortName, browserName]);
365  },
366
367  /**
368   * Show the user a notification bar indicating we automatically imported
369   * their data and offering them the possibility of removing it.
370   * @param target (xul:browser)
371   *        The browser in which we should show the notification.
372   */
373  showUndoNotificationBar(target) {
374    let isInPage = Preferences.get(kInPageUIEnabledPref, false);
375    let win = target.ownerGlobal;
376    let notificationBox = win.gBrowser.getNotificationBox(target);
377    if (isInPage || !notificationBox || notificationBox.getNotificationWithValue(kNotificationId)) {
378      return;
379    }
380    let message = this.getUndoMigrationMessage();
381    let buttons = [
382      {
383        label: MigrationUtils.getLocalizedString("automigration.undo.keep2.label"),
384        accessKey: MigrationUtils.getLocalizedString("automigration.undo.keep2.accesskey"),
385        callback: () => {
386          this.keepAutoMigration();
387          this._removeNotificationBars();
388        },
389      },
390      {
391        label: MigrationUtils.getLocalizedString("automigration.undo.dontkeep2.label"),
392        accessKey: MigrationUtils.getLocalizedString("automigration.undo.dontkeep2.accesskey"),
393        callback: () => {
394          this.undoAutoMigration(win);
395        },
396      },
397    ];
398    notificationBox.appendNotification(
399      message, kNotificationId, null, notificationBox.PRIORITY_INFO_HIGH, buttons
400    );
401  },
402
403
404  /**
405   * Return true if we have shown the prompt to user several days.
406   * (defined in kAutoMigrateDaysToOfferUndoPref)
407   */
408  isMigratePromptExpired() {
409    let today = new Date();
410    // Round down to midnight:
411    today = new Date(today.getFullYear(), today.getMonth(), today.getDate());
412    // We store the unix timestamp corresponding to midnight on the last day
413    // on which we prompted. Fetch that and compare it to today's date.
414    // (NB: stored as a string because int prefs are too small for unix
415    // timestamps.)
416    let previousPromptDateMsStr = Preferences.get(kAutoMigrateLastUndoPromptDateMsPref, "0");
417    let previousPromptDate = new Date(parseInt(previousPromptDateMsStr, 10));
418    if (previousPromptDate < today) {
419      let remainingDays = Preferences.get(kAutoMigrateDaysToOfferUndoPref, 4) - 1;
420      Preferences.set(kAutoMigrateDaysToOfferUndoPref, remainingDays);
421      Preferences.set(kAutoMigrateLastUndoPromptDateMsPref, today.valueOf().toString());
422      if (remainingDays <= 0) {
423        return true;
424      }
425    }
426    return false;
427  },
428
429  UNDO_REMOVED_REASON_UNDO_USED: 0,
430  UNDO_REMOVED_REASON_SYNC_SIGNIN: 1,
431  UNDO_REMOVED_REASON_PASSWORD_CHANGE: 2,
432  UNDO_REMOVED_REASON_BOOKMARK_CHANGE: 3,
433  UNDO_REMOVED_REASON_OFFER_EXPIRED: 4,
434  UNDO_REMOVED_REASON_OFFER_REJECTED: 5,
435
436  _jsonifyUndoState(state) {
437    if (!state) {
438      return "null";
439    }
440    // Deal with date serialization.
441    let bookmarks = state.get("bookmarks");
442    for (let bm of bookmarks) {
443      bm.lastModified = bm.lastModified.getTime();
444    }
445    let serializableState = {
446      bookmarks,
447      logins: state.get("logins"),
448      visits: state.get("visits"),
449    };
450    return JSON.stringify(serializableState);
451  },
452
453  _dejsonifyUndoState(state) {
454    state = JSON.parse(state);
455    if (!state) {
456      return new Map();
457    }
458    for (let bm of state.bookmarks) {
459      bm.lastModified = new Date(bm.lastModified);
460    }
461    return new Map([
462      ["bookmarks", state.bookmarks],
463      ["logins", state.logins],
464      ["visits", state.visits],
465    ]);
466  },
467
468  /**
469   * Store the items we've saved into a pref. We use this to be able to show
470   * a detailed message to the user indicating what we've imported.
471   * @param state (Map)
472   *        The 'undo' state for the import, which contains info about
473   *        how many items of each kind we've (tried to) import.
474   */
475  _setImportedItemPrefFromState(state) {
476    let itemsWithData = [];
477    if (state) {
478      for (let itemType of state.keys()) {
479        if (state.get(itemType).length) {
480          itemsWithData.push(itemType);
481        }
482      }
483    }
484    if (itemsWithData.length == 3) {
485      itemsWithData = "all";
486    } else {
487      itemsWithData = itemsWithData.sort().join(".");
488    }
489    if (itemsWithData) {
490      Preferences.set(kAutoMigrateImportedItemIds, itemsWithData);
491    }
492  },
493
494  /**
495   * Used for the shutdown blocker's information field.
496   */
497  _saveUndoStateTrackerForShutdown: "not running",
498  /**
499   * Store the information required for using 'undo' of the automatic
500   * migration in the user's profile.
501   */
502  async saveUndoState() {
503    let resolveSavingPromise;
504    this._saveUndoStateTrackerForShutdown = "processing undo history";
505    this._savingPromise = new Promise(resolve => { resolveSavingPromise = resolve; });
506    let state = await MigrationUtils.stopAndRetrieveUndoData();
507
508    if (!state || ![...state.values()].some(ary => ary.length > 0)) {
509      // If we didn't import anything, abort now.
510      resolveSavingPromise();
511      return Promise.resolve();
512    }
513
514    this._saveUndoStateTrackerForShutdown = "saving imported item list";
515    this._setImportedItemPrefFromState(state);
516
517    this._saveUndoStateTrackerForShutdown = "writing undo history";
518    this._undoSavePromise = OS.File.writeAtomic(
519      kUndoStateFullPath, this._jsonifyUndoState(state), {
520        encoding: "utf-8",
521        compression: "lz4",
522        tmpPath: kUndoStateFullPath + ".tmp",
523      });
524    this._undoSavePromise.then(
525      rv => {
526        resolveSavingPromise(rv);
527        delete this._savingPromise;
528      },
529      e => {
530        Cu.reportError("Could not write undo state for automatic migration.");
531        throw e;
532      });
533    return this._undoSavePromise;
534  },
535
536  async _removeUnchangedBookmarks(bookmarks) {
537    if (!bookmarks.length) {
538      return;
539    }
540
541    let guidToLMMap = new Map(bookmarks.map(b => [b.guid, b.lastModified]));
542    let bookmarksFromDB = [];
543    let bmPromises = Array.from(guidToLMMap.keys()).map(guid => {
544      // Ignore bookmarks where the promise doesn't resolve (ie that are missing)
545      // Also check that the bookmark fetch returns isn't null before adding it.
546      try {
547        return PlacesUtils.bookmarks.fetch(guid).then(bm => bm && bookmarksFromDB.push(bm), () => {});
548      } catch (ex) {
549        // Ignore immediate exceptions, too.
550      }
551      return Promise.resolve();
552    });
553    // We can't use the result of Promise.all because that would include nulls
554    // for bookmarks that no longer exist (which we're catching above).
555    await Promise.all(bmPromises);
556    let unchangedBookmarks = bookmarksFromDB.filter(bm => {
557      return bm.lastModified.getTime() == guidToLMMap.get(bm.guid).getTime();
558    });
559
560    // We need to remove items without children first, followed by their
561    // parents, etc. In order to do this, find out how many ancestors each item
562    // has that also appear in our list of things to remove, and sort the items
563    // by those numbers. This ensures that children are always removed before
564    // their parents.
565    function determineAncestorCount(bm) {
566      if (bm._ancestorCount) {
567        return bm._ancestorCount;
568      }
569      let myCount = 0;
570      let parentBM = unchangedBookmarks.find(item => item.guid == bm.parentGuid);
571      if (parentBM) {
572        myCount = determineAncestorCount(parentBM) + 1;
573      }
574      bm._ancestorCount = myCount;
575      return myCount;
576    }
577    unchangedBookmarks.forEach(determineAncestorCount);
578    unchangedBookmarks.sort((a, b) => b._ancestorCount - a._ancestorCount);
579    for (let {guid} of unchangedBookmarks) {
580      // Can't just use a .catch() because Bookmarks.remove() can throw (rather
581      // than returning rejected promises).
582      try {
583        await PlacesUtils.bookmarks.remove(guid, {preventRemovalOfNonEmptyFolders: true});
584      } catch (err) {
585        if (err && err.message != "Cannot remove a non-empty folder.") {
586          this._errorMap.bookmarks++;
587          Cu.reportError(err);
588        }
589      }
590    }
591  },
592
593  async _removeUnchangedLogins(logins) {
594    for (let login of logins) {
595      let foundLogins = LoginHelper.searchLoginsWithObject({guid: login.guid});
596      if (foundLogins.length) {
597        let foundLogin = foundLogins[0];
598        foundLogin.QueryInterface(Ci.nsILoginMetaInfo);
599        if (foundLogin.timePasswordChanged == login.timePasswordChanged) {
600          try {
601            Services.logins.removeLogin(foundLogin);
602          } catch (ex) {
603            Cu.reportError("Failed to remove a login for " + foundLogins.hostname);
604            Cu.reportError(ex);
605            this._errorMap.logins++;
606          }
607        }
608      }
609    }
610  },
611
612  async _removeSomeVisits(visits) {
613    for (let urlVisits of visits) {
614      let urlObj;
615      try {
616        urlObj = new URL(urlVisits.url);
617      } catch (ex) {
618        continue;
619      }
620      let visitData = {
621        url: urlObj,
622        beginDate: PlacesUtils.toDate(urlVisits.first),
623        endDate: PlacesUtils.toDate(urlVisits.last),
624        limit: urlVisits.visitCount,
625      };
626      try {
627        await PlacesUtils.history.removeVisitsByFilter(visitData);
628      } catch (ex) {
629        this._errorMap.visits++;
630        try {
631          visitData.url = visitData.url.href;
632        } catch (ignoredEx) {}
633        Cu.reportError("Failed to remove a visit: " + JSON.stringify(visitData));
634        Cu.reportError(ex);
635      }
636    }
637  },
638
639  /**
640   * Maybe open a new tab with a survey. The tab will only be opened if all of
641   * the following are true:
642   * - the 'browser.migrate.automigrate.undo-survey' pref is not empty.
643   *   It should contain the URL of the survey to open.
644   * - the 'browser.migrate.automigrate.undo-survey-locales' pref, a
645   *   comma-separated list of language codes, contains the language code
646   *   that is currently in use for the 'global' chrome pacakge (ie the
647   *   locale in which the user is currently using Firefox).
648   *   The URL will be passed through nsIURLFormatter to allow including
649   *   build ids etc. The special additional formatting variable
650   *   "%IMPORTEDBROWSER" is also replaced with the name of the browser
651   *   from which we imported data.
652   *
653   * @param {Window} chromeWindow   A reference to the window in which to open a link.
654   */
655  _maybeOpenUndoSurveyTab(chromeWindow) {
656    let canDoSurveyInLocale = false;
657    try {
658      let surveyLocales = Preferences.get(kAutoMigrateUndoSurveyLocalePref, "");
659      surveyLocales = surveyLocales.split(",").map(str => str.trim());
660      // Strip out any empty elements, so an empty pref doesn't
661      // lead to a an array with 1 empty string in it.
662      surveyLocales = new Set(surveyLocales.filter(str => !!str));
663      canDoSurveyInLocale =
664        surveyLocales.has(Services.locale.getAppLocaleAsLangTag());
665    } catch (ex) {
666      /* ignore exceptions and just don't do the survey. */
667    }
668
669    let migrationBrowser = this.getBrowserUsedForMigration();
670    let rawURL = Preferences.get(kAutoMigrateUndoSurveyPref, "");
671    if (!canDoSurveyInLocale || !migrationBrowser || !rawURL) {
672      return;
673    }
674
675    let url = Services.urlFormatter.formatURL(rawURL);
676    url = url.replace("%IMPORTEDBROWSER%", encodeURIComponent(migrationBrowser));
677    chromeWindow.openUILinkIn(url, "tab");
678  },
679
680  QueryInterface: XPCOMUtils.generateQI(
681    [Ci.nsIObserver, Ci.nsINavBookmarkObserver, Ci.nsISupportsWeakReference]
682  ),
683
684  /**
685   * Undo action called by the UndoNotification or by the newtab
686   * @param chromeWindow A reference to the window in which to open a link.
687   */
688  undoAutoMigration(chromeWindow) {
689    this._maybeOpenUndoSurveyTab(chromeWindow);
690    this.undo();
691  },
692
693  /**
694   * Keep the automigration result and not prompt anymore
695   */
696  keepAutoMigration() {
697    this._purgeUndoState(this.UNDO_REMOVED_REASON_OFFER_REJECTED);
698  },
699};
700
701AutoMigrate.init();
702