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 = {
// 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
// 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);
});
Weave.Service.sync();
......
......@@ -2860,8 +2860,8 @@ BrowserGlue.prototype = {
const firstTab = await openTab(URIs[0]);
await Promise.all(URIs.slice(1).map(URI => openTab(URI)));
const deviceName = URIs[0].sender && URIs[0].sender.name;
let title, body;
const deviceName = URIs[0].sender.name;
const bundle = Services.strings.createBundle("chrome://browser/locale/accounts.properties");
if (URIs.length == 1) {
// Due to bug 1305895, tabs from iOS may not have device information, so
......@@ -2885,13 +2885,15 @@ BrowserGlue.prototype = {
}
} else {
title = bundle.GetStringFromName("multipleTabsArrivingNotification.title");
const allSameDevice = URIs.every(URI => URI.sender.id == URIs[0].sender.id);
const unknownDevice = allSameDevice && !deviceName;
const allKnownSender = URIs.every(URI => URI.sender != null);
const allSameDevice = allKnownSender && URIs.every(URI => URI.sender.id == URIs[0].sender.id);
let tabArrivingBody;
if (unknownDevice) {
tabArrivingBody = "unnamedTabsArrivingNotificationNoDevice.body";
} else if (allSameDevice) {
if (allSameDevice) {
if (deviceName) {
tabArrivingBody = "unnamedTabsArrivingNotification2.body";
} else {
tabArrivingBody = "unnamedTabsArrivingNotificationNoDevice.body";
}
} else {
tabArrivingBody = "unnamedTabsArrivingNotificationMultiple2.body";
}
......
......@@ -38,62 +38,52 @@ class FxAccountsCommands {
log.info(`Payload sent to device ${device.id}.`);
}
async consumeRemoteCommand(index) {
if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)) {
return false;
}
log.info(`Consuming command with index ${index}.`);
const {messages} = await this._fetchRemoteCommands(index, 1);
if (messages.length != 1) {
log.warn(`Should have retrieved 1 and only 1 message, got ${messages.length}.`);
}
return this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => {
const {device} = await getUserData(["device"]);
if (!device) {
throw new Error("No device registration.");
}
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() {
/**
* Poll and handle device commands for the current device.
* This method can be called either in response to a Push message,
* or by itself as a "commands recovery" mechanism.
*
* @param {Number} receivedIndex "Command received" push messages include
* the index of the command that triggered the message. We use it as a
* hint when we have no "last command index" stored.
*/
async pollDeviceCommands(receivedIndex = 0) {
// Whether the call to `pollDeviceCommands` was initiated by a Push message from the FxA
// servers in response to a message being received or simply scheduled in order
// to fetch missed messages.
const scheduledFetch = receivedIndex == 0;
if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)) {
return false;
}
log.info(`Consuming missed commands.`);
log.info(`Polling device commands.`);
await this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => {
const {device} = await getUserData(["device"]);
if (!device) {
throw new Error("No device registration.");
}
const lastCommandIndex = device.lastCommandIndex || 0;
const handledCommands = device.handledCommands || [];
handledCommands.push(lastCommandIndex); // Because the server also returns this command.
const {index, messages} = await this._fetchRemoteCommands(lastCommandIndex);
const missedMessages = messages.filter(m => !handledCommands.includes(m.index));
// We increment lastCommandIndex by 1 because the server response includes the current index.
// If we don't have a `lastCommandIndex` stored, we fall back on the index from the push message we just got.
const lastCommandIndex = (device.lastCommandIndex + 1) || receivedIndex;
// We have already received this message before.
if (receivedIndex > 0 && receivedIndex < lastCommandIndex) {
return;
}
const {index, messages} = await this._fetchDeviceCommands(lastCommandIndex);
if (messages.length) {
await updateUserData({
device: {...device, lastCommandIndex: index, handledCommands: []},
device: {...device, lastCommandIndex: index},
});
if (missedMessages.length) {
log.info(`Handling ${missedMessages.length} missed messages`);
Services.telemetry.scalarAdd("identity.fxaccounts.missed_commands_fetched", missedMessages.length);
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;
}
async _fetchRemoteCommands(index, limit = null) {
async _fetchDeviceCommands(index, limit = null) {
const userData = await this._fxAccounts.getSignedInUser();
if (!userData) {
throw new Error("No user.");
......@@ -112,15 +102,20 @@ class FxAccountsCommands {
async _handleCommands(messages) {
const fxaDevices = await this._fxAccounts.getDeviceList();
// We debounce multiple incoming tabs so we show a single notification.
const tabsReceived = [];
for (const {data} of messages) {
let {command, payload, sender} = data;
if (sender) {
sender = fxaDevices.find(d => d.id == sender);
const {command, payload, sender: senderId} = data;
const sender = senderId ? fxaDevices.find(d => d.id == senderId) : null;
if (!sender) {
log.warn("Incoming command is from an unknown device (maybe disconnected?)");
}
switch (command) {
case COMMAND_SENDTAB:
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) {
log.error(`Error while handling incoming Send Tab payload.`, e);
}
......@@ -129,6 +124,9 @@ class FxAccountsCommands {
log.info(`Unknown command: ${command}.`);
}
}
if (tabsReceived.length) {
Observers.notify("fxaccounts:commands:open-uri", tabsReceived);
}
}
}
......@@ -184,22 +182,17 @@ class SendTab {
}
// Handle incoming send tab payload, called by FxAccountsCommands.
async handle(sender, {encrypted}) {
if (!sender) {
log.warn("Incoming tab is from an unknown device (maybe disconnected?)");
}
async handle({encrypted}) {
const bytes = await this._decrypt(encrypted);
const decoder = new TextDecoder("utf8");
const data = JSON.parse(decoder.decode(bytes));
const current = data.hasOwnProperty("current") ? data.current :
data.entries.length - 1;
const tabSender = {
id: sender ? sender.id : "",
name: sender ? sender.name : "",
};
const {title, url: uri} = data.entries[current];
log.info(`Tab received with FxA commands: ${title} from ${tabSender.name}.`);
Observers.notify("fxaccounts:commands:open-uri", [{uri, title, sender: tabSender}]);
return {
title,
uri,
};
}
async _encrypt(bytes, device) {
......
......@@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
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");
/**
......@@ -71,10 +72,17 @@ FxAccountsPushService.prototype = {
"resource://gre/modules/FxAccounts.jsm");
}
// listen to new push messages, push changes and logout events
Services.obs.addObserver(this, this.pushService.pushTopic);
Services.obs.addObserver(this, this.pushService.subscriptionChangeTopic);
Services.obs.addObserver(this, ONLOGOUT_NOTIFICATION);
this.asyncObserver = Async.asyncObserver(this, this.log);
// We use an async observer because a device waking up can
// observe multiple "Send Tab received" push notifications at the same time.
// 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");
return true;
......@@ -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 topic
* @param data
* @returns {Promise}
*/
_observe(subject, topic, data) {
async observe(subject, topic, data) {
try {
this.log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`);
switch (topic) {
case this.pushService.pushTopic:
if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
let message = subject.QueryInterface(Ci.nsIPushMessage);
this._onPushMessage(message);
await this._onPushMessage(message);
}
break;
case this.pushService.subscriptionChangeTopic:
if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
this._onPushSubscriptionChange();
await this._onPushSubscriptionChange();
}
break;
case ONLOGOUT_NOTIFICATION:
// user signed out, we need to stop polling the Push Server
this.unsubscribe().catch(err => {
try {
await this.unsubscribe();
} catch (err) {
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.
*
* @private
* @returns {Promise}
*/
_onPushMessage(message) {
async _onPushMessage(message) {
this.log.trace("FxAccountsPushService _onPushMessage");
if (!message.data) {
// Use the empty signal to check the verification state of the account right away
......@@ -159,7 +166,7 @@ FxAccountsPushService.prototype = {
this.log.debug(`push command: ${payload.command}`);
switch (payload.command) {
case ON_COMMAND_RECEIVED_NOTIFICATION:
this.fxAccounts.commands.consumeRemoteCommand(payload.data.index);
await this.fxAccounts.commands.pollDeviceCommands(payload.data.index);
break;
case ON_DEVICE_CONNECTED_NOTIFICATION:
Services.obs.notifyObservers(null, ON_DEVICE_CONNECTED_NOTIFICATION, payload.data.deviceName);
......
......@@ -50,15 +50,100 @@ add_task(async function test_sendtab_send() {
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: {
handledCommands: [8, 9, 10, 11],
lastCommandIndex: 11,
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 = {
data: {
device: {
lastCommandIndex: 12,
},
},
};
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").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,
data: {},
},
];
const remoteIndex = 12;
// Local state.
const pushIndexReceived = 11;
const accountState = {
data: {
device: {},
},
};
const fxAccounts = {
async _withCurrentAccountState(cb) {
const get = () => accountState.data;
......@@ -67,10 +152,64 @@ add_task(async function test_commands_fetchMissedRemoteCommands() {
},
};
const commands = new FxAccountsCommands(fxAccounts);
commands._fetchRemoteCommands = () => {
return {
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,
messages: [
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: {},
......@@ -79,14 +218,32 @@ add_task(async function test_commands_fetchMissedRemoteCommands() {
index: 12,
data: {},
},
],
];
const remoteIndex = 12;
// Local state.
const accountState = {
data: {
device: {},
},
};
const fxAccounts = {
async _withCurrentAccountState(cb) {
const get = () => accountState.data;
const set = (val) => { accountState.data = val; };
await cb(get, set);
},
};
commands._handleCommands = sinon.spy();
await commands.fetchMissedRemoteCommands();
const commands = new FxAccountsCommands(fxAccounts);
const mockCommands = sinon.mock(commands);
mockCommands.expects("_fetchDeviceCommands").once().withArgs(0).returns({
index: remoteIndex,
messages: remoteMessages,
});
mockCommands.expects("_handleCommands").once().withArgs(remoteMessages);
await commands.pollDeviceCommands();
Assert.equal(accountState.data.device.handledCommands.length, 0);
mockCommands.verify();
Assert.equal(accountState.data.device.lastCommandIndex, 12);
const callArgs = commands._handleCommands.args[0][0];
Assert.equal(callArgs[0].index, 12);
});
......@@ -426,7 +426,7 @@ add_task(async function commandReceived() {
let fxAccountsMock = {};
const promiseConsumeRemoteMessagesCalled = new Promise(res => {
fxAccountsMock.commands = {
consumeRemoteCommand() {
pollDeviceCommands() {
res();
},
};
......
......@@ -550,7 +550,7 @@ SyncScheduler.prototype = {
// Only fetch missed messages in a "scheduled" sync so we don't race against
// the Push service reconnecting on a network link change for example.
if (why == "schedule" && now >= this.missedFxACommandsLastFetch + this.missedFxACommandsFetchInterval) {
fxAccounts.commands.fetchMissedRemoteCommands().then(() => {
fxAccounts.commands.pollDeviceCommands().then(() => {
this.missedFxACommandsLastFetch = now;
}).catch(e => {
this._log.error("Fetching missed remote commands failed.", e);
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment