Commit ff5f5361 authored by Edouard Oger's avatar Edouard Oger
Browse files

Bug 1528622 - Debounce FxA Send Tab commands. r=markh,rfkelly

Differential Revision: https://phabricator.services.mozilla.com/D21286

--HG--
extra : moz-landing-system : lando
parent 104c0bd8
...@@ -645,7 +645,7 @@ var gSync = { ...@@ -645,7 +645,7 @@ var gSync = {
// We are pretty confident that push helps us pick up all FxA commands, // We are pretty confident that push helps us pick up all FxA commands,
// but some users might have issues with push, so let's unblock them // but some users might have issues with push, so let's unblock them
// by fetching the missed FxA commands on manual sync. // by fetching the missed FxA commands on manual sync.
fxAccounts.commands.fetchMissedRemoteCommands().catch(e => { fxAccounts.commands.pollDeviceCommands().catch(e => {
console.error("Fetching missed remote commands failed.", e); console.error("Fetching missed remote commands failed.", e);
}); });
Weave.Service.sync(); Weave.Service.sync();
......
...@@ -2860,8 +2860,8 @@ BrowserGlue.prototype = { ...@@ -2860,8 +2860,8 @@ BrowserGlue.prototype = {
const firstTab = await openTab(URIs[0]); const firstTab = await openTab(URIs[0]);
await Promise.all(URIs.slice(1).map(URI => openTab(URI))); await Promise.all(URIs.slice(1).map(URI => openTab(URI)));
const deviceName = URIs[0].sender && URIs[0].sender.name;
let title, body; let title, body;
const deviceName = URIs[0].sender.name;
const bundle = Services.strings.createBundle("chrome://browser/locale/accounts.properties"); const bundle = Services.strings.createBundle("chrome://browser/locale/accounts.properties");
if (URIs.length == 1) { if (URIs.length == 1) {
// Due to bug 1305895, tabs from iOS may not have device information, so // Due to bug 1305895, tabs from iOS may not have device information, so
...@@ -2885,13 +2885,15 @@ BrowserGlue.prototype = { ...@@ -2885,13 +2885,15 @@ BrowserGlue.prototype = {
} }
} else { } else {
title = bundle.GetStringFromName("multipleTabsArrivingNotification.title"); title = bundle.GetStringFromName("multipleTabsArrivingNotification.title");
const allSameDevice = URIs.every(URI => URI.sender.id == URIs[0].sender.id); const allKnownSender = URIs.every(URI => URI.sender != null);
const unknownDevice = allSameDevice && !deviceName; const allSameDevice = allKnownSender && URIs.every(URI => URI.sender.id == URIs[0].sender.id);
let tabArrivingBody; let tabArrivingBody;
if (unknownDevice) { if (allSameDevice) {
tabArrivingBody = "unnamedTabsArrivingNotificationNoDevice.body"; if (deviceName) {
} else if (allSameDevice) { tabArrivingBody = "unnamedTabsArrivingNotification2.body";
tabArrivingBody = "unnamedTabsArrivingNotification2.body"; } else {
tabArrivingBody = "unnamedTabsArrivingNotificationNoDevice.body";
}
} else { } else {
tabArrivingBody = "unnamedTabsArrivingNotificationMultiple2.body"; tabArrivingBody = "unnamedTabsArrivingNotificationMultiple2.body";
} }
......
...@@ -38,62 +38,52 @@ class FxAccountsCommands { ...@@ -38,62 +38,52 @@ class FxAccountsCommands {
log.info(`Payload sent to device ${device.id}.`); log.info(`Payload sent to device ${device.id}.`);
} }
async consumeRemoteCommand(index) { /**
if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)) { * Poll and handle device commands for the current device.
return false; * This method can be called either in response to a Push message,
} * or by itself as a "commands recovery" mechanism.
log.info(`Consuming command with index ${index}.`); *
const {messages} = await this._fetchRemoteCommands(index, 1); * @param {Number} receivedIndex "Command received" push messages include
if (messages.length != 1) { * the index of the command that triggered the message. We use it as a
log.warn(`Should have retrieved 1 and only 1 message, got ${messages.length}.`); * hint when we have no "last command index" stored.
} */
return this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => { async pollDeviceCommands(receivedIndex = 0) {
const {device} = await getUserData(["device"]); // Whether the call to `pollDeviceCommands` was initiated by a Push message from the FxA
if (!device) { // servers in response to a message being received or simply scheduled in order
throw new Error("No device registration."); // to fetch missed messages.
} const scheduledFetch = receivedIndex == 0;
const handledCommands = (device.handledCommands || []).concat(messages.map(m => m.index));
await updateUserData({
device: {...device, handledCommands},
});
await this._handleCommands(messages);
// Once the handledCommands array length passes a threshold, check the
// potentially missed remote commands in order to clear it.
if (handledCommands.length > 20) {
await this.fetchMissedRemoteCommands();
}
});
}
async fetchMissedRemoteCommands() {
if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)) { if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)) {
return false; return false;
} }
log.info(`Consuming missed commands.`); log.info(`Polling device commands.`);
await this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => { await this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => {
const {device} = await getUserData(["device"]); const {device} = await getUserData(["device"]);
if (!device) { if (!device) {
throw new Error("No device registration."); throw new Error("No device registration.");
} }
const lastCommandIndex = device.lastCommandIndex || 0; // We increment lastCommandIndex by 1 because the server response includes the current index.
const handledCommands = device.handledCommands || []; // If we don't have a `lastCommandIndex` stored, we fall back on the index from the push message we just got.
handledCommands.push(lastCommandIndex); // Because the server also returns this command. const lastCommandIndex = (device.lastCommandIndex + 1) || receivedIndex;
const {index, messages} = await this._fetchRemoteCommands(lastCommandIndex); // We have already received this message before.
const missedMessages = messages.filter(m => !handledCommands.includes(m.index)); if (receivedIndex > 0 && receivedIndex < lastCommandIndex) {
await updateUserData({ return;
device: {...device, lastCommandIndex: index, handledCommands: []}, }
}); const {index, messages} = await this._fetchDeviceCommands(lastCommandIndex);
if (missedMessages.length) { if (messages.length) {
log.info(`Handling ${missedMessages.length} missed messages`); await updateUserData({
Services.telemetry.scalarAdd("identity.fxaccounts.missed_commands_fetched", missedMessages.length); device: {...device, lastCommandIndex: index},
await this._handleCommands(missedMessages); });
log.info(`Handling ${messages.length} messages`);
if (scheduledFetch) {
Services.telemetry.scalarAdd("identity.fxaccounts.missed_commands_fetched", messages.length);
}
await this._handleCommands(messages);
} }
}); });
return true; return true;
} }
async _fetchRemoteCommands(index, limit = null) { async _fetchDeviceCommands(index, limit = null) {
const userData = await this._fxAccounts.getSignedInUser(); const userData = await this._fxAccounts.getSignedInUser();
if (!userData) { if (!userData) {
throw new Error("No user."); throw new Error("No user.");
...@@ -112,15 +102,20 @@ class FxAccountsCommands { ...@@ -112,15 +102,20 @@ class FxAccountsCommands {
async _handleCommands(messages) { async _handleCommands(messages) {
const fxaDevices = await this._fxAccounts.getDeviceList(); const fxaDevices = await this._fxAccounts.getDeviceList();
// We debounce multiple incoming tabs so we show a single notification.
const tabsReceived = [];
for (const {data} of messages) { for (const {data} of messages) {
let {command, payload, sender} = data; const {command, payload, sender: senderId} = data;
if (sender) { const sender = senderId ? fxaDevices.find(d => d.id == senderId) : null;
sender = fxaDevices.find(d => d.id == sender); if (!sender) {
log.warn("Incoming command is from an unknown device (maybe disconnected?)");
} }
switch (command) { switch (command) {
case COMMAND_SENDTAB: case COMMAND_SENDTAB:
try { try {
await this.sendTab.handle(sender, payload); const {title, uri} = await this.sendTab.handle(payload);
log.info(`Tab received with FxA commands: ${title} from ${sender ? sender.name : "Unknown device"}.`);
tabsReceived.push({title, uri, sender});
} catch (e) { } catch (e) {
log.error(`Error while handling incoming Send Tab payload.`, e); log.error(`Error while handling incoming Send Tab payload.`, e);
} }
...@@ -129,6 +124,9 @@ class FxAccountsCommands { ...@@ -129,6 +124,9 @@ class FxAccountsCommands {
log.info(`Unknown command: ${command}.`); log.info(`Unknown command: ${command}.`);
} }
} }
if (tabsReceived.length) {
Observers.notify("fxaccounts:commands:open-uri", tabsReceived);
}
} }
} }
...@@ -184,22 +182,17 @@ class SendTab { ...@@ -184,22 +182,17 @@ class SendTab {
} }
// Handle incoming send tab payload, called by FxAccountsCommands. // Handle incoming send tab payload, called by FxAccountsCommands.
async handle(sender, {encrypted}) { async handle({encrypted}) {
if (!sender) {
log.warn("Incoming tab is from an unknown device (maybe disconnected?)");
}
const bytes = await this._decrypt(encrypted); const bytes = await this._decrypt(encrypted);
const decoder = new TextDecoder("utf8"); const decoder = new TextDecoder("utf8");
const data = JSON.parse(decoder.decode(bytes)); const data = JSON.parse(decoder.decode(bytes));
const current = data.hasOwnProperty("current") ? data.current : const current = data.hasOwnProperty("current") ? data.current :
data.entries.length - 1; data.entries.length - 1;
const tabSender = {
id: sender ? sender.id : "",
name: sender ? sender.name : "",
};
const {title, url: uri} = data.entries[current]; const {title, url: uri} = data.entries[current];
log.info(`Tab received with FxA commands: ${title} from ${tabSender.name}.`); return {
Observers.notify("fxaccounts:commands:open-uri", [{uri, title, sender: tabSender}]); title,
uri,
};
} }
async _encrypt(bytes, device) { async _encrypt(bytes, device) {
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
const {Async} = ChromeUtils.import("resource://services-common/async.js");
const {FXA_PUSH_SCOPE_ACCOUNT_UPDATE, ONLOGOUT_NOTIFICATION, ON_ACCOUNT_DESTROYED_NOTIFICATION, ON_ACCOUNT_STATE_CHANGE_NOTIFICATION, ON_COLLECTION_CHANGED_NOTIFICATION, ON_COMMAND_RECEIVED_NOTIFICATION, ON_DEVICE_CONNECTED_NOTIFICATION, ON_DEVICE_DISCONNECTED_NOTIFICATION, ON_PASSWORD_CHANGED_NOTIFICATION, ON_PASSWORD_RESET_NOTIFICATION, ON_PROFILE_CHANGE_NOTIFICATION, ON_PROFILE_UPDATED_NOTIFICATION, ON_VERIFY_LOGIN_NOTIFICATION, log} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); const {FXA_PUSH_SCOPE_ACCOUNT_UPDATE, ONLOGOUT_NOTIFICATION, ON_ACCOUNT_DESTROYED_NOTIFICATION, ON_ACCOUNT_STATE_CHANGE_NOTIFICATION, ON_COLLECTION_CHANGED_NOTIFICATION, ON_COMMAND_RECEIVED_NOTIFICATION, ON_DEVICE_CONNECTED_NOTIFICATION, ON_DEVICE_DISCONNECTED_NOTIFICATION, ON_PASSWORD_CHANGED_NOTIFICATION, ON_PASSWORD_RESET_NOTIFICATION, ON_PROFILE_CHANGE_NOTIFICATION, ON_PROFILE_UPDATED_NOTIFICATION, ON_VERIFY_LOGIN_NOTIFICATION, log} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
/** /**
...@@ -71,10 +72,17 @@ FxAccountsPushService.prototype = { ...@@ -71,10 +72,17 @@ FxAccountsPushService.prototype = {
"resource://gre/modules/FxAccounts.jsm"); "resource://gre/modules/FxAccounts.jsm");
} }
// listen to new push messages, push changes and logout events this.asyncObserver = Async.asyncObserver(this, this.log);
Services.obs.addObserver(this, this.pushService.pushTopic); // We use an async observer because a device waking up can
Services.obs.addObserver(this, this.pushService.subscriptionChangeTopic); // observe multiple "Send Tab received" push notifications at the same time.
Services.obs.addObserver(this, ONLOGOUT_NOTIFICATION); // The way these notifications are handled is as follows:
// Read index from storage, make network request, update the index.
// You can imagine what happens when multiple calls race: we load
// the same index multiple times and receive the same exact tabs, multiple times.
// The async observer will ensure we make these network requests serially.
Services.obs.addObserver(this.asyncObserver, this.pushService.pushTopic);
Services.obs.addObserver(this.asyncObserver, this.pushService.subscriptionChangeTopic);
Services.obs.addObserver(this.asyncObserver, ONLOGOUT_NOTIFICATION);
this.log.debug("FxAccountsPush initialized"); this.log.debug("FxAccountsPush initialized");
return true; return true;
...@@ -103,51 +111,50 @@ FxAccountsPushService.prototype = { ...@@ -103,51 +111,50 @@ FxAccountsPushService.prototype = {
}); });
}, },
/** /**
* Standard observer interface to listen to push messages, changes and logout. * Async observer interface to listen to push messages, changes and logout.
* *
* @param subject * @param subject
* @param topic * @param topic
* @param data * @param data
* @returns {Promise} * @returns {Promise}
*/ */
_observe(subject, topic, data) { async observe(subject, topic, data) {
this.log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`); try {
switch (topic) { this.log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`);
case this.pushService.pushTopic: switch (topic) {
if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) { case this.pushService.pushTopic:
let message = subject.QueryInterface(Ci.nsIPushMessage); if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
this._onPushMessage(message); let message = subject.QueryInterface(Ci.nsIPushMessage);
} await this._onPushMessage(message);
break; }
case this.pushService.subscriptionChangeTopic: break;
if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) { case this.pushService.subscriptionChangeTopic:
this._onPushSubscriptionChange(); if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
} await this._onPushSubscriptionChange();
break; }
case ONLOGOUT_NOTIFICATION: break;
// user signed out, we need to stop polling the Push Server case ONLOGOUT_NOTIFICATION:
this.unsubscribe().catch(err => { // user signed out, we need to stop polling the Push Server
this.log.error("Error during unsubscribe", err); try {
}); await this.unsubscribe();
default: } catch (err) {
break; this.log.error("Error during unsubscribe", err);
}
default:
break;
}
} catch (err) {
this.log.error(err);
} }
}, },
/**
* Wrapper around _observe that catches errors
*/
observe(subject, topic, data) {
Promise.resolve()
.then(() => this._observe(subject, topic, data))
.catch(err => this.log.error(err));
},
/** /**
* Fired when the Push server sends a notification. * Fired when the Push server sends a notification.
* *
* @private * @private
* @returns {Promise} * @returns {Promise}
*/ */
_onPushMessage(message) { async _onPushMessage(message) {
this.log.trace("FxAccountsPushService _onPushMessage"); this.log.trace("FxAccountsPushService _onPushMessage");
if (!message.data) { if (!message.data) {
// Use the empty signal to check the verification state of the account right away // Use the empty signal to check the verification state of the account right away
...@@ -159,7 +166,7 @@ FxAccountsPushService.prototype = { ...@@ -159,7 +166,7 @@ FxAccountsPushService.prototype = {
this.log.debug(`push command: ${payload.command}`); this.log.debug(`push command: ${payload.command}`);
switch (payload.command) { switch (payload.command) {
case ON_COMMAND_RECEIVED_NOTIFICATION: case ON_COMMAND_RECEIVED_NOTIFICATION:
this.fxAccounts.commands.consumeRemoteCommand(payload.data.index); await this.fxAccounts.commands.pollDeviceCommands(payload.data.index);
break; break;
case ON_DEVICE_CONNECTED_NOTIFICATION: case ON_DEVICE_CONNECTED_NOTIFICATION:
Services.obs.notifyObservers(null, ON_DEVICE_CONNECTED_NOTIFICATION, payload.data.deviceName); Services.obs.notifyObservers(null, ON_DEVICE_CONNECTED_NOTIFICATION, payload.data.deviceName);
......
...@@ -50,15 +50,61 @@ add_task(async function test_sendtab_send() { ...@@ -50,15 +50,61 @@ add_task(async function test_sendtab_send() {
Assert.ok(commands.invoke.calledTwice); Assert.ok(commands.invoke.calledTwice);
}); });
add_task(async function test_commands_fetchMissedRemoteCommands() { add_task(async function test_commands_pollDeviceCommands_push() {
// Server state.
const remoteMessages = [
{
index: 11,
data: {},
},
{
index: 12,
data: {},
},
];
const remoteIndex = 12;
// Local state.
const pushIndexReceived = 11;
const accountState = {
data: {
device: {
lastCommandIndex: 10,
},
},
};
const fxAccounts = {
async _withCurrentAccountState(cb) {
const get = () => accountState.data;
const set = (val) => { accountState.data = val; };
await cb(get, set);
},
};
const commands = new FxAccountsCommands(fxAccounts);
const mockCommands = sinon.mock(commands);
mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
index: remoteIndex,
messages: remoteMessages,
});
mockCommands.expects("_handleCommands").once().withArgs(remoteMessages);
await commands.pollDeviceCommands(pushIndexReceived);
mockCommands.verify();
Assert.equal(accountState.data.device.lastCommandIndex, 12);
});
add_task(async function test_commands_pollDeviceCommands_push_already_fetched() {
// Local state.
const pushIndexReceived = 12;
const accountState = { const accountState = {
data: { data: {
device: { device: {
handledCommands: [8, 9, 10, 11], lastCommandIndex: 12,
lastCommandIndex: 11,
}, },
}, },
}; };
const fxAccounts = { const fxAccounts = {
async _withCurrentAccountState(cb) { async _withCurrentAccountState(cb) {
const get = () => accountState.data; const get = () => accountState.data;
...@@ -67,26 +113,137 @@ add_task(async function test_commands_fetchMissedRemoteCommands() { ...@@ -67,26 +113,137 @@ add_task(async function test_commands_fetchMissedRemoteCommands() {
}, },
}; };
const commands = new FxAccountsCommands(fxAccounts); const commands = new FxAccountsCommands(fxAccounts);
commands._fetchRemoteCommands = () => { const mockCommands = sinon.mock(commands);
return { mockCommands.expects("_fetchDeviceCommands").never();
mockCommands.expects("_handleCommands").never();
await commands.pollDeviceCommands(pushIndexReceived);
mockCommands.verify();
Assert.equal(accountState.data.device.lastCommandIndex, 12);
});
add_task(async function test_commands_pollDeviceCommands_push_local_state_empty() {
// Server state.
const remoteMessages = [
{
index: 11,
data: {},
},
{
index: 12, index: 12,
messages: [ data: {},
{ },
index: 11, ];
data: {}, const remoteIndex = 12;
},
{ // Local state.
index: 12, const pushIndexReceived = 11;
data: {}, const accountState = {
}, data: {
], device: {},
}; },
}; };
commands._handleCommands = sinon.spy();
await commands.fetchMissedRemoteCommands();
Assert.equal(accountState.data.device.handledCommands.length, 0); const fxAccounts = {
async _withCurrentAccountState(cb) {
const get = () => accountState.data;
const set = (val) => { accountState.data = val; };
await cb(get, set);
},
};
const commands = new FxAccountsCommands(fxAccounts);
const mockCommands = sinon.mock(commands);
mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
index: remoteIndex,
messages: remoteMessages,
});
mockCommands.expects("_handleCommands").once().withArgs(remoteMessages);
await commands.pollDeviceCommands(pushIndexReceived);
mockCommands.verify();
Assert.equal(accountState.data.device.lastCommandIndex, 12);
});
add_task(async function test_commands_pollDeviceCommands_scheduled_local() {
// Server state.
const remoteMessages = [
{
index: 11,
data: {},
},
{
index: 12,
data: {},
},
];
const remoteIndex = 12;
// Local state.
const accountState = {
data: {
device: {
lastCommandIndex: 10,
},
},
};
const fxAccounts = {
async _withCurrentAccountState(cb) {
const get = () => accountState.data;
const set = (val) => { accountState.data = val; };
await cb(get, set);
},
};
const commands = new FxAccountsCommands(fxAccounts);
const mockCommands = sinon.mock(commands);
mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
index: remoteIndex,
messages: remoteMessages,
});
mockCommands.expects("_handleCommands").once().withArgs(remoteMessages);
await commands.pollDeviceCommands();
mockCommands.verify();
Assert.equal(accountState.data.device.lastCommandIndex, 12);
});
add_task(async function test_commands_pollDeviceCommands_scheduled_local_state_empty() {
// Server state.
const remoteMessages = [
{
index: 11,
data: {},
},
{
index: 12,
data: {},
},
];
const remoteIndex = 12;
// Local state.
const accountState = {
data: {
device: {},