Bug 1583413 - Fetch the Send Tab target list from FxA, not Sync. r=markh,eoger

Instead of using the list of FxA devices from the Sync clients engine,
we now fetch the list of Send Tab devices from FxA. This works like
this:

* `FxAccountsDevice#getDeviceList` has been split up into
  `recentDeviceList` and `refreshDeviceList`.
* `recentDeviceList` synchronously returns the last fetched list, so
  that consumers like Send Tab can use it right away.
* `refreshDeviceList` is asynchronous, and refreshes the last fetched
  list. Refreshes are limited to once every minute by default, matching
  the minimum sync interval (Send Tab passes the `ignoreCached` option
  to override the limit if the user clicks the "refresh" button).
  Concurrent calls to `refreshDeviceList` are also serialized, to
  ensure the list is only fetched once.
* The list is flagged as stale when a device is connected or
  disconnected. It's still kept around, but the next call to
  `refreshDeviceList` will fetch a new list from the server.
* The Send Tab UI refreshes FxA devices in the background. Matching FxA
  devices to Sync client records is best effort; we don't do it if Sync
  isn't configured or hasn't run yet. This only impacts the fallback
  case if the target doesn't support FxA commands.

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

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Lina Cambridge 2019-10-03 22:40:55 +00:00
parent 51e03623ee
commit 397d3a1156
12 changed files with 604 additions and 156 deletions

View File

