1374215
[gecko.git] / 
blob1374215201b7064f3866b5444fedd62464fee757
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/. */
5 /**
6  * This module exports the TabsSetupFlowManager singleton, which manages the state and
7  * diverse inputs which drive the Firefox View synced tabs setup flow
8  */
10 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
12 const lazy = {};
14 ChromeUtils.defineESModuleGetters(lazy, {
15   Log: "resource://gre/modules/Log.sys.mjs",
16   SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
17   SyncedTabsErrorHandler:
18     "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs",
19   UIState: "resource://services-sync/UIState.sys.mjs",
20 });
22 ChromeUtils.defineLazyGetter(lazy, "syncUtils", () => {
23   return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs")
24     .Utils;
25 });
27 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
28   return ChromeUtils.importESModule(
29     "resource://gre/modules/FxAccounts.sys.mjs"
30   ).getFxAccountsSingleton();
31 });
33 const SYNC_TABS_PREF = "services.sync.engine.tabs";
34 const TOPIC_TABS_CHANGED = "services.sync.tabs.changed";
35 const LOGGING_PREF = "browser.tabs.firefox-view.logLevel";
36 const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed";
37 const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed";
38 const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated";
39 const NETWORK_STATUS_CHANGED = "network:offline-status-changed";
40 const SYNC_SERVICE_ERROR = "weave:service:sync:error";
41 const FXA_DEVICE_CONNECTED = "fxaccounts:device_connected";
42 const FXA_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected";
43 const SYNC_SERVICE_FINISHED = "weave:service:sync:finish";
44 const PRIMARY_PASSWORD_UNLOCKED = "passwordmgr-crypto-login";
46 function openTabInWindow(window, url) {
47   const { switchToTabHavingURI } =
48     window.docShell.chromeEventHandler.ownerGlobal;
49   switchToTabHavingURI(url, true, {});
52 export const TabsSetupFlowManager = new (class {
53   constructor() {
54     this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]);
56     this.setupState = new Map();
57     this.resetInternalState();
58     this._currentSetupStateName = "";
59     this.syncIsConnected = lazy.UIState.get().syncEnabled;
60     this.didFxaTabOpen = false;
62     this.registerSetupState({
63       uiStateIndex: 0,
64       name: "error-state",
65       exitConditions: () => {
66         return lazy.SyncedTabsErrorHandler.isSyncReady();
67       },
68     });
69     this.registerSetupState({
70       uiStateIndex: 1,
71       name: "not-signed-in",
72       exitConditions: () => {
73         return this.fxaSignedIn;
74       },
75     });
76     this.registerSetupState({
77       uiStateIndex: 2,
78       name: "connect-secondary-device",
79       exitConditions: () => {
80         return this.secondaryDeviceConnected;
81       },
82     });
83     this.registerSetupState({
84       uiStateIndex: 3,
85       name: "disabled-tab-sync",
86       exitConditions: () => {
87         return this.syncTabsPrefEnabled;
88       },
89     });
90     this.registerSetupState({
91       uiStateIndex: 4,
92       name: "synced-tabs-loaded",
93       exitConditions: () => {
94         // This is the end state
95         return false;
96       },
97     });
99     Services.obs.addObserver(this, lazy.UIState.ON_UPDATE);
100     Services.obs.addObserver(this, TOPIC_DEVICELIST_UPDATED);
101     Services.obs.addObserver(this, NETWORK_STATUS_CHANGED);
102     Services.obs.addObserver(this, SYNC_SERVICE_ERROR);
103     Services.obs.addObserver(this, SYNC_SERVICE_FINISHED);
104     Services.obs.addObserver(this, TOPIC_TABS_CHANGED);
105     Services.obs.addObserver(this, PRIMARY_PASSWORD_UNLOCKED);
106     Services.obs.addObserver(this, FXA_DEVICE_CONNECTED);
107     Services.obs.addObserver(this, FXA_DEVICE_DISCONNECTED);
109     // this.syncTabsPrefEnabled will track the value of the tabs pref
110     XPCOMUtils.defineLazyPreferenceGetter(
111       this,
112       "syncTabsPrefEnabled",
113       SYNC_TABS_PREF,
114       false,
115       () => {
116         this.maybeUpdateUI(true);
117       }
118     );
120     this._lastFxASignedIn = this.fxaSignedIn;
121     this.logger.debug(
122       "TabsSetupFlowManager constructor, fxaSignedIn:",
123       this._lastFxASignedIn
124     );
125     this.onSignedInChange();
126   }
128   resetInternalState() {
129     // assign initial values for all the managed internal properties
130     delete this._lastFxASignedIn;
131     this._currentSetupStateName = "not-signed-in";
132     this._shouldShowSuccessConfirmation = false;
133     this._didShowMobilePromo = false;
134     this.abortWaitingForTabs();
136     Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED);
138     // keep track of what is connected so we can respond to changes
139     this._deviceStateSnapshot = {
140       mobileDeviceConnected: this.mobileDeviceConnected,
141       secondaryDeviceConnected: this.secondaryDeviceConnected,
142     };
143     // keep track of tab-pickup-container instance visibilities
144     this._viewVisibilityStates = new Map();
145   }
147   get isPrimaryPasswordLocked() {
148     return lazy.syncUtils.mpLocked();
149   }
151   uninit() {
152     Services.obs.removeObserver(this, lazy.UIState.ON_UPDATE);
153     Services.obs.removeObserver(this, TOPIC_DEVICELIST_UPDATED);
154     Services.obs.removeObserver(this, NETWORK_STATUS_CHANGED);
155     Services.obs.removeObserver(this, SYNC_SERVICE_ERROR);
156     Services.obs.removeObserver(this, SYNC_SERVICE_FINISHED);
157     Services.obs.removeObserver(this, TOPIC_TABS_CHANGED);
158     Services.obs.removeObserver(this, PRIMARY_PASSWORD_UNLOCKED);
159     Services.obs.removeObserver(this, FXA_DEVICE_CONNECTED);
160     Services.obs.removeObserver(this, FXA_DEVICE_DISCONNECTED);
161   }
162   get hasVisibleViews() {
163     return Array.from(this._viewVisibilityStates.values()).reduce(
164       (hasVisible, visibility) => {
165         return hasVisible || visibility == "visible";
166       },
167       false
168     );
169   }
170   get currentSetupState() {
171     return this.setupState.get(this._currentSetupStateName);
172   }
173   get isTabSyncSetupComplete() {
174     return this.currentSetupState.uiStateIndex >= 4;
175   }
176   get uiStateIndex() {
177     return this.currentSetupState.uiStateIndex;
178   }
179   get fxaSignedIn() {
180     let { UIState } = lazy;
181     let syncState = UIState.get();
182     return (
183       UIState.isReady() &&
184       syncState.status === UIState.STATUS_SIGNED_IN &&
185       // syncEnabled just checks the "services.sync.username" pref has a value
186       syncState.syncEnabled
187     );
188   }
190   get secondaryDeviceConnected() {
191     if (!this.fxaSignedIn) {
192       return false;
193     }
194     let recentDevices = lazy.fxAccounts.device?.recentDeviceList?.length;
195     return recentDevices > 1;
196   }
197   get mobileDeviceConnected() {
198     if (!this.fxaSignedIn) {
199       return false;
200     }
201     let mobileClients = lazy.fxAccounts.device.recentDeviceList?.filter(
202       device => device.type == "mobile" || device.type == "tablet"
203     );
204     return mobileClients?.length > 0;
205   }
206   get shouldShowMobilePromo() {
207     return (
208       this.syncIsConnected &&
209       this.fxaSignedIn &&
210       this.currentSetupState.uiStateIndex >= 4 &&
211       !this.mobileDeviceConnected &&
212       !this.mobilePromoDismissedPref
213     );
214   }
215   get shouldShowMobileConnectedSuccess() {
216     return (
217       this.currentSetupState.uiStateIndex >= 3 &&
218       this._shouldShowSuccessConfirmation &&
219       this.mobileDeviceConnected
220     );
221   }
222   get logger() {
223     if (!this._log) {
224       let setupLog = lazy.Log.repository.getLogger("FirefoxView.TabsSetup");
225       setupLog.manageLevelFromPref(LOGGING_PREF);
226       setupLog.addAppender(
227         new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter())
228       );
229       this._log = setupLog;
230     }
231     return this._log;
232   }
234   registerSetupState(state) {
235     this.setupState.set(state.name, state);
236   }
238   async observe(subject, topic, data) {
239     switch (topic) {
240       case lazy.UIState.ON_UPDATE:
241         this.logger.debug("Handling UIState update");
242         this.syncIsConnected = lazy.UIState.get().syncEnabled;
243         if (this._lastFxASignedIn !== this.fxaSignedIn) {
244           this.onSignedInChange();
245         } else {
246           await this.maybeUpdateUI();
247         }
248         this._lastFxASignedIn = this.fxaSignedIn;
249         break;
250       case TOPIC_DEVICELIST_UPDATED:
251         this.logger.debug("Handling observer notification:", topic, data);
252         const { deviceStateChanged, deviceAdded } = await this.refreshDevices();
253         if (deviceStateChanged) {
254           await this.maybeUpdateUI(true);
255         }
256         if (deviceAdded && this.secondaryDeviceConnected) {
257           this.logger.debug("device was added");
258           this._deviceAddedResultsNeverSeen = true;
259           if (this.hasVisibleViews) {
260             this.startWaitingForNewDeviceTabs();
261           }
262         }
263         break;
264       case FXA_DEVICE_CONNECTED:
265       case FXA_DEVICE_DISCONNECTED:
266         await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true });
267         await this.maybeUpdateUI(true);
268         break;
269       case SYNC_SERVICE_ERROR:
270         this.logger.debug(`Handling ${SYNC_SERVICE_ERROR}`);
271         if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) {
272           this.abortWaitingForTabs();
273           await this.maybeUpdateUI(true);
274         }
275         break;
276       case NETWORK_STATUS_CHANGED:
277         this.abortWaitingForTabs();
278         await this.maybeUpdateUI(true);
279         break;
280       case SYNC_SERVICE_FINISHED:
281         this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`);
282         // We intentionally leave any empty-tabs timestamp
283         // as we may be still waiting for a sync that delivers some tabs
284         this._waitingForNextTabSync = false;
285         await this.maybeUpdateUI(true);
286         break;
287       case TOPIC_TABS_CHANGED:
288         this.stopWaitingForTabs();
289         break;
290       case PRIMARY_PASSWORD_UNLOCKED:
291         this.logger.debug(`Handling ${PRIMARY_PASSWORD_UNLOCKED}`);
292         this.tryToClearError();
293         break;
294     }
295   }
297   updateViewVisibility(instanceId, visibility) {
298     const wasVisible = this.hasVisibleViews;
299     this.logger.debug(
300       `updateViewVisibility for instance: ${instanceId}, visibility: ${visibility}`
301     );
302     if (visibility == "unloaded") {
303       this._viewVisibilityStates.delete(instanceId);
304     } else {
305       this._viewVisibilityStates.set(instanceId, visibility);
306     }
307     const isVisible = this.hasVisibleViews;
308     if (isVisible && !wasVisible) {
309       // If we're already timing waiting for tabs from a newly-added device
310       // we might be able to stop
311       if (this._noTabsVisibleFromAddedDeviceTimestamp) {
312         return this.stopWaitingForNewDeviceTabs();
313       }
314       if (this._deviceAddedResultsNeverSeen) {
315         // If this is the first time a view has been visible since a device was added
316         // we may want to start the empty-tabs visible timer
317         return this.startWaitingForNewDeviceTabs();
318       }
319     }
320     if (!isVisible) {
321       this.logger.debug(
322         "Resetting timestamp and tabs pending flags as there are no visible views"
323       );
324       // if there's no view visible, we're not really waiting anymore
325       this.abortWaitingForTabs();
326     }
327     return null;
328   }
330   get waitingForTabs() {
331     return (
332       // signed in & at least 1 other device is syncing indicates there's something to wait for
333       this.secondaryDeviceConnected && this._waitingForNextTabSync
334     );
335   }
337   abortWaitingForTabs() {
338     this._waitingForNextTabSync = false;
339     // also clear out the device-added / tabs pending flags
340     this._noTabsVisibleFromAddedDeviceTimestamp = 0;
341     this._deviceAddedResultsNeverSeen = false;
342   }
344   startWaitingForTabs() {
345     if (!this._waitingForNextTabSync) {
346       this._waitingForNextTabSync = true;
347       Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
348     }
349   }
351   async stopWaitingForTabs() {
352     const wasWaiting = this.waitingForTabs;
353     if (this.hasVisibleViews && this._deviceAddedResultsNeverSeen) {
354       await this.stopWaitingForNewDeviceTabs();
355     }
356     this._waitingForNextTabSync = false;
357     if (wasWaiting) {
358       Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
359     }
360   }
362   async onSignedInChange() {
363     this.logger.debug("onSignedInChange, fxaSignedIn:", this.fxaSignedIn);
364     // update UI to make the state change
365     await this.maybeUpdateUI(true);
366     if (!this.fxaSignedIn) {
367       // As we just signed out, ensure the waiting flag is reset for next time around
368       this.abortWaitingForTabs();
369       return;
370     }
372     // Now we need to figure out if we have recently synced tabs to show
373     // Or, if we are going to need to trigger a tab sync for them
374     const recentTabs = await lazy.SyncedTabs.getRecentTabs(50);
376     if (!this.fxaSignedIn) {
377       // We got signed-out in the meantime. We should get an ON_UPDATE which will put us
378       // back in the right state, so we just do nothing here
379       return;
380     }
382     // When SyncedTabs has resolved the getRecentTabs promise,
383     // we also know we can update devices-related internal state
384     const { deviceStateChanged } = await this.refreshDevices();
385     if (deviceStateChanged) {
386       this.logger.debug(
387         "onSignedInChange, after refreshDevices, calling maybeUpdateUI"
388       );
389       // give the UI an opportunity to update as secondaryDeviceConnected or
390       // mobileDeviceConnected have changed value
391       await this.maybeUpdateUI(true);
392     }
394     // If we can't get recent tabs, we need to trigger a request for them
395     const tabSyncNeeded = !recentTabs?.length;
396     this.logger.debug("onSignedInChange, tabSyncNeeded:", tabSyncNeeded);
398     if (tabSyncNeeded) {
399       this.startWaitingForTabs();
400       this.logger.debug(
401         "isPrimaryPasswordLocked:",
402         this.isPrimaryPasswordLocked
403       );
404       this.logger.debug("onSignedInChange, no recentTabs, calling syncTabs");
405       // If the syncTabs call rejects or resolves false we need to clear the waiting
406       // flag and update UI
407       this.syncTabs()
408         .catch(ex => {
409           this.logger.debug("onSignedInChange, syncTabs rejected:", ex);
410           this.stopWaitingForTabs();
411         })
412         .then(willSync => {
413           if (!willSync) {
414             this.logger.debug("onSignedInChange, no tab sync expected");
415             this.stopWaitingForTabs();
416           }
417         });
418     }
419   }
421   async startWaitingForNewDeviceTabs() {
422     // if we're already waiting for tabs, don't reset
423     if (this._noTabsVisibleFromAddedDeviceTimestamp) {
424       return;
425     }
427     // take a timestamp whenever the latest device is added and we have 0 tabs to show,
428     // allowing us to track how long we show an empty list after a new device is added
429     const hasRecentTabs = (await lazy.SyncedTabs.getRecentTabs(1)).length;
430     if (this.hasVisibleViews && !hasRecentTabs) {
431       this._noTabsVisibleFromAddedDeviceTimestamp = Date.now();
432       this.logger.debug(
433         "New device added with 0 synced tabs to show, storing timestamp:",
434         this._noTabsVisibleFromAddedDeviceTimestamp
435       );
436     }
437   }
439   async stopWaitingForNewDeviceTabs() {
440     if (!this._noTabsVisibleFromAddedDeviceTimestamp) {
441       return;
442     }
443     const recentTabs = await lazy.SyncedTabs.getRecentTabs(1);
444     if (recentTabs.length) {
445       // We have been waiting for > 0 tabs after a newly-added device, record
446       // the time elapsed
447       const elapsed = Date.now() - this._noTabsVisibleFromAddedDeviceTimestamp;
448       this.logger.debug(
449         "stopWaitingForTabs, resetting _noTabsVisibleFromAddedDeviceTimestamp and recording telemetry:",
450         Math.round(elapsed / 1000)
451       );
452       this._noTabsVisibleFromAddedDeviceTimestamp = 0;
453       this._deviceAddedResultsNeverSeen = false;
454     } else {
455       // we are still waiting for some tabs to show...
456       this.logger.debug(
457         "stopWaitingForTabs: Still no recent tabs, we are still waiting"
458       );
459     }
460   }
462   async refreshDevices() {
463     // If current device not found in recent device list, refresh device list
464     if (
465       !lazy.fxAccounts.device.recentDeviceList?.some(
466         device => device.isCurrentDevice
467       )
468     ) {
469       await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true });
470     }
472     // compare new values to the previous values
473     const mobileDeviceConnected = this.mobileDeviceConnected;
474     const secondaryDeviceConnected = this.secondaryDeviceConnected;
475     const oldDevicesCount = this._deviceStateSnapshot?.devicesCount ?? 0;
476     const devicesCount = lazy.fxAccounts.device?.recentDeviceList?.length ?? 0;
478     this.logger.debug(
479       `refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `,
480       `secondaryDeviceConnected: ${secondaryDeviceConnected}`
481     );
483     let deviceStateChanged =
484       this._deviceStateSnapshot.mobileDeviceConnected !=
485         mobileDeviceConnected ||
486       this._deviceStateSnapshot.secondaryDeviceConnected !=
487         secondaryDeviceConnected;
488     if (
489       mobileDeviceConnected &&
490       !this._deviceStateSnapshot.mobileDeviceConnected
491     ) {
492       // a mobile device was added, show success if we previously showed the promo
493       this._shouldShowSuccessConfirmation = this._didShowMobilePromo;
494     } else if (
495       !mobileDeviceConnected &&
496       this._deviceStateSnapshot.mobileDeviceConnected
497     ) {
498       // no mobile device connected now, reset
499       this._shouldShowSuccessConfirmation = false;
500     }
501     this._deviceStateSnapshot = {
502       mobileDeviceConnected,
503       secondaryDeviceConnected,
504       devicesCount,
505     };
506     if (deviceStateChanged) {
507       this.logger.debug("refreshDevices: device state did change");
508       if (!secondaryDeviceConnected) {
509         this.logger.debug(
510           "We lost a device, now claim sync hasn't worked before."
511         );
512         Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED);
513       }
514     } else {
515       this.logger.debug("refreshDevices: no device state change");
516     }
517     return {
518       deviceStateChanged,
519       deviceAdded: oldDevicesCount < devicesCount,
520     };
521   }
523   async maybeUpdateUI(forceUpdate = false) {
524     let nextSetupStateName = this._currentSetupStateName;
525     let errorState = null;
526     let stateChanged = false;
528     // state transition conditions
529     for (let state of this.setupState.values()) {
530       nextSetupStateName = state.name;
531       if (!state.exitConditions()) {
532         this.logger.debug(
533           "maybeUpdateUI, conditions not met to exit state: ",
534           nextSetupStateName
535         );
536         break;
537       }
538     }
540     let setupState = this.currentSetupState;
541     const state = this.setupState.get(nextSetupStateName);
542     const uiStateIndex = state.uiStateIndex;
544     if (
545       uiStateIndex == 0 ||
546       nextSetupStateName != this._currentSetupStateName
547     ) {
548       setupState = state;
549       this._currentSetupStateName = nextSetupStateName;
550       stateChanged = true;
551     }
552     this.logger.debug(
553       "maybeUpdateUI, will notify update?:",
554       stateChanged,
555       forceUpdate
556     );
557     if (stateChanged || forceUpdate) {
558       if (this.shouldShowMobilePromo) {
559         this._didShowMobilePromo = true;
560       }
561       if (uiStateIndex == 0) {
562         // Use idleDispatch() to give observers a chance to resolve before
563         // determining the new state.
564         errorState = await new Promise(resolve => {
565           ChromeUtils.idleDispatch(() => {
566             resolve(lazy.SyncedTabsErrorHandler.getErrorType());
567           });
568         });
569         this.logger.debug("maybeUpdateUI, in error state:", errorState);
570       }
571       Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED, errorState);
572     }
573     if ("function" == typeof setupState.enter) {
574       setupState.enter();
575     }
576   }
578   async openFxASignup(window) {
579     if (!(await lazy.fxAccounts.constructor.canConnectAccount())) {
580       return;
581     }
582     const url =
583       await lazy.fxAccounts.constructor.config.promiseConnectAccountURI(
584         "fx-view"
585       );
586     this.didFxaTabOpen = true;
587     openTabInWindow(window, url, true);
588   }
590   async openFxAPairDevice(window) {
591     const url = await lazy.fxAccounts.constructor.config.promisePairingURI({
592       entrypoint: "fx-view",
593     });
594     this.didFxaTabOpen = true;
595     openTabInWindow(window, url, true);
596   }
598   syncOpenTabs() {
599     // Flip the pref on.
600     // The observer should trigger re-evaluating state and advance to next step
601     Services.prefs.setBoolPref(SYNC_TABS_PREF, true);
602   }
604   async syncOnPageReload() {
605     if (lazy.UIState.isReady() && this.fxaSignedIn) {
606       this.startWaitingForTabs();
607       await this.syncTabs(true);
608     }
609   }
611   tryToClearError() {
612     if (lazy.UIState.isReady() && this.fxaSignedIn) {
613       this.startWaitingForTabs();
614       if (this.isPrimaryPasswordLocked) {
615         lazy.syncUtils.ensureMPUnlocked();
616       }
617       this.logger.debug("tryToClearError: triggering new tab sync");
618       this.syncTabs();
619       Services.tm.dispatchToMainThread(() => {});
620     } else {
621       this.logger.debug(
622         `tryToClearError: unable to sync, isReady: ${lazy.UIState.isReady()}, fxaSignedIn: ${
623           this.fxaSignedIn
624         }`
625       );
626     }
627   }
628   // For easy overriding in tests
629   syncTabs(force = false) {
630     return lazy.SyncedTabs.syncTabs(force);
631   }
632 })();