/* global SettingsHelper */ /* global asyncStorage */ /* global Requester */ /* global Commands */ /* global Config */ /* global DUMP */ /* global IAC_API_WAKEUP_REASON_ENABLED_CHANGED */ /* global IAC_API_WAKEUP_REASON_LOGIN */ /* global IAC_API_WAKEUP_REASON_LOGOUT */ /* global IAC_API_WAKEUP_REASON_STALE_REGISTRATION */ /* global IAC_API_WAKEUP_REASON_TRY_DISABLE */ /* global IAC_API_WAKEUP_REASON_LOCKSCREEN_CLOSED */ 'use strict'; var FindMyDevice = { _state: null, _registering: false, _refreshingClientID: false, _reply: {}, _registered: false, _registeredHelper: null, _enabled: false, _enabledHelper: null, _loggedIn: false, _currentClientID: '', _currentClientIDHelper: null, _canDisable: false, _canDisableHelper: null, _retryCount: -1, _retryCountHelper: null, _disableAttempt: false, // There are two situations in which we want to make sure // FMD is alive: // - when it is running client logic that accesses the network; // - when it is executing a command with a duration. // Command wakelocks are managed by individual commands. // For client logic wakelocks, we use the fact that when a network // request succeeds, we'll either update 'findmydevice.registered', // or 'findmydevice.can-disable', or process a command and set // a 'ping' timer, and when it fails, we'll set a timer to retry. // Since each network transaction finishes with either a change // in one of these settings or with a timer, we release the locks // in the observers for the settings and in _scheduleAlarm. _highPriorityWakeLocks: { clientLogic: [], command: [] }, _fxaReady: false, init: function fmd_init() { var self = this; navigator.mozId.watch({ wantIssuer: 'firefox-accounts', audience: Config.api_url, onready: self._onReady.bind(self), onlogin: self._onLogin.bind(self), onlogout: self._onLogout.bind(self), onerror: self._onFxAError.bind(self) }); }, _observeSettings: function fmd_observe_settings() { var settings = navigator.mozSettings; settings.addObserver('findmydevice.registered', this._onRegisteredChanged.bind(this)); settings.addObserver('findmydevice.enabled', this._onEnabledChanged.bind(this)); settings.addObserver('findmydevice.current-clientid', this._onClientIDChanged.bind(this)); // We only allow disabling Find My Device if the same person // who first enabled it is logged in to FxA, and in that case // 'findmydevice.can-disable' is true. // However, note that FMD doesn't store the user's email address, // but rather a client ID that is derived from the FxA assertion // and stored in this._state.clientid. settings.addObserver('findmydevice.can-disable', this._onCanDisableChanged.bind(this)); }, _onReady: function fmd_fxa_onready() { var self = this; this._loadState(function() { self._initSettings(self._initMessageHandlers.bind(self)); }); this._fxaReady = true; this._observeSettings(); }, _loadState: function fmd_load_state(callback) { var self = this; asyncStorage.getItem('findmydevice-state', function(state) { self._state = state; if (state) { Requester.setHawkCredentials(state.deviceid, state.secret); } callback && callback(); }); }, _initSettings: function fmd_init_settings(callback) { // for each setting we're interested in, create a SettingsHelper, // cache its initial value in a property (e.g., _enabled), and // store the SettingsHelper as another property (e.g., _enabledHelper). var settingsToProperties = { // setting name : property name for cached value 'findmydevice.enabled': '_enabled', 'findmydevice.registered': '_registered', 'findmydevice.current-clientid': '_currentClientID', 'findmydevice.can-disable': '_canDisable', 'findmydevice.retry-count': '_retryCount' }; var settings = Object.keys(settingsToProperties); var loaded = 0; var totalSettings = Object.keys(settingsToProperties).length; settings.forEach(function(s) { var prop = settingsToProperties[s]; var helper = this[prop + 'Helper'] = SettingsHelper(s); helper.get((function(value) { this[prop] = value; if (++loaded === totalSettings) { callback && callback(); } }).bind(this)); }, this); }, _initMessageHandlers: function fmd_init_message_handlers() { navigator.mozSetMessageHandler('push', (function(message) { DUMP('findmydevice got push notification!'); this._contactServer(); }).bind(this)); navigator.mozSetMessageHandler('push-register', (function(message) { DUMP('findmydevice lost push endpoint, re-registering'); this.beginHighPriority('clientLogic'); this._registeredHelper.set(false); }).bind(this)); navigator.mozSetMessageHandler('alarm', (function(alarm) { DUMP('findmydevice alarm!'); this._contactServer(); this._refreshClientIDIfRegistered(false); }).bind(this)); navigator.mozSetMessageHandler('connection', (function(request) { var port = request.port; port.onmessage = (function(event) { if (request.keyword === 'findmydevice-wakeup') { DUMP('got wake up request'); var reason = event.data; if (reason === IAC_API_WAKEUP_REASON_ENABLED_CHANGED) { DUMP('enabled state changed, trying to reach the server'); // Ensure the retry counter is reset to 0 on enable this._retryCountHelper.set(0); this._contactServer(); } else if (reason === IAC_API_WAKEUP_REASON_STALE_REGISTRATION) { DUMP('stale registration, re-registering'); this.beginHighPriority('clientLogic'); this._registeredHelper.set(false); } else if (reason === IAC_API_WAKEUP_REASON_LOGIN) { DUMP('new login, invalidating client id'); this.beginHighPriority('clientLogic'); this._loggedIn = true; this._currentClientIDHelper.set(''); } else if (reason === IAC_API_WAKEUP_REASON_LOGOUT) { DUMP('logout, invalidating client id'); this.beginHighPriority('clientLogic'); this._loggedIn = false; this._currentClientIDHelper.set(''); } else if (reason === IAC_API_WAKEUP_REASON_TRY_DISABLE) { DUMP('refreshing client id and attempting to disable'); this._disableAttempt = true; this._refreshClientIDIfRegistered(true); } else if (reason === IAC_API_WAKEUP_REASON_LOCKSCREEN_CLOSED) { DUMP('unlocked'); this._onLockscreenClosed(); } return; } if (request.keyword === 'findmydevice-test') { DUMP('got request for test command!'); this._processCommands(event.data, true); } }).bind(this); }).bind(this)); }, _contactServer: function fmd_contact_server() { this.beginHighPriority('clientLogic'); if (this._registered && this._enabled) { this._replyAndFetchCommands(); } else if (this._registered && !this._enabled) { this._reportDisabled(); } else if (!this._registered && this._enabled) { this._register(); } else { // XXX(ggp) this should never happen, but let's play // it safe and release lock we acquired DUMP('can\'t contact the server while not registered and not enabled!!'); this.endHighPriority('clientLogic'); } }, _reportDisabled: function fmd_report_disabled() { DUMP('reporting disabled'); Requester.post( this._getCommandEndpoint(), {enabled: false}, this._handleServerResponse.bind(this), this._handleServerError.bind(this)); }, _register: function fmd_register() { DUMP('findmydevice attempting registration.'); DUMP('registering: ' + this._registering); if (this._registering) { this._registering = false; this.endHighPriority('clientLogic'); return; } this._registering = true; if (this._state === null) { // We are not registered yet, so we need to give the server a FxA // assertion. Request a fresh one here and continue the registration // process from the login callback. if (!this._loggedIn) { // Can't request() while not logged in, as that would bring up the // FxA dialog. Attempting a first registration while logged out is // theoretically possible (if, for example, a first registration // failed, and this is an alarm-driven re-attempt, and the user logged // out while we slept before this attempt), but it's a very unlikely // corner case. For now, just give up if this happens, but we still // need to notify the user somehow (bug 1013423). this._registering = false; this.endHighPriority('clientLogic'); return; } navigator.mozId.request(); } else { // We don't need to send assertions on re-registrations, just rely on // our client ID and HAWK signature to authenticate us. This works even // if we're logged out of FxA, so use null for the assertion. this._continueRegistration(null); } }, _onLogin: function fmd_on_login(assertion) { DUMP('logged in to FxA'); // XXX(ggp) When initializing, we'll process FxA's automatic invocation // of onlogin/onlogout before any IAC message, so use these calls to // initialize _loggedIn. However, when FMD is already running, there's // no guaranteee that onlogin/onlogout will fire before the IAC handler, // so we have dedicated IAC messages (IAC_API_WAKEUP_REASON_LOGIN and // IAC_API_WAKEUP_REASON_LOGOUT) to cover login state changes, and we // use the handlers for these messages to update _loggedIn. this._loggedIn = true; if (this._refreshingClientID) { DUMP('resuming client id refresh'); this._fetchClientID(assertion); } if (!this._enabled || !this._registering) { return; } // We are in the middle of a registration, so continue here this._continueRegistration(assertion); }, _continueRegistration: function fmd_continue_registration(assertion) { var self = this; if (assertion == null && this._state == null) { // this shouldn't happen throw new Error('Trying to register with no assertion and no state!'); } var pushRequest = navigator.push.register(); pushRequest.onsuccess = function fmd_push_handler() { DUMP('findmydevice received push endpoint!'); var endpoint = pushRequest.result; if (self._enabled) { self._requestRegistration(assertion, endpoint); } self._registering = false; }; pushRequest.onerror = function fmd_push_error_handler() { DUMP('findmydevice push request failed!'); self._registering = false; self._countRegistrationRetry(); }; }, _requestRegistration: function fmd_request_registration(assertion, endpoint) { var obj = { pushurl: endpoint, accepts: Commands.getEnabledCommands() }; if (assertion != null) { obj.assert = assertion; } if (this._state !== null) { obj.deviceid = this._state.deviceid; } var self = this; Requester.post('/register/', obj, function(response) { DUMP('findmydevice successfully registered: ', response); asyncStorage.setItem('findmydevice-state', response, function() { self._registeredHelper.set(true); }); }, this._handleServerError.bind(this)); }, _onLogout: function fmd_fxa_onlogout() { DUMP('logged out of FxA'); this._loggedIn = false; }, _onFxAError: function fmd_on_error(error) { DUMP('FxA error: ' + error); if (!this._fxaReady) { // FIXME(ggp) workaround for bug 1040935, if FxA errors out // while we are initializing (usually due to an unverified // account), just give up for now. window.close(); } if (this._refreshingClientID) { this._cancelClientIDRefresh(); } this._scheduleAlarm('retry'); }, _cancelClientIDRefresh: function fmd_cancel_clientid_refresh() { this._disableAttempt = false; this._refreshingClientID = false; // If we've been woken up by the Settings app because of a disable // attempt, it expects us to change these two settings at the end // of the operation, so set them to their current values if we're // canceling to make sure the state in the Settings is updated // properly. this._enabledHelper.set(this._enabled); this._currentClientIDHelper.set(this._currentClientID); // XXX(ggp) no need to unlock the 'currentClient' lock here, let // _onClientIDChanged do it for us }, _scheduleAlarm: function fmd_schedule_alarm(mode) { var nextAlarm = new Date(); if (mode === 'ping') { // this is just a regular ping to the server to make // sure we're still registered, so use a long interval nextAlarm.setHours(nextAlarm.getHours() + 6); } else if (mode === 'retry') { // something went wrong when registering or talking to the // server, we should check back shortly var interval = 1 + Math.floor(5 * Math.random()); nextAlarm.setMinutes(nextAlarm.getMinutes() + interval); } else { DUMP('invalid alarm mode!'); return; } var self = this; var request = navigator.mozAlarms.getAll(); request.onsuccess = function fmd_alarms_get_all() { this.result.forEach(function(alarm) { navigator.mozAlarms.remove(alarm.id); }); var data = {type: 'findmydevice-alarm'}; var request = navigator.mozAlarms.add(nextAlarm, 'honorTimezone', data); request.onsuccess = function() { self.endHighPriority('clientLogic'); }; }; }, _replyAndFetchCommands: function fmd_reply_and_fetch() { this._reply.has_passcode = Commands.deviceHasPasscode(); Requester.post( this._getCommandEndpoint(), this._reply, this._handleServerResponse.bind(this), this._handleServerError.bind(this)); this._reply = {}; }, _onRegisteredChanged: function fmd_registered_changed(event) { this._registered = event.settingValue; DUMP('registered: ' + this._registered); if (!this._registered) { this._contactServer(); this.endHighPriority('clientLogic'); } else { this._loadState((function() { this._contactServer(); this._currentClientIDHelper.set(''); // XXX(ggp) we're still holding a 'clientLogic' lock, but let // _onClientIDChanged or _onCanDisableChanged release it (depending // on whether we're logged in or not). }).bind(this)); } }, _onEnabledChanged: function fmd_enabled_changed(event) { // No need to contact the server here if we've been enabled, // since that means we'll get a wake up request from the System // app this._enabled = event.settingValue; DUMP('enabled: ' + this._enabled); }, _onClientIDChanged: function fmd_client_id_changed(event) { this._currentClientID = event.settingValue; DUMP('current id set to: ', this._currentClientID); if (this._loggedIn && this._currentClientID === '') { this._refreshClientIDIfRegistered(false); this.endHighPriority('clientLogic'); } else if (this._registered) { this._canDisableHelper.set( this._loggedIn && this._currentClientID === this._state.clientid); } else { this.endHighPriority('clientLogic'); } }, _onCanDisableChanged: function fmd_can_disable_changed(event) { if (event.settingValue === true && this._disableAttempt) { this._enabledHelper.set(false); } this._disableAttempt = false; this.endHighPriority('clientLogic'); }, _refreshClientIDIfRegistered: function fmd_refresh_client_id(forceReauth) { DUMP('refreshing client id if registered and logged in: ', {registered: this._registered, loggedIn: this._loggedIn}); if (!this._registered || !this._loggedIn) { return; } DUMP('requesting assertion to refresh client id, forceReauth: ' + forceReauth); this.beginHighPriority('clientLogic'); this._refreshingClientID = true; var mozIdRequestOptions = {}; if (forceReauth) { mozIdRequestOptions.refreshAuthentication = 0; mozIdRequestOptions.oncancel = this._cancelClientIDRefresh.bind(this); } navigator.mozId.request(mozIdRequestOptions); }, _onLockscreenClosed: function fmd_on_lockscreen_closed() { if (Commands.deviceHasPasscode()) { DUMP('cancelling ring and track'); Commands.invokeCommand('ring', [0]); Commands.invokeCommand('track', [0]); } }, _fetchClientID: function fmd_fetch_client_id(assertion) { Requester.post('/validate/', {assert: assertion}, this._onClientIDResponse.bind(this), this._onClientIDServerError.bind(this)); this._refreshingClientID = false; }, _onClientIDResponse: function fmd_on_client_id(response) { DUMP('got clientid reponse: ', response); if (response.valid) { this._currentClientIDHelper.set(response.uid); return; } DUMP('failed to verify assertion for client id!'); this._scheduleAlarm('retry'); }, _onClientIDServerError: function fmd_on_client_id_error(err) { DUMP('failed to fetch client id with status: ' + err.status); this._scheduleAlarm('retry'); this._disableAttempt = false; }, _handleServerResponse: function(response, testing) { if (response && (this._enabled || testing)) { this._processCommands(response); } this._scheduleAlarm('ping'); }, _processCommands: function fmd_process_commands(cmdobj) { function noop() {} // callback for testing for (var cmd in cmdobj) { // map server (short) commands to methods in the // commands object, and parse the arguments. var argsObj = cmdobj[cmd], command, args; switch (cmd) { case 'e': command = 'erase'; args = []; break; case 'k': command = 'killswitch'; args = []; break; case 'l': command = 'lock'; args = [argsObj.m, argsObj.c]; break; case 'r': command = 'ring'; args = [parseInt(argsObj.d, 10)]; break; case 't': command = 'track'; args = [parseInt(argsObj.d, 10)]; break; default: this._replyCallback(cmd, false, 'command not available'); return; } DUMP('command ' + cmd + ', args ' + JSON.stringify(args)); // add the callback as the last argument if (cmdobj.testing !== true) { args.push(this._replyCallback.bind(this, cmd)); } else { args.push(noop); } Commands.invokeCommand(command, args); } }, _countRegistrationRetry: function fmd_count_registration_retry (){ this._scheduleAlarm('retry'); if (!navigator.onLine) { return; } if (!this._registered) { this._retryCountHelper.get(count => { this._retryCountHelper.set((count || 0) + 1); }); } }, _handleServerError: function fmd_handle_server_error(err) { DUMP('findmydevice request failed with status: ' + err.status); if (err.status === 401 && this._registered) { this._registeredHelper.set(false); } else { this._countRegistrationRetry(); } }, _replyCallback: function fmd_reply(cmd, ok, retval) { var value = {ok: ok}; if (cmd === 't' && ok === true && retval !== undefined) { value.la = retval.coords.latitude; value.lo = retval.coords.longitude; value.acc = retval.coords.accuracy; value.ti = retval.timestamp; } else if (ok === false) { value.error = retval; } this._reply[cmd] = value; this._contactServer(); }, _getCommandEndpoint: function fmd_get_command_endpoint() { return '/cmd/' + this._state.deviceid; }, beginHighPriority: function(reason) { DUMP('begin high priority section, reason: ', reason); if (Object.keys(this._highPriorityWakeLocks).indexOf(reason) === -1) { DUMP('unknown reason for high priority section?!'); return; } DUMP('acquiring one wakelock, wakelocks are: ', this._highPriorityWakeLocks); this._highPriorityWakeLocks[reason].push( navigator.requestWakeLock('high-priority')); }, endHighPriority: function(reason) { DUMP('end high priority section, reason: ', reason); if (!this._highPriorityWakeLocks[reason]) { return; } DUMP('releasing one wakelock, wakelocks are: ', this._highPriorityWakeLocks); this._highPriorityWakeLocks[reason].pop().unlock(); } }; FindMyDevice.init();