@ -57,17 +57,12 @@ var gSync = {
));
},
get syncReady() {
return Cc["@mozilla.org/weave/service;1"].getService().wrappedJSObject
.ready;
},
// Returns true if sync is configured but hasn't loaded or the send tab
// targets list isn't ready yet.
// Returns true if FxA is configured, but the send tab targets list isn't
// ready yet.
get sendTabConfiguredAndLoading() {
return (
UIState.get().status == UIState.STATUS_SIGNED_IN &&
(!this.syncReady || !Weave.Service.clientsEngine.hasSyncedThisSession)
!fxAccounts.device.recentDeviceList
);
},
@ -75,14 +70,26 @@ var gSync = {
return UIState.get().status == UIState.STATUS_SIGNED_IN;
},
get sendTabTargets() {
return Weave.Service.clientsEngine.fxaDevices
.sort((a, b) => a.name.localeCompare(b.name))
.filter(
d =>
!d.isCurrentDevice &&
(fxAccounts.commands.sendTab.isDeviceCompatible(d) || d.clientRecord)
getSendTabTargets() {
let targets = [];
if (!fxAccounts.device.recentDeviceList) {
return targets;
}
for (let d of fxAccounts.device.recentDeviceList) {
if (d.isCurrentDevice) {
continue;
}
let clientRecord = Weave.Service.clientsEngine.getClientByFxaDeviceId(
d.id
);
if (clientRecord || fxAccounts.commands.sendTab.isDeviceCompatible(d)) {
targets.push({
clientRecord,
...d,
});
}
}
return targets.sort((a, b) => a.name.localeCompare(b.name));
},
_generateNodeGetters() {
@ -220,6 +227,24 @@ var gSync = {
this.updateSyncButtonsTooltip(state);
this.updateSyncStatus(state);
this.updateFxAPanel(state);
// Refresh the device list in the background.
this.refreshFxaDevices();
},
async refreshFxaDevices(options) {
if (UIState.get().status != UIState.STATUS_SIGNED_IN) {
console.info("Skipping device list refresh; not signed in");
return;
}
try {
// Poke FxA to refresh the recent device list. It's safe to call
// `refreshDeviceList` multiple times in the background, as it avoids
// making new requests if one is already active, and caches the list for
// 1 minute by default.
await fxAccounts.device.refreshDeviceList(options);
} catch (e) {
console.error("Refreshing device list failed.", e);
}
},
updateSendToDeviceTitle() {
@ -248,7 +273,10 @@ var gSync = {
return;
}
if (this.sendTabConfiguredAndLoading || this.sendTabTargets.length <= 0) {
const targets = this.sendTabConfiguredAndLoading
? []
: this.getSendTabTargets();
if (!targets.length) {
PanelUI.showSubView("PanelUI-fxa-menu-sendtab-no-devices", anchor);
return;
}
@ -308,20 +336,30 @@ var gSync = {
);
bodyNode.removeAttribute("state");
// In the first ~10 sec after startup, Sync may not be loaded and the list
// of devices will be empty.
// If the app just started, we won't have fetched the device list yet. Sync
// does this automatically ~10 sec after startup, but there's no trigger for
// this if we're signed in to FxA, but not Sync.
if (gSync.sendTabConfiguredAndLoading) {
bodyNode.setAttribute("state", "notready");
}
if (reloadDevices && UIState.get().syncEnabled) {
// Force a background Sync
Services.tm.dispatchToMainThread(async () => {
// `engines: []` = clients engine only + refresh FxA Devices.
await Weave.Service.sync({ why: "pageactions", engines: [] });
if (!window.closed) {
this.populateSendTabToDevicesView(panelViewNode, false);
}
});
if (reloadDevices) {
if (UIState.get().syncEnabled) {
Services.tm.dispatchToMainThread(async () => {
// `engines: []` = clients engine only + refresh FxA Devices.
await Weave.Service.sync({ why: "pageactions", engines: [] });
if (!window.closed) {
this.populateSendTabToDevicesView(panelViewNode, false);
}
});
} else {
// Force a refresh, since the user probably connected a new device, and
// is waiting for it to show up.
this.refreshFxaDevices({ ignoreCached: true }).then(_ => {
if (!window.closed) {
this.populateSendTabToDevicesView(panelViewNode, false);
}
});
}
}
},
@ -806,8 +844,10 @@ var gSync = {
const state = UIState.get();
if (state.status == UIState.STATUS_SIGNED_IN) {
if (this.sendTabTargets.length) {
const targets = this.getSendTabTargets();
if (targets.length) {
this._appendSendTabDeviceList(
targets,
fragment,
createDeviceNodeFn,
url,
@ -829,18 +869,14 @@ var gSync = {
devicesPopup.appendChild(fragment);
},
// TODO: once our transition from the old-send tab world is complete,
// this list should be built using the FxA device list instead of the client
// collection.
_appendSendTabDeviceList(
targets,
fragment,
createDeviceNodeFn,
url,
title,
multiselected
) {
const targets = this.sendTabTargets;
let tabsToSend = multiselected
? gBrowser.selectedTabs.map(t => {
return {
@ -1341,6 +1377,7 @@ var gSync = {
},
onClientsSynced() {
// Note that this element is only shown if Sync is enabled.
let element = document.getElementById("PanelUI-remotetabs-main");
if (element) {
if (Weave.Service.clientsEngine.stats.numClients > 1) {

View File

@ -307,7 +307,7 @@ add_task(async function sendToDevice_syncNotReady_other_states() {
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
await promiseSyncReady();
const sandbox = sinon.createSandbox();
sandbox.stub(gSync, "syncReady").get(() => false);
sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => null);
sandbox
.stub(UIState, "get")
.returns({ status: UIState.STATUS_NOT_VERIFIED });
@ -366,17 +366,22 @@ add_task(async function sendToDevice_syncNotReady_configured() {
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
await promiseSyncReady();
const sandbox = sinon.createSandbox();
const syncReady = sandbox.stub(gSync, "syncReady").get(() => false);
const hasSyncedThisSession = sandbox
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
.get(() => false);
const recentDeviceList = sandbox
.stub(fxAccounts.device, "recentDeviceList")
.get(() => null);
sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
sandbox.stub(gSync, "isSendableURI").returns(true);
sandbox.stub(Weave.Service, "sync").callsFake(() => {
syncReady.get(() => true);
hasSyncedThisSession.get(() => true);
sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets);
sandbox.stub(fxAccounts.device, "refreshDeviceList").callsFake(() => {
recentDeviceList.get(() =>
mockTargets.map(({ id, name, type }) => ({ id, name, type }))
);
sandbox
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
.callsFake(fxaDeviceId => {
let target = mockTargets.find(c => c.id == fxaDeviceId);
return target ? target.clientRecord : null;
});
sandbox
.stub(Weave.Service.clientsEngine, "getClientType")
.callsFake(
@ -521,13 +526,16 @@ add_task(async function sendToDevice_noDevices() {
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
await promiseSyncReady();
const sandbox = sinon.createSandbox();
sandbox.stub(gSync, "syncReady").get(() => true);
sandbox
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
.get(() => true);
sandbox.stub(Weave.Service.clientsEngine, "fxaDevices").get(() => []);
sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
sandbox.stub(gSync, "isSendableURI").returns(true);
sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
sandbox
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
.callsFake(fxaDeviceId => {
let target = mockTargets.find(c => c.id == fxaDeviceId);
return target ? target.clientRecord : null;
});
sandbox
.stub(Weave.Service.clientsEngine, "getClientType")
.callsFake(
@ -596,13 +604,22 @@ add_task(async function sendToDevice_devices() {
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
await promiseSyncReady();
const sandbox = sinon.createSandbox();
sandbox.stub(gSync, "syncReady").get(() => true);
sandbox
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
.get(() => true);
.stub(fxAccounts.device, "recentDeviceList")
.get(() => mockTargets.map(({ id, name, type }) => ({ id, name, type })));
sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
sandbox.stub(gSync, "isSendableURI").returns(true);
sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets);
sandbox
.stub(fxAccounts.commands.sendTab, "isDeviceCompatible")
.returns(true);
sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
sandbox.spy(Weave.Service, "sync");
sandbox
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
.callsFake(fxaDeviceId => {
let target = mockTargets.find(c => c.id == fxaDeviceId);
return target ? target.clientRecord : null;
});
sandbox
.stub(Weave.Service.clientsEngine, "getClientType")
.callsFake(
@ -636,23 +653,129 @@ add_task(async function sendToDevice_devices() {
display: "none",
disabled: true,
},
];
for (let target of mockTargets) {
expectedItems.push({
{
attrs: {
clientId: target.id,
label: target.name,
clientType: target.type,
clientId: "1",
label: "bar",
clientType: "desktop",
},
});
}
expectedItems.push(null, {
attrs: {
label: "Send to All Devices",
},
});
{
attrs: {
clientId: "2",
label: "baz",
clientType: "phone",
},
},
{
attrs: {
clientId: "0",
label: "foo",
clientType: "phone",
},
},
{
attrs: {
clientId: "3",
label: "no client record device",
clientType: "phone",
},
},
null,
{
attrs: {
label: "Send to All Devices",
},
},
];
checkSendToDeviceItems(expectedItems);
Assert.ok(Weave.Service.sync.notCalled);
// Done, hide the panel.
let hiddenPromise = promisePageActionPanelHidden();
BrowserPageActions.panelNode.hidePopup();
await hiddenPromise;
cleanUp();
});
});
add_task(async function sendTabToDevice_syncEnabled() {
// Open a tab that's sendable.
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
await promiseSyncReady();
const sandbox = sinon.createSandbox();
sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
sandbox
.stub(UIState, "get")
.returns({ status: UIState.STATUS_SIGNED_IN, syncEnabled: true });
sandbox.stub(gSync, "isSendableURI").returns(true);
sandbox.spy(fxAccounts.device, "refreshDeviceList");
sandbox.spy(Weave.Service, "sync");
sandbox
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
.callsFake(fxaDeviceId => {
let target = mockTargets.find(c => c.id == fxaDeviceId);
return target ? target.clientRecord : null;
});
sandbox
.stub(Weave.Service.clientsEngine, "getClientType")
.callsFake(
id =>
mockTargets.find(c => c.clientRecord && c.clientRecord.id == id)
.clientRecord.type
);
let cleanUp = () => {
sandbox.restore();
};
registerCleanupFunction(cleanUp);
// Open the panel.
await promisePageActionPanelOpen();
let sendToDeviceButton = document.getElementById(
"pageAction-panel-sendToDevice"
);
Assert.ok(!sendToDeviceButton.disabled);
// Click Send to Device.
let viewPromise = promisePageActionViewShown();
EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {});
let view = await viewPromise;
Assert.equal(view.id, "pageAction-panel-sendToDevice-subview");
let expectedItems = [
{
className: "pageAction-sendToDevice-notReady",
display: "none",
disabled: true,
},
{
attrs: {
label: "No Devices Connected",
},
disabled: true,
},
null,
{
attrs: {
label: "Connect Another Device...",
},
},
{
attrs: {
label: "Learn About Sending Tabs...",
},
},
];
checkSendToDeviceItems(expectedItems);
Assert.ok(
Weave.Service.sync.calledWith({ why: "pageactions", engines: [] })
);
Assert.ok(fxAccounts.device.refreshDeviceList.notCalled);
// Done, hide the panel.
let hiddenPromise = promisePageActionPanelHidden();
BrowserPageActions.panelNode.hidePopup();
@ -670,15 +793,18 @@ add_task(async function sendToDevice_title() {
await BrowserTestUtils.withNewTab("http://example.com/b", async () => {
await promiseSyncReady();
const sandbox = sinon.createSandbox();
sandbox.stub(gSync, "syncReady").get(() => true);
sandbox
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
.get(() => true);
sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => []);
sandbox
.stub(UIState, "get")
.returns({ status: UIState.STATUS_SIGNED_IN });
sandbox.stub(gSync, "isSendableURI").returns(true);
sandbox.stub(gSync, "sendTabTargets").get(() => []);
sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
sandbox
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
.callsFake(fxaDeviceId => {
let target = mockTargets.find(c => c.id == fxaDeviceId);
return target ? target.clientRecord : null;
});
sandbox
.stub(Weave.Service.clientsEngine, "getClientType")
.callsFake(
@ -745,14 +871,21 @@ add_task(async function sendToDevice_inUrlbar() {
await BrowserTestUtils.withNewTab("http://example.com/", async () => {
await promiseSyncReady();
const sandbox = sinon.createSandbox();
sandbox.stub(gSync, "syncReady").get(() => true);
sandbox
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
.get(() => true);
.stub(fxAccounts.device, "recentDeviceList")
.get(() => mockTargets.map(({ id, name, type }) => ({ id, name, type })));
sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN });
sandbox.stub(gSync, "isSendableURI").returns(true);
sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets);
sandbox.stub(gSync, "sendTabToDevice").resolves(true);
sandbox
.stub(fxAccounts.commands.sendTab, "isDeviceCompatible")
.returns(true);
sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
sandbox
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
.callsFake(fxaDeviceId => {
let target = mockTargets.find(c => c.id == fxaDeviceId);
return target ? target.clientRecord : null;
});
sandbox
.stub(Weave.Service.clientsEngine, "getClientType")
.callsFake(
@ -760,6 +893,7 @@ add_task(async function sendToDevice_inUrlbar() {
mockTargets.find(c => c.clientRecord && c.clientRecord.id == id)
.clientRecord.type
);
sandbox.stub(gSync, "sendTabToDevice").resolves(true);
let cleanUp = () => {
sandbox.restore();
@ -796,21 +930,41 @@ add_task(async function sendToDevice_inUrlbar() {
display: "none",
disabled: true,
},
];
for (let target of mockTargets) {
expectedItems.push({
{
attrs: {
clientId: target.id,
label: target.name,
clientType: target.type,
clientId: "1",
label: "bar",
clientType: "desktop",
},
});
}
expectedItems.push(null, {
attrs: {
label: "Send to All Devices",
},
});
{
attrs: {
clientId: "2",
label: "baz",
clientType: "phone",
},
},
{
attrs: {
clientId: "0",
label: "foo",
clientType: "phone",
},
},
{
attrs: {
clientId: "3",
label: "no client record device",
clientType: "phone",
},
},
null,
{
attrs: {
label: "Send to All Devices",
},
},
];
checkSendToDeviceItems(expectedItems, true);
// Get the first device menu item in the panel.

View File

@ -15,8 +15,15 @@ const fxaDevices = [
add_task(async function setup() {
await promiseSyncReady();
await Services.search.init();
// gSync.init() is called in a requestIdleCallback. Force its initialization.
gSync.init();
sinon
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
.callsFake(fxaDeviceId => {
let target = fxaDevices.find(c => c.id == fxaDeviceId);
return target ? target.clientRecord : null;
});
sinon.stub(Weave.Service.clientsEngine, "getClientType").returns("desktop");
await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla");
});
@ -336,6 +343,7 @@ add_task(async function test_page_contextmenu_fxa_disabled() {
// However, browser_contextmenu.js contains tests that verify its presence.
add_task(async function teardown() {
Weave.Service.clientsEngine.getClientByFxaDeviceId.restore();
Weave.Service.clientsEngine.getClientType.restore();
gBrowser.removeCurrentTab();
});

View File

@ -38,8 +38,15 @@ function updateTabContextMenu(tab = gBrowser.selectedTab) {
add_task(async function setup() {
await promiseSyncReady();
await Services.search.init();
// gSync.init() is called in a requestIdleCallback. Force its initialization.
gSync.init();
sinon
.stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
.callsFake(fxaDeviceId => {
let target = fxaDevices.find(c => c.id == fxaDeviceId);
return target ? target.clientRecord : null;
});
sinon.stub(Weave.Service.clientsEngine, "getClientType").returns("desktop");
await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla");
registerCleanupFunction(() => {
@ -195,6 +202,7 @@ add_task(async function test_tab_contextmenu_fxa_disabled() {
});
add_task(async function teardown() {
Weave.Service.clientsEngine.getClientByFxaDeviceId.restore();
Weave.Service.clientsEngine.getClientType.restore();
});

View File

@ -8,23 +8,14 @@ function promiseSyncReady() {
}
function setupSendTabMocks({
syncReady = true,
fxaDevices = null,
state = UIState.STATUS_SIGNED_IN,
isSendableURI = true,
}) {
const sandbox = sinon.createSandbox();
sandbox.stub(gSync, "syncReady").get(() => syncReady);
if (fxaDevices) {
// Clone fxaDevices because it gets sorted in-place.
sandbox
.stub(Weave.Service.clientsEngine, "fxaDevices")
.get(() => [...fxaDevices]);
}
sandbox
.stub(Weave.Service.clientsEngine, "hasSyncedThisSession")
.get(() => !!fxaDevices);
sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices);
sandbox.stub(UIState, "get").returns({ status: state });
sandbox.stub(gSync, "isSendableURI").returns(isSendableURI);
sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
return sandbox;
}

View File

@ -174,12 +174,11 @@ var AboutProtectionsHandler = {
* The login data.
*/
async getLoginData() {
let syncedDevices = [];
let hasFxa = false;
try {
if ((hasFxa = await fxAccounts.accountStatus())) {
syncedDevices = await fxAccounts.getDeviceList();
await fxAccounts.device.refreshDeviceList();
}
} catch (e) {
Cu.reportError("There was an error fetching login data: ", e.message);
@ -192,7 +191,9 @@ var AboutProtectionsHandler = {
return {
hasFxa,
numLogins: userFacingLogins,
numSyncedDevices: syncedDevices.length,
numSyncedDevices: fxAccounts.device.recentDeviceList
? fxAccounts.device.recentDeviceList.length
: 0,
};
},

View File

@ -422,10 +422,6 @@ class FxAccounts {
return this._internal.withVerifiedAccountState(func);
}
getDeviceList() {
return this._internal.getDeviceList();
}
/**
* Returns an array listing all the OAuth clients
* connected to the authenticated user's account.
@ -1101,10 +1097,6 @@ FxAccountsInternal.prototype = {
.then(result => currentState.resolve(result));
},
getDeviceList() {
return this.device.getDeviceList();
},
/*
* Reset state such that any previous flow is canceled.
*/
@ -1121,6 +1113,9 @@ FxAccountsInternal.prototype = {
if (this._commands) {
this._commands = null;
}
if (this._device) {
this._device.reset();
}
// We "abort" the accountState and assume our caller is about to throw it
// away and replace it with a new one.
return this.currentAccountState.abort();

View File

@ -128,12 +128,19 @@ class FxAccountsCommands {
}
async _handleCommands(messages) {
const fxaDevices = await this._fxai.getDeviceList();
try {
await this._fxai.device.refreshDeviceList();
} catch (e) {
log.warn("Error refreshing device list", e);
}
// We debounce multiple incoming tabs so we show a single notification.
const tabsReceived = [];
for (const { data } of messages) {
const { command, payload, sender: senderId } = data;
const sender = senderId ? fxaDevices.find(d => d.id == senderId) : null;
const sender =
senderId && this._fxai.device.recentDeviceList
? this._fxai.device.recentDeviceList.find(d => d.id == senderId)
: null;
if (!sender) {
log.warn(
"Incoming command is from an unknown device (maybe disconnected?)"

View File

@ -14,6 +14,8 @@ const {
ERRNO_DEVICE_SESSION_CONFLICT,
ERRNO_UNKNOWN_DEVICE,
ON_NEW_DEVICE_ID,
ON_DEVICE_CONNECTED_NOTIFICATION,
ON_DEVICE_DISCONNECTED_NOTIFICATION,
} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
const { DEVICE_TYPE_DESKTOP } = ChromeUtils.import(
@ -44,9 +46,27 @@ const PREF_DEPRECATED_DEVICE_NAME = "services.sync.client.name";
class FxAccountsDevice {
constructor(fxai) {
this._fxai = fxai;
this._deviceListCache = null;
// The generation avoids a race where we'll cache a stale device list if the
// user signs out during a background refresh. It works like this: during a
// refresh, we store the current generation, fetch the new list from the
// server, and compare the stored generation to the current one. Since we
// increment the generation on reset, we know that the fetched list isn't
// valid if the generations are different.
this._generation = 0;
// The current version of the device registration, we use this to re-register
// devices after we update what we send on device registration.
this.DEVICE_REGISTRATION_VERSION = 2;
// This is to avoid multiple sequential syncs ending up calling
// this expensive endpoint multiple times in a row.
this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 1 * 60 * 1000; // 1 minute
// Invalidate our cached device list when a device is connected or disconnected.
Services.obs.addObserver(this, ON_DEVICE_CONNECTED_NOTIFICATION, true);
Services.obs.addObserver(this, ON_DEVICE_DISCONNECTED_NOTIFICATION, true);
}
async getLocalId() {
@ -175,22 +195,87 @@ class FxAccountsDevice {
);
}
getDeviceList() {
return this._fxai.withVerifiedAccountState(async state => {
let accountData = await state.getUserAccountData();
/**
* Returns the most recently fetched device list, or `null` if the list
* hasn't been fetched yet. This is synchronous, so that consumers like
* Send Tab can render the device list right away, without waiting for
* it to refresh.
*
* @type {?Array}
*/
get recentDeviceList() {
return this._deviceListCache ? this._deviceListCache.devices : null;
}
const devices = await this._fxai.fxAccountsClient.getDeviceList(
accountData.sessionToken
);
/**
* Refreshes the device list. After this function returns, consumers can
* access the new list using the `recentDeviceList` getter. Note that
* multiple concurrent calls to `refreshDeviceList` will only refresh the
* list once.
*
* @param {Boolean} [options.ignoreCached]
* If `true`, forces a refresh, even if the cached device list is
* still fresh. Defaults to `false`.
* @return {Promise<Boolean>}
* `true` if the list was refreshed, `false` if the cached list is
* fresh. Rejects if an error occurs refreshing the list or device
* push registration.
*/
async refreshDeviceList({ ignoreCached = false } = {}) {
if (this._fetchAndCacheDeviceListPromise) {
// If we're already refreshing the list in the background, let that
// finish.
return this._fetchAndCacheDeviceListPromise;
}
if (ignoreCached || !this._deviceListCache) {
return this._fetchAndCacheDeviceList();
}
if (
this._fxai.now() - this._deviceListCache.lastFetch <
this.TIME_BETWEEN_FXA_DEVICES_FETCH_MS
) {
// If our recent device list is still fresh, skip the request to
// refresh it.
return false;
}
return this._fetchAndCacheDeviceList();
}
// Check if our push registration is still good.
const ourDevice = devices.find(device => device.isCurrentDevice);
if (ourDevice.pushEndpointExpired) {
await this._fxai.fxaPushService.unsubscribe();
await this._registerOrUpdateDevice(accountData);
}
return devices;
});
async _fetchAndCacheDeviceList() {
if (this._fetchAndCacheDeviceListPromise) {
return this._fetchAndCacheDeviceListPromise;
}
let generation = this._generation;
return (this._fetchAndCacheDeviceListPromise = this._fxai
.withVerifiedAccountState(async state => {
let accountData = await state.getUserAccountData([
"sessionToken",
"device",
]);
let devices = await this._fxai.fxAccountsClient.getDeviceList(
accountData.sessionToken
);
if (generation != this._generation) {
throw new Error("Another user has signed in");
}
this._deviceListCache = {
lastFetch: this._fxai.now(),
devices,
};
// Check if our push registration is still good.
const ourDevice = devices.find(device => device.isCurrentDevice);
if (ourDevice.pushEndpointExpired) {
await this._fxai.fxaPushService.unsubscribe();
await this._registerOrUpdateDevice(accountData);
}
return true;
})
.finally(_ => {
this._fetchAndCacheDeviceListPromise = null;
}));
}
async updateDeviceRegistration() {
@ -363,8 +448,46 @@ class FxAccountsDevice {
);
}
}
reset() {
this._deviceListCache = null;
this._generation++;
this._fetchAndCacheDeviceListPromise = null;
}
// Kick off a background refresh when a device is connected or disconnected.
observe(subject, topic, data) {
switch (topic) {
case ON_DEVICE_CONNECTED_NOTIFICATION:
this._fetchAndCacheDeviceList().catch(error => {
log.warn(
"failed to refresh devices after connecting a new device",
error
);
});
break;
case ON_DEVICE_DISCONNECTED_NOTIFICATION:
let json = JSON.parse(data);
if (!json.isLocalDevice) {
// If we're the device being disconnected, don't bother fetching a new
// list, since our session token is now invalid.
this._fetchAndCacheDeviceList().catch(error => {
log.warn(
"failed to refresh devices after disconnecting a device",
error
);
});
}
break;
}
}
}
FxAccountsDevice.prototype.QueryInterface = ChromeUtils.generateQI([
Ci.nsIObserver,
Ci.nsISupportsWeakReference,
]);
function urlsafeBase64Encode(buffer) {
return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
}

View File

@ -9,10 +9,15 @@ const { FxAccounts } = ChromeUtils.import(
const { FxAccountsClient } = ChromeUtils.import(
"resource://gre/modules/FxAccountsClient.jsm"
);
const { FxAccountsDevice } = ChromeUtils.import(
"resource://gre/modules/FxAccountsDevice.jsm"
);
const {
ERRNO_DEVICE_SESSION_CONFLICT,
ERRNO_TOO_MANY_CLIENT_REQUESTS,
ERRNO_UNKNOWN_DEVICE,
ON_DEVICE_CONNECTED_NOTIFICATION,
ON_DEVICE_DISCONNECTED_NOTIFICATION,
} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
var { AccountState } = ChromeUtils.import(
"resource://gre/modules/FxAccounts.jsm",
@ -635,12 +640,137 @@ add_task(async function test_devicelist_pushendpointexpired() {
]);
};
await fxa.getDeviceList();
await fxa.device.refreshDeviceList();
Assert.equal(spy.getDeviceList.count, 1);
Assert.equal(spy.updateDevice.count, 1);
});
add_task(async function test_refreshDeviceList() {
let credentials = getTestUser("baz");
let storage = new MockStorageManager();
storage.initialize(credentials);
let state = new AccountState(storage);
let fxAccountsClient = new MockFxAccountsClient({
id: "deviceAAAAAA",
name: "iPhone",
type: "phone",
sessionToken: credentials.sessionToken,
});
let spy = {
getDeviceList: { count: 0 },
};
fxAccountsClient.getDeviceList = (function(old) {
return function getDeviceList() {
spy.getDeviceList.count += 1;
return old.apply(this, arguments);
};
})(fxAccountsClient.getDeviceList);
let fxai = {
_now: Date.now(),
fxAccountsClient,
now() {
return this._now;
},
withVerifiedAccountState(func) {
// Ensure `func` is called asynchronously.
return Promise.resolve().then(_ => func(state));
},
fxaPushService: null,
};
let device = new FxAccountsDevice(fxai);
Assert.equal(
device.recentDeviceList,
null,
"Should not have device list initially"
);
Assert.ok(await device.refreshDeviceList(), "Should refresh list");
Assert.deepEqual(
device.recentDeviceList,
[
{
id: "deviceAAAAAA",
name: "iPhone",
type: "phone",
isCurrentDevice: true,
},
],
"Should fetch device list"
);
Assert.equal(
spy.getDeviceList.count,
1,
"Should make request to refresh list"
);
Assert.ok(
!(await device.refreshDeviceList()),
"Should not refresh device list if fresh"
);
fxai._now += device.TIME_BETWEEN_FXA_DEVICES_FETCH_MS;
let refreshPromise = device.refreshDeviceList();
let secondRefreshPromise = device.refreshDeviceList();
Assert.ok(
await Promise.all([refreshPromise, secondRefreshPromise]),
"Should refresh list if stale"
);
Assert.equal(
spy.getDeviceList.count,
2,
"Should only make one request if called with pending request"
);
device.observe(null, ON_DEVICE_CONNECTED_NOTIFICATION);
await device.refreshDeviceList();
Assert.equal(
spy.getDeviceList.count,
3,
"Should refresh device list after connecting new device"
);
device.observe(
null,
ON_DEVICE_DISCONNECTED_NOTIFICATION,
JSON.stringify({ isLocalDevice: false })
);
await device.refreshDeviceList();
Assert.equal(
spy.getDeviceList.count,
4,
"Should refresh device list after disconnecting device"
);
device.observe(
null,
ON_DEVICE_DISCONNECTED_NOTIFICATION,
JSON.stringify({ isLocalDevice: true })
);
await device.refreshDeviceList();
Assert.equal(
spy.getDeviceList.count,
4,
"Should not refresh device list after disconnecting this device"
);
let refreshBeforeResetPromise = device.refreshDeviceList({
ignoreCached: true,
});
device.reset();
await Assert.rejects(refreshBeforeResetPromise, /Another user has signed in/);
Assert.equal(
device.recentDeviceList,
null,
"Should clear device list after resetting"
);
Assert.ok(
await device.refreshDeviceList(),
"Should fetch new list after resetting"
);
});
function expandHex(two_hex) {
// Return a 64-character hex string, encoding 32 identical bytes.
let eight_hex = two_hex + two_hex + two_hex + two_hex;

View File

@ -65,10 +65,6 @@ const STALE_CLIENT_REMOTE_AGE = 604800; // 7 days
// TTL of the message sent to another device when sending a tab
const NOTIFY_TAB_SENT_TTL_SECS = 1 * 3600; // 1 hour
// This is to avoid multiple sequential syncs ending up calling
// this expensive endpoint multiple times in a row.
const TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 10 * 1000;
// Reasons behind sending collection_changed push notifications.
const COLLECTION_MODIFIED_REASON_SENDTAB = "sendtab";
const COLLECTION_MODIFIED_REASON_FIRSTSYNC = "firstsync";
@ -159,10 +155,6 @@ ClientEngine.prototype = {
Svc.Prefs.set(this.name + ".lastRecordUpload", Math.floor(value));
},
get fxaDevices() {
return this._fxaDevices;
},
get remoteClients() {
// return all non-stale clients for external consumption.
return Object.values(this._store._remoteClients).filter(v => !v.stale);
@ -259,6 +251,19 @@ ClientEngine.prototype = {
return null;
},
getClientByFxaDeviceId(fxaDeviceId) {
for (let id in this._store._remoteClients) {
let client = this._store._remoteClients[id];
if (client.stale) {
continue;
}
if (client.fxaDeviceId == fxaDeviceId) {
return client;
}
}
return null;
},
getClientType(id) {
const client = this._store._remoteClients[id];
if (client.type == DEVICE_TYPE_DESKTOP) {
@ -387,26 +392,11 @@ ClientEngine.prototype = {
},
async _fetchFxADevices() {
const now = new Date().getTime();
if (
(this._lastFxADevicesFetch || 0) + TIME_BETWEEN_FXA_DEVICES_FETCH_MS >=
now
) {
return;
}
const remoteClients = Object.values(this.remoteClients);
try {
this._fxaDevices = await this.fxAccounts.getDeviceList();
for (const device of this._fxaDevices) {
device.clientRecord = remoteClients.find(
c => c.fxaDeviceId == device.id
);
}
await this.fxAccounts.device.refreshDeviceList();
} catch (e) {
this._log.error("Could not retrieve the FxA device list", e);
this._fxaDevices = [];
this._log.error("Could not refresh the FxA device list", e);
}
this._lastFxADevicesFetch = now;
// We assume that clients not present in the FxA Device Manager list have been
// disconnected and so are stale
@ -414,7 +404,9 @@ ClientEngine.prototype = {
let localClients = Object.values(this._store._remoteClients)
.filter(client => client.fxaDeviceId) // iOS client records don't have fxaDeviceId
.map(client => client.fxaDeviceId);
const fxaClients = this._fxaDevices.map(device => device.id);
const fxaClients = this.fxAccounts.device.recentDeviceList
? this.fxAccounts.device.recentDeviceList.map(device => device.id)
: [];
this._knownStaleFxADeviceIds = Utils.arraySub(localClients, fxaClients);
},

View File

@ -990,9 +990,10 @@ add_task(async function test_clients_not_in_fxa_list() {
getLocalType() {
return fxAccounts.device.getLocalType();
},
},
getDeviceList() {
return Promise.resolve([{ id: remoteId }]);
recentDeviceList: [{ id: remoteId }],
refreshDeviceList() {
return Promise.resolve(true);
},
},
};
@ -1069,9 +1070,10 @@ add_task(async function test_dupe_device_ids() {
getLocalType() {
return fxAccounts.device.getLocalType();
},
},
getDeviceList() {
return Promise.resolve([{ id: remoteDeviceId }]);
recentDeviceList: [{ id: remoteDeviceId }],
refreshDeviceList() {
return Promise.resolve(true);
},
},
};