/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 'use strict'; /* global asyncStorage */ /* global BaseModule */ /* global ERROR_INVALID_SYNC_ACCOUNT */ /* global ERROR_NO_KEY_FETCH_TOKEN */ /* global ERROR_REQUEST_SYNC_REGISTRATION */ /* global ERROR_SYNC_APP_GENERIC */ /* global ERROR_SYNC_APP_KILLED */ /* global ERROR_SYNC_REQUEST */ /* global ERROR_UNVERIFIED_ACCOUNT */ /* global FxAccountsClient */ /* global IACHandler */ /* global LazyLoader */ /* global Service */ /* global SyncErrors */ /* global SyncRecoverableErrors */ /* global Telemetry */ /* global uuid */ /** * This module manages the lifecycle of Firefox Sync on Firefox OS. * It connects the Settings application where the Firefox Sync UI is presented * to the user with the Sync app that is responsible of doing the actual * synchronization of the user's data. * * SyncManager reacts to changes done on the Settings Sync UI that triggers * IAC requests or Settings API changes and translates these changes into * actions. It makes use of the SyncStateMachine to control that these actions * are possible on the current situation of Sync and communicates with the * Sync application via IAC API messages when needed. * * The IAC based API that is exposed to the Settings app (or any other * future consumer that requires to manage Sync) expects IAC messages with * a single property 'name' that can have any of the following values: * 'enable': requests to enable Sync. * 'disable': requests to disable Sync. * 'sync': requests a sync on demand. Although we have scheduled * synchronizations, we allow on demand sync requests. * 'getInfo': gets the current information about the state of Sync * including the state (enabled, disabled, etc.), the * last time a successful sync happened and the last error * if there's any. * * It also triggers one message through the same IAC API with name * 'onsyncchange' when something (state, error, lastSync) changes on Sync. * * SyncManager also communicates with the Sync app via IAC. Every time a * synchronization is required (either because the requestSync timer fires or * because the user requested a sync on demand), we wake the Sync app up and * give it the required information to perform the synchronization that * includes the URL of the Syncto server, a valid FxA assertion and its * associated encryption keys and the list of collections to be synchronized. */ (function(exports) { const FXA_EVENT = 'mozFxAccountsUnsolChromeEvent'; const SYNC_TASK = 'gaia::system::firefoxsync'; const SYNC_REQUEST_IAC_KEYWORD = 'gaia::sync::request'; const SYNC_MANAGEMENT_API_IAC_KEYWORD = 'gaia-sync-management'; const COLLECTIONS = ['history', 'passwords', 'bookmarks']; // Keys of asyncStorage persisted data. const SYNC_STATE = 'sync.state'; const SYNC_STATE_ERROR = 'sync.state.error'; const SYNC_LAST_TIME = 'sync.lastTime'; const SYNC_USER = 'sync.user'; var SyncManager = function() {}; SyncManager.STATES = []; SyncManager.SERVICES = []; SyncManager.SETTINGS = [ /** READ ONLY SETTINGS **/ // The user can choose the collections she wants to sync. These settings // indicates the user choice per collection. 'sync.collections.history.enabled', 'sync.collections.passwords.enabled', 'sync.collections.bookmarks.enabled', // Setting any of these two settings to true will make the synchronization // of the collection readonly. That means that we will only be retrieving // data from Firefox Sync and we won't be pushing any of the local // modifications to the collections source. 'sync.collections.history.readonly', 'sync.collections.passwords.readonly', 'sync.collections.bookmarks.readonly', // Limit the number of records fetched from the server in a single request. // Once the sync engine supports pagination, the limit will be the page // size. 'sync.collections.history.limit', 'sync.collections.passwords.limit', 'sync.collections.bookmarks.limit', 'sync.server.url', 'sync.scheduler.interval', 'sync.scheduler.wifionly', 'sync.fxa.audience' ]; SyncManager.EVENTS = [ /** IAC based sync management API **/ 'iac-' + SYNC_MANAGEMENT_API_IAC_KEYWORD, /** Sync State Machine events **/ 'onsyncdisabled', 'onsyncdisabling', 'onsyncenabled', 'onsyncenabling', 'onsyncerrored', 'onsyncsyncing', /** Sync app lifecycle **/ 'appterminated' ]; SyncManager.SUB_MODULES = [ 'SyncStateMachine' ]; BaseModule.create(SyncManager, { name: 'SyncManager', DEBUG: false, EVENT_PREFIX: 'sync', store: new Map(), /** BaseModule life cycle **/ _start: function() { return new Promise((resolve, reject) => { LazyLoader.load('/shared/js/sync/errors.js', () => { navigator.mozSetMessageHandler('request-sync', event => { this.debug('Sync request'); if (event.task != SYNC_TASK) { return; } Service.request('SyncStateMachine:sync'); }); this.initStore().then(() => { this.debug('Sync.state setting initial value', this.state); // SyncStateMachine starts by default with 'disabled', so we can // request to enable sync or stay in the disabled state. // We need to make sure that we didn't end up in an inconsistent // state during the latest run (i.e. error, success, syncing, // enabling, disabling). switch(this.state) { case 'enabled': case 'success': case 'syncing': Service.request('SyncStateMachine:enable').then(resolve); break; case 'disabling': case 'enabling': this.updateState('disabled'); resolve(); break; case 'errored': if (this.error == ERROR_UNVERIFIED_ACCOUNT) { // We try to enable Sync. This will check if the account is // verified or not. If it's not verified, we go back to the // errored state, otherwise, the user logs in. Service.request('SyncStateMachine:enable').then(resolve); break; } this.updateState('disabled'); resolve(); break; case 'disabled': resolve(); break; default: console.error('Unexpected sync.state value', this.state); reject(); break; } }); }); }); }, _stop: function() {}, /** Event handlers **/ // We expose a very simple IAC based API to allow the Settings app to // manage Sync. // // We expect IAC messages to contain a property named 'name' with any // of the following values corresponding to the requests that we are // able to handle: // - enable // - disable // - sync // - getInfo // // SyncStateMachine is responsible of checking if the request is valid in // the current situation and progressing it accordingly by publishing the // corresponding events. '_handle_iac-gaia-sync-management': function(event) { if (!event || !event.detail) { console.error('Wrong IAC Sync event'); return; } var request = event.detail; switch(request.name) { case 'enable': case 'disable': case 'sync': try { Service.request('SyncStateMachine:' + request.name).then(() => { return LazyLoader.load('/shared/js/sync/telemetry.js'); }).then(() => { Telemetry.logUserAction(request.name); }).catch(e => { console.error(e); }); } catch(e) { console.error(e); } break; case 'getInfo': if (!request.id) { console.warn('IAC request ignored. Missing id'); return; } this.getAccount().then(() => { this.managementPortMessage({ id: request.id, state: this.state, error: this.error, lastSync: this.lastSync, user: this.user }); }); break; default: console.warn('Unknown IAC Sync request', request.name); break; } }, /** SyncStateMachine event handlers **/ _handle_onsyncdisabled: function() { this.debug('onsyncdisabled observed'); this.updateState(); this.user = null; this.lastSync = null; this.error = null; this.cleanup(); }, _handle_onsyncdisabling: function() { this.debug('onsyncdisabling observed'); this.updateState(); // Go back to default settings, so new users won't inherit old users' // preferences. this.saveUserSettings().then(() => { return this.restoreDefaultSettings(); }).then(() => { Service.request('SyncStateMachine:success'); }); }, _handle_onsyncenabled: function(event) { this.debug('onsyncenabled observed'); this.updateState(); // If we got to this point with no user, something went wrong, so we // need to disable Sync. if (!this.user) { this.debug('No user'); Service.request('SyncStateMachine:disable'); return; } this.registerSyncRequest().then(() => { this.debug('Sync request registered'); var from = event.detail && event.detail.from; // After login in with a new account or rebooting the device, // we trigger a sync request. if (from === 'enabling') { Service.request('SyncStateMachine:sync'); } }).catch(error => { console.error('Could not register sync request', error); Service.request('SyncStateMachine:error', ERROR_REQUEST_SYNC_REGISTRATION); }); }, _handle_onsyncenabling: function() { this.debug('onsyncenabling observed'); this.updateState(); // Because we need to bind this to the event handler, we need to save // a reference to it so we can properly remove it later. this.fxaEventHandler = this._handle_fxaEvent.bind(this); window.addEventListener(FXA_EVENT, this.fxaEventHandler); // We need to verify that we have everything that we need to start // synchronizing data. This is: a valid FxA, the ability to obtain // a valid assertion and encryption keys and the remote crypto/keys // record. var args = []; this.updateStateDeferred.then(() => { return this.getAssertion(); }).then(assertion =>{ args.push(assertion); return this.getKeys(); }).then(keys => { args.push(keys); return this.getAccount(); }).then(() => { // We request a sync without any collection. This should fetch the // crypto/keys object. If it is available, we will successfully // continue the enabling process. If it is not available, that probably // means that this FxA has never been used before with Sync. // In that case, until we are able to create new Sync user, we need // to disable Sync and let the user know about this situation. return this.trySync.apply(this, args); }).then(() => { // We save default settings so we can revert user changes to the // default values when the user logs out from Sync. So new users don't // inherit old users' preferences. return this.saveDefaultSettings(); }).then(() => { return this.restoreUserSettings(); }).then(() => { Service.request('SyncStateMachine:success'); }).catch(error => { // XXX Bug 1200284 - Normalize all Firefox Accounts error reporting. error = error.message || error.name || error.error || error; console.error('Could not enable sync', error); if (error == 'UNVERIFIED_ACCOUNT') { this.getAccount().then(() => { Service.request('SyncStateMachine:error', ERROR_UNVERIFIED_ACCOUNT); }); return; } error = SyncErrors[error] || error; Service.request('SyncStateMachine:error', error); }); }, _handle_onsyncerrored: function(event) { this.debug('onsyncerrored observed'); var error = event.detail && Array.isArray(event.detail.args) ? event.detail.args[0] : null; if (!error) { return; } this.error = error; // We don't update the state until we set the error. this.updateState(); var from = event.detail && event.detail.from; // There are some cases where we want to show to the user // the enabled state but warn her about the invalid state of her // account. So we stay in the errored state. if ([ERROR_UNVERIFIED_ACCOUNT].indexOf(error) > -1) { return; } // If the error is recoverable and we are coming from the syncing // state, we go back to the enabled state, otherwise, we disable Sync. if ((SyncRecoverableErrors.indexOf(error) > -1 && from === 'syncing') || // XXX ERROR_INVALID_SYNC_ACCOUNT is an unrecoverable error, but we // temporarily treat it in a different way depending on the device. // On TVs, we show a dialog asking the user to go to Desktop or // Android to create an account and we move to the disabled state. // On phones, we move to the enabled state, but we show a warning to // the user about her account being empty. error == ERROR_INVALID_SYNC_ACCOUNT) { Service.request('SyncStateMachine:enable'); return; } // If the current FxA login does not contain the token to fetch sync keys // we need to log the user out. Unfortunately the FxA auth server does not // allow to obtain this token outside of the initial sign in flow. if (error == ERROR_NO_KEY_FETCH_TOKEN) { // No need to disable Sync here as we will be doing it as soon as the // logout is done. FxAccountsClient.logout(); return; } this.debug('Unrecoverable error'); Service.request('SyncStateMachine:disable'); }, _handle_onsyncsyncing: function() { this.debug('onsyncsyncing observed'); this.updateState(); // In case that we are syncing on user demand, we unregister the // sync request. // It's harmless to unregister an expired request, so it's also ok // to do it for a periodic sync. this.unregisterSyncRequest(); if (!navigator.onLine) { Service.request('SyncStateMachine:success'); return; } var collections = {}; COLLECTIONS.forEach(name => { if (this._settings['sync.collections.' + name + '.enabled']) { collections[name] = { readonly: this._settings['sync.collections.' + name + '.readonly'], limit: this._settings['sync.collections.' + name + '.limit'] }; } }); if (!Object.keys(collections).length) { Service.request('SyncStateMachine:success'); return; } var args = []; this.getAssertion().then(assertion => { args.push(assertion); return this.getKeys(); }).then(keys => { args.push(keys); args.push(collections); this.doSync.apply(this, args); }).catch(error => { console.error('Sync error', error); Service.request('SyncStateMachine:error', ERROR_SYNC_REQUEST); }); }, _handle_fxaEvent: function(event) { // We don't care about FxA events if we are already disabling. if (this.state === 'disabling') { return; } if (!event || !event.detail) { console.error('Wrong event'); return; } switch (event.detail.eventName) { case 'onlogout': // Logging out from Firefox Accounts should disable Sync. Service.request('SyncStateMachine:disable'); break; case 'onverified': // Once the user verified her account, we can continue with the // enabling procedure. if (this.state == 'errored' && this.error == ERROR_UNVERIFIED_ACCOUNT) { this.getAccount().then(() => { if (this.user) { Service.request('SyncStateMachine:enable'); } }); } break; } }, _handle_appterminated(event) { // If the Sync app is closed, we need to release the reference to // its IAC port, so we can get a new connection on the next sync // request. Unfortunately, the IAC API doesn't take care of // notifying when a port is dead. So we need to do it manually. if (!this._port) { return; } this.isSyncApp(event.detail ? event.detail.origin : null).then(() => { this._port = null; }); }, onportclose() { // The synchronizer app can be killed while it's handling a sync // request. In that case, we need to record an error and notify // the state machine about it. Otherwise we could end up on a // permanent 'syncing' state. if (this.state !== 'syncing') { return; } this.debug('Synchronizer closed before completing the sync request'); Service.request('SyncStateMachine:error', ERROR_SYNC_APP_KILLED); }, /** Helpers **/ initStore: function() { var promises = []; [SYNC_STATE, SYNC_STATE_ERROR, SYNC_LAST_TIME, SYNC_USER].forEach(key => { var promise = new Promise(resolve => { asyncStorage.getItem(key, value => { this.debug(key + ': ' + value); this.store.set(key, value); resolve(); }); }); promises.push(promise); }); return Promise.all(promises); }, updateState: function(state) { this.state = state || Service.query('SyncStateMachine.state'); }, registerSyncRequest: function() { return navigator.sync.registrations().then(registrations => { var existingReg; registrations.some(reg => { if (reg.task === SYNC_TASK) { return !!(existingReg = reg); } }); if (existingReg) { this.debug('Sync request already exists'); return Promise.resolve(); } this.debug('Registering sync request', location.href); // XXX We need to default to the settings value (3600 and false) // because of // Bug 1206004 - BaseModule._settings is not be available during // BaseModule._start var minInterval = this.DEBUG ? 120 : this._settings['sync.scheduler.interval'] || 3600; var wifiOnly = this._settings['sync.scheduler.wifionly'] || false; return navigator.sync.register(SYNC_TASK, { minInterval: minInterval, // minInterval is in seconds. oneShot: true, wifiOnly: wifiOnly, data: null, wakeUpPage: location.href }); }); }, unregisterSyncRequest: function() { navigator.sync.unregister(SYNC_TASK).catch(e => { // A failure unregistering the sync task might be expected // depending on the state we are (i.e. enabling). In any case, // we show a warning, so a bug can be identified or discarded. console.warn('Could not unregister sync task', e, Service.query('SyncStateMachine.state')); }); }, getKeys: function() { return new Promise((resolve, reject) => { LazyLoader.load('js/fx_accounts_client.js', () => { FxAccountsClient.getKeys(resolve, reject); }); }); }, getAssertion: function() { return new Promise((resolve, reject) => { LazyLoader.load('js/fx_accounts_client.js', () => { FxAccountsClient.getAssertion({ audience: this._settings['sync.fxa.audience'] }, resolve, reject); }); }); }, getAccount: function() { return new Promise((resolve, reject) => { LazyLoader.load('js/fx_accounts_client.js', () => { FxAccountsClient.getAccount(account => { this.user = account ? account.email : null; resolve(account); }, reject); }); }); }, managementPortMessage: function(message) { var port = IACHandler.getPort(SYNC_MANAGEMENT_API_IAC_KEYWORD, this); if (!port) { this.debug('No management API consumers'); // We may not have any consumers yet. So in that case // we simply bail out here. return; } port.postMessage(message); }, notifyStateChange: function() { this.managementPortMessage({ name: 'onsyncchange', state: this.state, error: this.error, lastSync: this.lastSync, user: this.user }); }, connect: function() { if (this._port) { return Promise.resolve(this._port); } return new Promise((resolve, reject) => { navigator.mozApps.getSelf().onsuccess = event => { var app = event.target.result; app.connect(SYNC_REQUEST_IAC_KEYWORD, { // For now we only allow one synchronizer app. pageURLs: 'app://sync.gaiamobile.org' }).then(ports => { if (!ports || !ports.length) { return reject(); } this._port = ports[0]; this._port.onclose = this.onportclose.bind(this); resolve(this._port); }).catch(error => { console.error(error); reject(error); }); }; }); }, iacRequest: function(request) { return new Promise((resolve, reject) => { this.connect().then(port => { LazyLoader.load('/shared/js/uuid.js', () => { var id = uuid(); request.id = id; port.postMessage(request); port.onmessage = event => { var message = event.data; if (!message || (message.id != id)) { return; } resolve(message); }; }); }); }); }, doSync: function(assertion, keys, collections) { this.debug('Syncing with', JSON.stringify(collections)); this.iacRequest({ name: 'sync', URL: this._settings['sync.server.url'], assertion: assertion, keys: keys, collections: collections }).then(result => { if (result.error) { var error = result.error; error = error.message ? error.message : error; console.error('Error while trying to sync', error); error = SyncErrors[error] || ERROR_SYNC_APP_GENERIC; Service.request('SyncStateMachine:error', error); return; } // If we made a successfull synchronization we are good and so // we don't care about past errors anymore. this.error = null; this.debug('Sync succeded'); this.lastSync = Date.now(); Service.request('SyncStateMachine:success'); }); }, trySync: function(assertion, keys) { return this.iacRequest({ name: 'sync', URL: this._settings['sync.server.url'], assertion: assertion, keys: keys, collections: {} }).then(result => { if (result && result.error) { console.error('Error trying sync', result.error.message); throw result.error.message; } }); }, cancelSync: function() { this.iacRequest({ name: 'cancel' }); }, cleanup: function() { this.unregisterSyncRequest(); window.removeEventListener(FXA_EVENT, this.fxaEventHandler); // If we disabled Sync while a sync request was in progress, we need to // cancel the request. if (this.state === 'syncing') { this.cancelSync(); } }, isSyncApp: function(origin) { if (!origin) { return Promise.reject(); } return new Promise((resolve, reject) => { navigator.mozApps.getSelf().onsuccess = event => { var app = event.target.result; app.getConnections().then(connections => { connections.forEach(connection => { if (connection.keyword != SYNC_REQUEST_IAC_KEYWORD || connection.subscriber.indexOf(origin) < 0) { return; } resolve(); }); reject(); }); }; }); }, updateStatePreference: function() { // Changing the value of this setting changes the value of // the 'services.sync.enabled' preference as well. // We don't retrieve the Sync keys unless this pref is set to true. const CHROME_EVENT = 'mozPrefChromeEvent'; return new Promise((resolve, reject) => { var onprefchange = event => { this.debug('Preference change', event.detail); if (event.detail.prefName !== 'services.sync.enabled') { return; } window.removeEventListener(CHROME_EVENT, onprefchange); resolve(); }; window.addEventListener(CHROME_EVENT, onprefchange); navigator.mozSettings.createLock().set({ 'services.sync.enabled': this.state !== 'disabled' }).onerror = reject; }); }, saveSettings(prefix) { var saveSetting = setting => { return new Promise(resolve => { asyncStorage.getItem(prefix + setting, value => { if (value !== null) { this.debug('Setting value already saved', setting); resolve(); return; } this.debug('Saving setting', prefix + setting, this._settings[setting]); asyncStorage.setItem(prefix + setting, this._settings[setting], resolve); }); }); }; var promises = []; SyncManager.SETTINGS.forEach(setting => { promises.push(saveSetting(setting)); }); return Promise.all(promises); }, saveDefaultSettings() { this.debug('Saving default settings'); return this.saveSettings(''); }, saveUserSettings() { this.debug('Saving user settings'); return this.saveSettings(this.user + '.'); }, restoreSettings(prefix) { var revertSetting = setting => { return new Promise((resolve, reject) => { asyncStorage.getItem(prefix + setting, value => { this.debug('Restoring setting ' + prefix + setting, value); if (value === null) { resolve(); return; } // We need to remove the setting from asyncStorage to allow // default setting changes within OTA updates. asyncStorage.removeItem(prefix + setting, () => { var settingObject = {}; settingObject[setting] = value; var req = navigator.mozSettings.createLock().set(settingObject); req.onerror = error => { console.error(error); reject(); }; req.onsuccess = resolve; }); }); }); }; var promises = []; SyncManager.SETTINGS.forEach(setting => { promises.push(revertSetting(setting)); }); return Promise.all(promises); }, restoreDefaultSettings() { this.debug('Restoring default settings'); return this.restoreSettings(''); }, restoreUserSettings() { this.debug('Restoring user settings'); return this.restoreSettings(this.user + '.'); } }, { state: { set: function(state) { state = state || Service.query('SyncStateMachine.state'); this.debug('Setting state', state); this.store.set(SYNC_STATE, state); this.updateStateDeferred = new Promise(resolve => { asyncStorage.setItem(SYNC_STATE, state, () => { if (state === 'disabled' || state === 'enabled' || state === 'enabling') { this.updateStatePreference().then(() => { this.notifyStateChange(); resolve(); }); return; } this.notifyStateChange(); }); }); }, get: function() { var state = this.store.get(SYNC_STATE); return state ? state : 'disabled'; } }, error: { set: function(error) { this.store.set(SYNC_STATE_ERROR, error); asyncStorage.setItem(SYNC_STATE_ERROR, error); }, get: function() { return this.store.get(SYNC_STATE_ERROR); } }, lastSync: { set: function(now) { var lastSync = now; this.store.set(SYNC_LAST_TIME, lastSync); asyncStorage.setItem(SYNC_LAST_TIME, lastSync); }, get: function() { return this.store.get(SYNC_LAST_TIME); } }, user: { set: function(user) { // We need to check if there's a user logged in during the enabled // state handler, so we cannot wait for the asyncStorage write. this.store.set(SYNC_USER, user); asyncStorage.setItem(SYNC_USER, user, () => {}, () => { // This should never happen, but if something goes wrong storing the // user we delete the user from memory and disable Sync to avoid // inconsistent states. this.store.delete(SYNC_USER); Service.request('SyncStateMachine:disable'); }); }, get: function() { return this.store.get(SYNC_USER); } } }); // Exported only for testing purposes. exports.SyncManager = SyncManager; }(window));