merge fx-team to mozilla-central a=merge

This commit is contained in:
Carsten "Tomcat" Book 2016-01-05 11:56:41 +01:00
commit 766339cc81
207 changed files with 3038 additions and 560 deletions

View File

@ -3,6 +3,10 @@
"plugins": [
"mozilla"
],
"rules": {
"mozilla/components-imports": 1,
"mozilla/import-globals-from": 1,
},
"env": {
"es6": true
},

View File

@ -537,7 +537,7 @@ SocialShare = {
populateProviderMenu: function() {
if (!this.iframe)
return;
let providers = [p for (p of Social.providers) if (p.shareURL)];
let providers = Social.providers.filter(p => p.shareURL);
let hbox = document.getElementById("social-share-provider-buttons");
// remove everything before the add-share-provider button (which should also
// be lastChild if any share providers were added)
@ -976,7 +976,7 @@ SocialSidebar = {
// first, otherwise fallback to the first provider in the list
let sbrowser = document.getElementById("social-sidebar-browser");
let origin = sbrowser.getAttribute("origin");
let providers = [p for (p of Social.providers) if (p.sidebarURL)];
let providers = Social.providers.filter(p => p.sidebarURL);
let provider;
if (origin)
provider = Social._getProviderFromOrigin(origin);
@ -1093,7 +1093,7 @@ SocialSidebar = {
menu.removeChild(providerMenuSep.previousSibling);
}
// only show a selection in the sidebar header menu if there is more than one
let providers = [p for (p of Social.providers) if (p.sidebarURL)];
let providers = Social.providers.filter(p => p.sidebarURL);
if (providers.length < 2 && menu.id != "viewSidebarMenu") {
providerMenuSep.hidden = true;
return;
@ -1153,7 +1153,9 @@ ToolbarHelper.prototype = {
},
clearPalette: function() {
[this.removeProviderButton(p.origin) for (p of Social.providers)];
for (let p of Social.providers) {
this.removeProviderButton(p.origin);
}
},
// should be called on enable of a provider
@ -1320,8 +1322,7 @@ var SocialMarksWidgetListener = {
*/
SocialMarks = {
get nodes() {
let providers = [p for (p of Social.providers) if (p.markURL)];
for (let p of providers) {
for (let p of Social.providers.filter(p => p.markURL)) {
let widgetId = SocialMarks._toolbarHelper.idFromOrigin(p.origin);
let widget = CustomizableUI.getWidget(widgetId);
if (!widget)
@ -1348,8 +1349,8 @@ SocialMarks = {
// also means that populateToolbarPalette must be called prior to using this
// method, otherwise you get a big fat zero. For our use case with context
// menu's, this is ok.
return [p for (p of Social.providers) if (p.markURL &&
document.getElementById(this._toolbarHelper.idFromOrigin(p.origin)))];
return Social.providers.filter(p => p.markURL &&
document.getElementById(this._toolbarHelper.idFromOrigin(p.origin)));
},
populateContextMenu: function() {
@ -1357,8 +1358,10 @@ SocialMarks = {
let providers = this.getProviders();
// remove all previous entries by class
let menus = [m for (m of document.getElementsByClassName("context-socialmarks"))];
[m.parentNode.removeChild(m) for (m of menus)];
let menus = [...document.getElementsByClassName("context-socialmarks")];
for (let m of menus) {
m.parentNode.removeChild(m);
}
let contextMenus = [
{

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/chrome.eslintrc"
]
}

View File

@ -0,0 +1,6 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc",
"../../../../../testing/mochitest/mochitest.eslintrc",
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -5,7 +5,7 @@
browser.jar:
content/browser/customizableui/aboutCustomizing.xul
content/browser/customizableui/panelUI.css
* content/browser/customizableui/panelUI.js
content/browser/customizableui/panelUI.js
content/browser/customizableui/panelUI.xml
content/browser/customizableui/toolbar.xml

View File

@ -10,6 +10,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "Promise",
"resource://gre/modules/Promise.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ShortcutUtils",
"resource://gre/modules/ShortcutUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
"resource://gre/modules/AppConstants.jsm");
/**
* Maintains the state and dispatches events for the main menu panel.
@ -488,12 +490,14 @@ const PanelUI = {
},
_updateQuitTooltip: function() {
#ifndef XP_WIN
#ifdef XP_MACOSX
let tooltipId = "quit-button.tooltiptext.mac";
#else
let tooltipId = "quit-button.tooltiptext.linux2";
#endif
if (AppConstants.platform == "win") {
return;
}
let tooltipId = AppConstants.platform == "macosx" ?
"quit-button.tooltiptext.mac" :
"quit-button.tooltiptext.linux2";
let brands = Services.strings.createBundle("chrome://branding/locale/brand.properties");
let stringArgs = [brands.GetStringFromName("brandShortName")];
@ -502,7 +506,6 @@ const PanelUI = {
let tooltipString = CustomizableUI.getLocalizedProperty({x: tooltipId}, "x", stringArgs);
let quitButton = document.getElementById("PanelUI-quit");
quitButton.setAttribute("tooltiptext", tooltipString);
#endif
},
_overlayScrollListenerBoundFn: null,

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -471,11 +471,7 @@ const DownloadsPanel = {
}
let pasting = aEvent.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_V &&
#ifdef XP_MACOSX
aEvent.metaKey;
#else
aEvent.ctrlKey;
#endif
aEvent.getModifierState("Accel");
if (!pasting) {
return;

View File

@ -6,7 +6,7 @@ browser.jar:
* content/browser/downloads/download.xml (content/download.xml)
content/browser/downloads/download.css (content/download.css)
content/browser/downloads/downloads.css (content/downloads.css)
* content/browser/downloads/downloads.js (content/downloads.js)
content/browser/downloads/downloads.js (content/downloads.js)
* content/browser/downloads/downloadsOverlay.xul (content/downloadsOverlay.xul)
content/browser/downloads/indicator.js (content/indicator.js)
content/browser/downloads/indicatorOverlay.xul (content/indicatorOverlay.xul)

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/mochitest.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/chrome.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/chrome.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -379,19 +379,18 @@ var gMainPane = {
// We should only include visible & non-pinned tabs
tabs = win.gBrowser.visibleTabs.slice(win.gBrowser._numPinnedTabs);
tabs = tabs.filter(this.isNotAboutPreferences);
}
return tabs;
},
/**
* Check to see if a tab is not about:preferences
*/
isNotAboutPreferences: function (aElement, aIndex, aArray)
{
return (aElement.linkedBrowser.currentURI.spec.startsWith != "about:preferences");
return !aElement.linkedBrowser.currentURI.spec.startsWith("about:preferences");
},
/**

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -19,6 +19,7 @@ skip-if = os != "win" # This test tests the windows-specific app selection dialo
[browser_cookies_exceptions.js]
[browser_healthreport.js]
skip-if = true || !healthreport || (os == 'linux' && debug) # Bug 1185403 for the "true"
[browser_homepages_filter_aboutpreferences.js]
[browser_notifications_do_not_disturb.js]
[browser_permissions_urlFieldHidden.js]
[browser_proxy_backup.js]

View File

@ -0,0 +1,20 @@
add_task(function() {
is(gBrowser.currentURI.spec, "about:blank", "Test starts with about:blank open");
yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:home");
yield openPreferencesViaOpenPreferencesAPI("paneGeneral", null, {leaveOpen: true});
let doc = gBrowser.contentDocument;
is(gBrowser.currentURI.spec, "about:preferences#general",
"#general should be in the URI for about:preferences");
let oldHomepagePref = Services.prefs.getCharPref("browser.startup.homepage");
let useCurrent = doc.getElementById("useCurrent");
useCurrent.click();
is(gBrowser.tabs.length, 3, "Three tabs should be open");
is(Services.prefs.getCharPref("browser.startup.homepage"), "about:blank|about:home",
"about:blank and about:home should be the only homepages set");
Services.prefs.setCharPref("browser.startup.homepage", oldHomepagePref);
yield BrowserTestUtils.removeTab(gBrowser.selectedTab);
yield BrowserTestUtils.removeTab(gBrowser.selectedTab);
});

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -24,9 +24,6 @@
// Rules from the mozilla plugin
"mozilla/balanced-listeners": 2,
"mozilla/components-imports": 1,
"mozilla/import-globals-from": 1,
"mozilla/import-headjs-globals": 1,
"mozilla/mark-test-function-used": 1,
"mozilla/no-aArgs": 1,
"mozilla/no-cpows-in-tests": 1,

View File

@ -1,44 +1,10 @@
// Parent config file for all devtools browser mochitest files.
{
"rules": {
// Only disallow non-global unused vars, so that head.js does not produce
// errors.
"no-unused-vars": [2, {"vars": "local"}]
},
"extends": [
"../testing/mochitest/browser.eslintrc"
],
// All globals made available in the test environment.
"globals": {
"add_task": true,
"Assert": true,
"BrowserTestUtils": true,
"content": true,
"ContentTask": true,
"document": true,
"EventUtils": true,
"executeSoon": true,
"export_assertions": true,
"finish": true,
"gBrowser": true,
"gDevTools": true,
"getRootDirectory": true,
"getTestFilePath": true,
"gTestPath": true,
"info": true,
"is": true,
"isnot": true,
"navigator": true,
"ok": true,
"promise": true,
"registerCleanupFunction": true,
"requestLongerTimeout": true,
"setTimeout": true,
"SimpleTest": true,
"SpecialPowers": true,
"todo": true,
"todo_is": true,
"todo_isnot": true,
"waitForClipboard": true,
"waitForExplicitFinish": true,
"waitForFocus": true,
"window": true,
}
}

View File

@ -1,11 +1,11 @@
// Parent config file for all devtools browser mochitest files.
{
"extends": [
"../testing/xpcshell/xpcshell.eslintrc"
],
"rules": {
// Allow non-camelcase so that run_test doesn't produce a warning.
"camelcase": 0,
// Only disallow non-global unused vars, so that things like the test
// function do not produce errors.
"no-unused-vars": [2, {"vars": "local"}],
// Allow using undefined variables so that tests can refer to functions
// and variables defined in head.js files, without having to maintain a
// list of globals in each .eslintrc file.
@ -13,39 +13,5 @@
// from head.js files.
"no-undef": 0,
"block-scoped-var": 0
},
// All globals made available in the test environment.
"globals": {
"add_task": true,
"add_test": true,
"Assert": true,
"deepEqual": true,
"do_check_eq": true,
"do_check_false": true,
"do_check_neq": true,
"do_check_null": true,
"do_check_true": true,
"do_execute_soon": true,
"do_get_cwd": true,
"do_get_file": true,
"do_get_idle": true,
"do_get_profile": true,
"do_load_module": true,
"do_parse_document": true,
"do_print": true,
"do_register_cleanup": true,
"do_test_finished": true,
"do_test_pending": true,
"do_throw": true,
"do_timeout": true,
"equal": true,
"load": true,
"notDeepEqual": true,
"notEqual": true,
"notStrictEqual": true,
"ok": true,
"run_next_test": true,
"run_test": true,
"strictEqual": true,
}
}
}

View File

@ -51,7 +51,7 @@ add_task(function* runTest() {
},
{
asyncCause: "promise callback",
columnNumber: 1,
columnNumber: 3,
filename: TEST_URI,
functionName: "time1",
language: 2,

View File

@ -3197,36 +3197,8 @@ FrameActor.prototype = {
return this.frame.arguments.map(arg => createValueGrip(arg,
this.threadActor._pausePool, this.threadActor.objectGrip));
},
/**
* Handle a protocol request to pop this frame from the stack.
*
* @param aRequest object
* The protocol request object.
*/
onPop: function (aRequest) {
// TODO: remove this when Debugger.Frame.prototype.pop is implemented
if (typeof this.frame.pop != "function") {
return { error: "notImplemented",
message: "Popping frames is not yet implemented." };
}
while (this.frame != this.threadActor.dbg.getNewestFrame()) {
this.threadActor.dbg.getNewestFrame().pop();
}
this.frame.pop(aRequest.completionValue);
// TODO: return the watches property when frame pop watch actors are
// implemented.
return { from: this.actorID };
}
};
FrameActor.prototype.requestTypes = {
"pop": FrameActor.prototype.onPop,
};
/**
* Creates a BreakpointActor. BreakpointActors exist for the lifetime of their
* containing thread and are responsible for deleting breakpoints, handling

View File

@ -20,7 +20,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1107592
/* Async parent frames from pushPrefEnv don't show up in e10s. */
var isE10S = !SpecialPowers.isMainProcess();
if (!isE10S && SpecialPowers.getBoolPref("javascript.options.asyncstack")) {
asyncFrame = `Async*@${file}:153:1
asyncFrame = `Async*@${file}:153:3
`;
} else {
asyncFrame = "";

View File

@ -41,7 +41,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1107592
var isE10S = !SpecialPowers.isMainProcess();
var asyncStack = SpecialPowers.getBoolPref("javascript.options.asyncstack");
var ourFile = location.href;
var parentFrame = (asyncStack && !isE10S) ? `Async*@${ourFile}:121:1
var parentFrame = (asyncStack && !isE10S) ? `Async*@${ourFile}:121:3
` : "";
Promise.all([

View File

@ -3243,21 +3243,17 @@ js::PCToLineNumber(unsigned startLine, jssrcnote* notes, jsbytecode* code, jsbyt
ptrdiff_t target = pc - code;
for (jssrcnote* sn = notes; !SN_IS_TERMINATOR(sn); sn = SN_NEXT(sn)) {
offset += SN_DELTA(sn);
SrcNoteType type = (SrcNoteType) SN_TYPE(sn);
if (type == SRC_SETLINE) {
if (offset <= target)
lineno = unsigned(GetSrcNoteOffset(sn, 0));
column = 0;
} else if (type == SRC_NEWLINE) {
if (offset <= target)
lineno++;
column = 0;
}
if (offset > target)
break;
if (type == SRC_COLSPAN) {
SrcNoteType type = (SrcNoteType) SN_TYPE(sn);
if (type == SRC_SETLINE) {
lineno = unsigned(GetSrcNoteOffset(sn, 0));
column = 0;
} else if (type == SRC_NEWLINE) {
lineno++;
column = 0;
} else if (type == SRC_COLSPAN) {
ptrdiff_t colspan = SN_OFFSET_TO_COLSPAN(GetSrcNoteOffset(sn, 0));
MOZ_ASSERT(ptrdiff_t(column) + colspan >= 0);
column += colspan;

View File

@ -123,6 +123,7 @@ dependencies {
testCompile 'junit:junit:4.12'
testCompile 'org.robolectric:robolectric:3.0'
testCompile 'org.simpleframework:simple-http:4.1.13'
testCompile 'org.mockito:mockito-core:1.10.19'
}
apply plugin: 'idea'

View File

@ -267,7 +267,7 @@ public class ZoomedView extends FrameLayout implements LayerView.DynamicToolbarL
changeZoomFactorButton = (TextView) findViewById(R.id.change_zoom_factor);
zoomedImageView = (ImageView) findViewById(R.id.zoomed_image_view);
setTextInZoomFactorButton(zoomFactor);
updateUI();
toolbarHeight = getResources().getDimensionPixelSize(R.dimen.zoomed_view_toolbar_height);
containterSize = getResources().getDimensionPixelSize(R.dimen.drawable_dropshadow_size);
@ -504,19 +504,30 @@ public class ZoomedView extends FrameLayout implements LayerView.DynamicToolbarL
return (GeckoAppShell.getScreenDepth() == 24) ? Bitmap.Config.ARGB_8888 : Bitmap.Config.RGB_565;
}
private void updateUI() {
// onFinishInflate is not yet completed, the update of the UI will be done later
if (changeZoomFactorButton == null) {
return;
}
if (isSimplifiedUI) {
changeZoomFactorButton.setVisibility(View.INVISIBLE);
} else {
setTextInZoomFactorButton(zoomFactor);
changeZoomFactorButton.setVisibility(View.VISIBLE);
}
}
private void getPrefs() {
prefSimplifiedUIObserverId = PrefsHelper.getPref("ui.zoomedview.simplified", new PrefsHelper.PrefHandlerBase() {
@Override
public void prefValue(String pref, boolean simplified) {
isSimplifiedUI = simplified;
if (simplified) {
changeZoomFactorButton.setVisibility(View.INVISIBLE);
zoomFactor = (float) defaultZoomFactor;
} else {
zoomFactor = ZOOM_FACTORS_LIST[currentZoomFactorIndex];
setTextInZoomFactorButton(zoomFactor);
changeZoomFactorButton.setVisibility(View.VISIBLE);
}
updateUI();
}
@Override
@ -533,8 +544,8 @@ public class ZoomedView extends FrameLayout implements LayerView.DynamicToolbarL
zoomFactor = (float) defaultZoomFactor;
} else {
zoomFactor = ZOOM_FACTORS_LIST[currentZoomFactorIndex];
setTextInZoomFactorButton(zoomFactor);
}
updateUI();
}
@Override

View File

@ -0,0 +1,132 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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/. */
package org.mozilla.gecko.dlc;
import android.content.Context;
import android.support.annotation.IntDef;
import android.util.Log;
import org.mozilla.gecko.background.nativecode.NativeCrypto;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import org.mozilla.gecko.sync.Utils;
import org.mozilla.gecko.util.IOUtils;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
public abstract class BaseAction {
private static final String LOGTAG = "GeckoDLCBaseAction";
/**
* Exception indicating a recoverable error has happened. Download of the content will be retried later.
*/
/* package-private */ static class RecoverableDownloadContentException extends Exception {
private static final long serialVersionUID = -2246772819507370734L;
@IntDef({MEMORY, DISK_IO, SERVER, NETWORK})
public @interface ErrorType {}
public static final int MEMORY = 1;
public static final int DISK_IO = 2;
public static final int SERVER = 3;
public static final int NETWORK = 4;
private int errorType;
public RecoverableDownloadContentException(@ErrorType int errorType, String message) {
super(message);
this.errorType = errorType;
}
public RecoverableDownloadContentException(@ErrorType int errorType, Throwable cause) {
super(cause);
this.errorType = errorType;
}
@ErrorType
public int getErrorType() {
return errorType;
}
/**
* Should this error be counted as failure? If this type of error will happen multiple times in a row then this
* error will be treated as permanently and the operation will not be tried again until the content changes.
*/
public boolean shouldBeCountedAsFailure() {
if (NETWORK == errorType) {
return false; // Always retry after network errors
}
return true;
}
}
/**
* If this exception is thrown the content will be marked as unrecoverable, permanently failed and we will not try
* downloading it again - until a newer version of the content is available.
*/
/* package-private */ static class UnrecoverableDownloadContentException extends Exception {
private static final long serialVersionUID = 8956080754787367105L;
public UnrecoverableDownloadContentException(String message) {
super(message);
}
public UnrecoverableDownloadContentException(Throwable cause) {
super(cause);
}
}
public abstract void perform(Context context, DownloadContentCatalog catalog);
protected File getDestinationFile(Context context, DownloadContent content) throws UnrecoverableDownloadContentException {
if (content.isFont()) {
return new File(new File(context.getApplicationInfo().dataDir, "fonts"), content.getFilename());
}
// Unrecoverable: We downloaded a file and we don't know what to do with it (Should not happen)
throw new UnrecoverableDownloadContentException("Can't determine destination for kind: " + content.getKind());
}
protected boolean verify(File file, String expectedChecksum)
throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
InputStream inputStream = null;
try {
inputStream = new BufferedInputStream(new FileInputStream(file));
byte[] ctx = NativeCrypto.sha256init();
if (ctx == null) {
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.MEMORY,
"Could not create SHA-256 context");
}
byte[] buffer = new byte[4096];
int read;
while ((read = inputStream.read(buffer)) != -1) {
NativeCrypto.sha256update(ctx, buffer, read);
}
String actualChecksum = Utils.byte2Hex(NativeCrypto.sha256finalize(ctx));
if (!actualChecksum.equalsIgnoreCase(expectedChecksum)) {
Log.w(LOGTAG, "Checksums do not match. Expected=" + expectedChecksum + ", Actual=" + actualChecksum);
return false;
}
return true;
} catch (IOException e) {
// Recoverable: Just I/O discontinuation
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e);
} finally {
IOUtils.safeStreamClose(inputStream);
}
}
}

View File

@ -5,17 +5,28 @@
package org.mozilla.gecko.dlc;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.support.v4.net.ConnectivityManagerCompat;
import android.util.Log;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.background.nativecode.NativeCrypto;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.sync.Utils;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import org.mozilla.gecko.util.HardwareUtils;
import org.mozilla.gecko.util.IOUtils;
import android.content.Context;
import android.net.ConnectivityManager;
import android.support.v4.net.ConnectivityManagerCompat;
import android.util.Log;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.zip.GZIPInputStream;
import ch.boye.httpclientandroidlib.HttpEntity;
import ch.boye.httpclientandroidlib.HttpResponse;
@ -25,97 +36,138 @@ import ch.boye.httpclientandroidlib.client.methods.HttpGet;
import ch.boye.httpclientandroidlib.impl.client.DefaultHttpRequestRetryHandler;
import ch.boye.httpclientandroidlib.impl.client.HttpClientBuilder;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.NoSuchAlgorithmException;
import java.util.zip.GZIPInputStream;
/* package-private */ class DownloadContentHelper {
private static final String LOGTAG = "GeckoDLCHelper";
public static final String ACTION_STUDY_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.STUDY";
public static final String ACTION_VERIFY_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.VERIFY";
public static final String ACTION_DOWNLOAD_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.DOWNLOAD";
private static final String CDN_BASE_URL = "https://mobile.cdn.mozilla.net/";
/**
* Download content that has been scheduled during "study" or "verify".
*/
public class DownloadAction extends BaseAction {
private static final String LOGTAG = "DLCDownloadAction";
private static final String CACHE_DIRECTORY = "downloadContent";
private static final String CDN_BASE_URL = "https://mobile.cdn.mozilla.net/";
/**
* Exception indicating a recoverable error has happened. Download of the content will be retried later.
*/
/* package-private */ static class RecoverableDownloadContentException extends Exception {
private static final long serialVersionUID = -2246772819507370734L;
public RecoverableDownloadContentException(String message) {
super(message);
}
public RecoverableDownloadContentException(Throwable cause) {
super(cause);
}
public interface Callback {
void onContentDownloaded(DownloadContent content);
}
/**
* If this exception is thrown the content will be marked as unrecoverable, permanently failed and we will not try
* downloading it again - until a newer version of the content is available.
*/
/* package-private */ static class UnrecoverableDownloadContentException extends Exception {
private static final long serialVersionUID = 8956080754787367105L;
private Callback callback;
public UnrecoverableDownloadContentException(String message) {
super(message);
}
public UnrecoverableDownloadContentException(Throwable cause) {
super(cause);
}
public DownloadAction(Callback callback) {
this.callback = callback;
}
/* package-private */ static HttpClient buildHttpClient() {
// TODO: Implement proxy support (Bug 1209496)
return HttpClientBuilder.create()
.setUserAgent(HardwareUtils.isTablet() ?
AppConstants.USER_AGENT_FENNEC_TABLET :
AppConstants.USER_AGENT_FENNEC_MOBILE)
.setRetryHandler(new DefaultHttpRequestRetryHandler())
.build();
}
public void perform(Context context, DownloadContentCatalog catalog) {
Log.d(LOGTAG, "Downloading content..");
/* package-private */ static File createTemporaryFile(Context context, DownloadContent content)
throws RecoverableDownloadContentException {
File cacheDirectory = new File(context.getCacheDir(), CACHE_DIRECTORY);
if (!cacheDirectory.exists() && !cacheDirectory.mkdirs()) {
// Recoverable: File system might not be mounted NOW and we didn't download anything yet anyways.
throw new RecoverableDownloadContentException("Could not create cache directory: " + cacheDirectory);
if (!isConnectedToNetwork(context)) {
Log.d(LOGTAG, "No connected network available. Postponing download.");
// TODO: Reschedule download (bug 1209498)
return;
}
return new File(cacheDirectory, content.getDownloadChecksum() + "-" + content.getId());
if (isActiveNetworkMetered(context)) {
Log.d(LOGTAG, "Network is metered. Postponing download.");
// TODO: Reschedule download (bug 1209498)
return;
}
final HttpClient client = buildHttpClient();
for (DownloadContent content : catalog.getScheduledDownloads()) {
Log.d(LOGTAG, "Downloading: " + content);
File temporaryFile = null;
try {
File destinationFile = getDestinationFile(context, content);
if (destinationFile.exists() && verify(destinationFile, content.getChecksum())) {
Log.d(LOGTAG, "Content already exists and is up-to-date.");
catalog.markAsDownloaded(content);
continue;
}
temporaryFile = createTemporaryFile(context, content);
if (!hasEnoughDiskSpace(content, destinationFile, temporaryFile)) {
Log.d(LOGTAG, "Not enough disk space to save content. Skipping download.");
continue;
}
// TODO: Check space on disk before downloading content (bug 1220145)
final String url = createDownloadURL(content);
if (!temporaryFile.exists() || temporaryFile.length() < content.getSize()) {
download(client, url, temporaryFile);
}
if (!verify(temporaryFile, content.getDownloadChecksum())) {
Log.w(LOGTAG, "Wrong checksum after download, content=" + content.getId());
temporaryFile.delete();
continue;
}
if (!content.isAssetArchive()) {
Log.e(LOGTAG, "Downloaded content is not of type 'asset-archive': " + content.getType());
temporaryFile.delete();
continue;
}
extract(temporaryFile, destinationFile, content.getChecksum());
catalog.markAsDownloaded(content);
Log.d(LOGTAG, "Successfully downloaded: " + content);
if (callback != null) {
callback.onContentDownloaded(content);
}
if (temporaryFile != null && temporaryFile.exists()) {
temporaryFile.delete();
}
} catch (RecoverableDownloadContentException e) {
Log.w(LOGTAG, "Downloading content failed (Recoverable): " + content , e);
if (e.shouldBeCountedAsFailure()) {
catalog.rememberFailure(content, e.getErrorType());
}
// TODO: Reschedule download (bug 1209498)
} catch (UnrecoverableDownloadContentException e) {
Log.w(LOGTAG, "Downloading content failed (Unrecoverable): " + content, e);
catalog.markAsPermanentlyFailed(content);
if (temporaryFile != null && temporaryFile.exists()) {
temporaryFile.delete();
}
}
}
Log.v(LOGTAG, "Done");
}
/* package-private */ static void download(HttpClient client, String source, File temporaryFile)
protected void download(HttpClient client, String source, File temporaryFile)
throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
InputStream inputStream = null;
OutputStream outputStream = null;
final HttpGet request = new HttpGet(source);
final long offset = temporaryFile.exists() ? temporaryFile.length() : 0;
if (offset > 0) {
request.setHeader("Range", "bytes=" + offset + "-");
}
try {
final HttpResponse response = client.execute(request);
final int status = response.getStatusLine().getStatusCode();
if (status != HttpStatus.SC_OK) {
if (status != HttpStatus.SC_OK && status != HttpStatus.SC_PARTIAL_CONTENT) {
// We are trying to be smart and only retry if this is an error that might resolve in the future.
// TODO: This is guesstimating at best. We want to implement failure counters (Bug 1215106).
if (status >= 500) {
// Recoverable: Server errors 5xx
throw new RecoverableDownloadContentException("(Recoverable) Download failed. Status code: " + status);
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER,
"(Recoverable) Download failed. Status code: " + status);
} else if (status >= 400) {
// Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed.
throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status);
@ -130,73 +182,30 @@ import java.util.zip.GZIPInputStream;
final HttpEntity entity = response.getEntity();
if (entity == null) {
// Recoverable: Should not happen for a valid asset
throw new RecoverableDownloadContentException("Null entity");
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER, "Null entity");
}
inputStream = new BufferedInputStream(entity.getContent());
outputStream = new BufferedOutputStream(new FileOutputStream(temporaryFile));
outputStream = openFile(temporaryFile, status == HttpStatus.SC_PARTIAL_CONTENT);
IOUtils.copy(inputStream, outputStream);
inputStream.close();
outputStream.close();
} catch (IOException e) {
// TODO: Support resuming downloads using 'Range' header (Bug 1209513)
temporaryFile.delete();
// Recoverable: Just I/O discontinuation
throw new RecoverableDownloadContentException(e);
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e);
} finally {
IOUtils.safeStreamClose(inputStream);
IOUtils.safeStreamClose(outputStream);
}
}
/* package-private */ static boolean verify(File file, String expectedChecksum)
throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
InputStream inputStream = null;
try {
inputStream = new BufferedInputStream(new FileInputStream(file));
byte[] ctx = NativeCrypto.sha256init();
if (ctx == null) {
throw new RecoverableDownloadContentException("Could not create SHA-256 context");
}
byte[] buffer = new byte[4096];
int read;
while ((read = inputStream.read(buffer)) != -1) {
NativeCrypto.sha256update(ctx, buffer, read);
}
String actualChecksum = Utils.byte2Hex(NativeCrypto.sha256finalize(ctx));
if (!actualChecksum.equalsIgnoreCase(expectedChecksum)) {
Log.w(LOGTAG, "Checksums do not match. Expected=" + expectedChecksum + ", Actual=" + actualChecksum);
return false;
}
return true;
} catch (IOException e) {
// Recoverable: Just I/O discontinuation
throw new RecoverableDownloadContentException(e);
} finally {
IOUtils.safeStreamClose(inputStream);
}
protected OutputStream openFile(File file, boolean append) throws FileNotFoundException {
return new BufferedOutputStream(new FileOutputStream(file, append));
}
/* package-private */ static void move(File temporaryFile, File destinationFile)
throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
if (!temporaryFile.renameTo(destinationFile)) {
Log.d(LOGTAG, "Could not move temporary file to destination. Trying to copy..");
copy(temporaryFile, destinationFile);
temporaryFile.delete();
}
}
/* package-private */ static void extract(File sourceFile, File destinationFile, String checksum)
protected void extract(File sourceFile, File destinationFile, String checksum)
throws UnrecoverableDownloadContentException, RecoverableDownloadContentException {
InputStream inputStream = null;
OutputStream outputStream = null;
@ -225,9 +234,8 @@ import java.util.zip.GZIPInputStream;
move(temporaryFile, destinationFile);
} catch (IOException e) {
// We do not support resume yet (Bug 1209513). Therefore we have to treat this as unrecoverable: The
// temporarily file will be deleted and we want to avoid downloading and failing repeatedly.
throw new UnrecoverableDownloadContentException(e);
// We could not extract to the destination: Keep temporary file and try again next time we run.
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e);
} finally {
IOUtils.safeStreamClose(inputStream);
IOUtils.safeStreamClose(outputStream);
@ -238,7 +246,55 @@ import java.util.zip.GZIPInputStream;
}
}
private static void copy(File temporaryFile, File destinationFile)
protected boolean isConnectedToNetwork(Context context) {
ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo networkInfo = manager.getActiveNetworkInfo();
return networkInfo != null && networkInfo.isConnected();
}
protected boolean isActiveNetworkMetered(Context context) {
return ConnectivityManagerCompat.isActiveNetworkMetered(
(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE));
}
protected HttpClient buildHttpClient() {
// TODO: Implement proxy support (Bug 1209496)
return HttpClientBuilder.create()
.setUserAgent(HardwareUtils.isTablet() ?
AppConstants.USER_AGENT_FENNEC_TABLET :
AppConstants.USER_AGENT_FENNEC_MOBILE)
.setRetryHandler(new DefaultHttpRequestRetryHandler())
.build();
}
protected String createDownloadURL(DownloadContent content) {
return CDN_BASE_URL + content.getLocation();
}
protected File createTemporaryFile(Context context, DownloadContent content)
throws RecoverableDownloadContentException {
File cacheDirectory = new File(context.getCacheDir(), CACHE_DIRECTORY);
if (!cacheDirectory.exists() && !cacheDirectory.mkdirs()) {
// Recoverable: File system might not be mounted NOW and we didn't download anything yet anyways.
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO,
"Could not create cache directory: " + cacheDirectory);
}
return new File(cacheDirectory, content.getDownloadChecksum() + "-" + content.getId());
}
protected void move(File temporaryFile, File destinationFile)
throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
if (!temporaryFile.renameTo(destinationFile)) {
Log.d(LOGTAG, "Could not move temporary file to destination. Trying to copy..");
copy(temporaryFile, destinationFile);
temporaryFile.delete();
}
}
protected void copy(File temporaryFile, File destinationFile)
throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
InputStream inputStream = null;
OutputStream outputStream = null;
@ -257,32 +313,27 @@ import java.util.zip.GZIPInputStream;
inputStream.close();
outputStream.close();
} catch (IOException e) {
// Meh. This is an awkward situation: We downloaded the content but we can't move it to its destination. We
// are treating this as "unrecoverable" error because we want to avoid downloading this again and again and
// then always failing to copy it to the destination. This will be fixed after we implement resuming
// downloads (Bug 1209513): We will keep the downloaded temporary file and just retry copying.
throw new UnrecoverableDownloadContentException(e);
// We could not copy the temporary file to its destination: Keep the temporary file and
// try again the next time we run.
throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e);
} finally {
IOUtils.safeStreamClose(inputStream);
IOUtils.safeStreamClose(outputStream);
}
}
/* package-private */ static File getDestinationFile(Context context, DownloadContent content) throws UnrecoverableDownloadContentException {
if (content.isFont()) {
return new File(new File(context.getApplicationInfo().dataDir, "fonts"), content.getFilename());
protected boolean hasEnoughDiskSpace(DownloadContent content, File destinationFile, File temporaryFile) {
final File temporaryDirectory = temporaryFile.getParentFile();
if (temporaryDirectory.getUsableSpace() < content.getSize()) {
return false;
}
// Unrecoverable: We downloaded a file and we don't know what to do with it (Should not happen)
throw new UnrecoverableDownloadContentException("Can't determine destination for kind: " + content.getKind());
}
final File destinationDirectory = destinationFile.getParentFile();
// We need some more space to extract the file (getSize() returns the uncompressed size)
if (destinationDirectory.getUsableSpace() < content.getSize() * 2) {
return false;
}
/* package-private */ static boolean isActiveNetworkMetered(Context context) {
return ConnectivityManagerCompat.isActiveNetworkMetered(
(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE));
}
/* package-private */ static String createDownloadURL(DownloadContent content) {
return CDN_BASE_URL + content.getLocation();
return true;
}
}

View File

@ -1,14 +1,10 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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/. */
package org.mozilla.gecko.dlc;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.GeckoAppShell;
import org.mozilla.gecko.GeckoEvent;
import org.mozilla.gecko.dlc.DownloadContentHelper;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
@ -18,30 +14,30 @@ import android.content.Context;
import android.content.Intent;
import android.util.Log;
import ch.boye.httpclientandroidlib.client.HttpClient;
import java.io.File;
/**
* Service to handle downloadable content that did not ship with the APK.
*/
public class DownloadContentService extends IntentService {
private static final String LOGTAG = "GeckoDLCService";
private static final String ACTION_STUDY_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.STUDY";
private static final String ACTION_VERIFY_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.VERIFY";
private static final String ACTION_DOWNLOAD_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.DOWNLOAD";
public static void startStudy(Context context) {
Intent intent = new Intent(DownloadContentHelper.ACTION_STUDY_CATALOG);
Intent intent = new Intent(ACTION_STUDY_CATALOG);
intent.setComponent(new ComponentName(context, DownloadContentService.class));
context.startService(intent);
}
public static void startVerification(Context context) {
Intent intent = new Intent(DownloadContentHelper.ACTION_VERIFY_CONTENT);
Intent intent = new Intent(ACTION_VERIFY_CONTENT);
intent.setComponent(new ComponentName(context, DownloadContentService.class));
context.startService(intent);
}
public static void startDownloads(Context context) {
Intent intent = new Intent(DownloadContentHelper.ACTION_DOWNLOAD_CONTENT);
Intent intent = new Intent(ACTION_DOWNLOAD_CONTENT);
intent.setComponent(new ComponentName(context, DownloadContentService.class));
context.startService(intent);
}
@ -69,156 +65,34 @@ public class DownloadContentService extends IntentService {
return;
}
final BaseAction action;
switch (intent.getAction()) {
case DownloadContentHelper.ACTION_STUDY_CATALOG:
studyCatalog();
case ACTION_STUDY_CATALOG:
action = new StudyAction();
break;
case DownloadContentHelper.ACTION_DOWNLOAD_CONTENT:
downloadContent();
case ACTION_DOWNLOAD_CONTENT:
action = new DownloadAction(new DownloadAction.Callback() {
@Override
public void onContentDownloaded(DownloadContent content) {
if (content.isFont()) {
GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Fonts:Reload", ""));
}
}
});
break;
case DownloadContentHelper.ACTION_VERIFY_CONTENT:
verifyCatalog();
case ACTION_VERIFY_CONTENT:
action = new VerifyAction();
break;
default:
Log.e(LOGTAG, "Unknown action: " + intent.getAction());
return;
}
action.perform(this, catalog);
catalog.persistChanges();
}
/**
* Study: Scan the catalog for "new" content available for download.
*/
private void studyCatalog() {
Log.d(LOGTAG, "Studying catalog..");
for (DownloadContent content : catalog.getContentWithoutState()) {
if (content.isAssetArchive() && content.isFont()) {
catalog.scheduleDownload(content);
Log.d(LOGTAG, "Scheduled download: " + content);
}
}
if (catalog.hasScheduledDownloads()) {
startDownloads(this);
}
Log.v(LOGTAG, "Done");
}
/**
* Verify: Validate downloaded content. Does it still exist and does it have the correct checksum?
*/
private void verifyCatalog() {
Log.d(LOGTAG, "Verifying catalog..");
for (DownloadContent content : catalog.getDownloadedContent()) {
try {
File destinationFile = DownloadContentHelper.getDestinationFile(this, content);
if (!destinationFile.exists()) {
Log.d(LOGTAG, "Downloaded content does not exist anymore: " + content);
// This file does not exist even though it is marked as downloaded in the catalog. Scheduling a
// download to fetch it again.
catalog.scheduleDownload(content);
}
if (!DownloadContentHelper.verify(destinationFile, content.getChecksum())) {
catalog.scheduleDownload(content);
Log.d(LOGTAG, "Wrong checksum. Scheduling download: " + content);
continue;
}
Log.v(LOGTAG, "Content okay: " + content);
} catch (DownloadContentHelper.UnrecoverableDownloadContentException e) {
Log.w(LOGTAG, "Unrecoverable exception while verifying downloaded file", e);
} catch (DownloadContentHelper.RecoverableDownloadContentException e) {
// That's okay, we are just verifying already existing content. No log.
}
}
if (catalog.hasScheduledDownloads()) {
startDownloads(this);
}
Log.v(LOGTAG, "Done");
}
/**
* Download content that has been scheduled during "study" or "verify".
*/
private void downloadContent() {
Log.d(LOGTAG, "Downloading content..");
if (DownloadContentHelper.isActiveNetworkMetered(this)) {
Log.d(LOGTAG, "Network is metered. Postponing download.");
// TODO: Reschedule download (bug 1209498)
return;
}
HttpClient client = DownloadContentHelper.buildHttpClient();
for (DownloadContent content : catalog.getScheduledDownloads()) {
Log.d(LOGTAG, "Downloading: " + content);
File temporaryFile = null;
try {
File destinationFile = DownloadContentHelper.getDestinationFile(this, content);
if (destinationFile.exists() && DownloadContentHelper.verify(destinationFile, content.getChecksum())) {
Log.d(LOGTAG, "Content already exists and is up-to-date.");
continue;
}
temporaryFile = DownloadContentHelper.createTemporaryFile(this, content);
// TODO: Check space on disk before downloading content (bug 1220145)
final String url = DownloadContentHelper.createDownloadURL(content);
DownloadContentHelper.download(client, url, temporaryFile);
if (!DownloadContentHelper.verify(temporaryFile, content.getDownloadChecksum())) {
Log.w(LOGTAG, "Wrong checksum after download, content=" + content.getId());
temporaryFile.delete();
continue;
}
if (!content.isAssetArchive()) {
Log.e(LOGTAG, "Downloaded content is not of type 'asset-archive': " + content.getType());
continue;
}
DownloadContentHelper.extract(temporaryFile, destinationFile, content.getChecksum());
catalog.markAsDownloaded(content);
Log.d(LOGTAG, "Successfully downloaded: " + content);
onContentDownloaded(content);
} catch (DownloadContentHelper.RecoverableDownloadContentException e) {
Log.w(LOGTAG, "Downloading content failed (Recoverable): " + content, e);
// TODO: Reschedule download (bug 1209498)
} catch (DownloadContentHelper.UnrecoverableDownloadContentException e) {
Log.w(LOGTAG, "Downloading content failed (Unrecoverable): " + content, e);
catalog.markAsPermanentlyFailed(content);
} finally {
if (temporaryFile != null && temporaryFile.exists()) {
temporaryFile.delete();
}
}
}
Log.v(LOGTAG, "Done");
}
private void onContentDownloaded(DownloadContent content) {
if (content.isFont()) {
GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Fonts:Reload", ""));
}
}
}

View File

@ -0,0 +1,41 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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/. */
package org.mozilla.gecko.dlc;
import android.content.Context;
import android.util.Log;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
/**
* Study: Scan the catalog for "new" content available for download.
*/
public class StudyAction extends BaseAction {
private static final String LOGTAG = "DLCStudyAction";
public void perform(Context context, DownloadContentCatalog catalog) {
Log.d(LOGTAG, "Studying catalog..");
for (DownloadContent content : catalog.getContentWithoutState()) {
if (content.isAssetArchive() && content.isFont()) {
catalog.scheduleDownload(content);
Log.d(LOGTAG, "Scheduled download: " + content);
}
}
if (catalog.hasScheduledDownloads()) {
startDownloads(context);
}
Log.v(LOGTAG, "Done");
}
protected void startDownloads(Context context) {
DownloadContentService.startDownloads(context);
}
}

View File

@ -0,0 +1,63 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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/. */
package org.mozilla.gecko.dlc;
import android.content.Context;
import android.util.Log;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import java.io.File;
/**
* Verify: Validate downloaded content. Does it still exist and does it have the correct checksum?
*/
public class VerifyAction extends BaseAction {
private static final String LOGTAG = "DLCVerifyAction";
@Override
public void perform(Context context, DownloadContentCatalog catalog) {
Log.d(LOGTAG, "Verifying catalog..");
for (DownloadContent content : catalog.getDownloadedContent()) {
try {
File destinationFile = getDestinationFile(context, content);
if (!destinationFile.exists()) {
Log.d(LOGTAG, "Downloaded content does not exist anymore: " + content);
// This file does not exist even though it is marked as downloaded in the catalog. Scheduling a
// download to fetch it again.
catalog.scheduleDownload(content);
continue;
}
if (!verify(destinationFile, content.getChecksum())) {
catalog.scheduleDownload(content);
Log.d(LOGTAG, "Wrong checksum. Scheduling download: " + content);
continue;
}
Log.v(LOGTAG, "Content okay: " + content);
} catch (UnrecoverableDownloadContentException e) {
Log.w(LOGTAG, "Unrecoverable exception while verifying downloaded file", e);
} catch (RecoverableDownloadContentException e) {
// That's okay, we are just verifying already existing content. No log.
}
}
if (catalog.hasScheduledDownloads()) {
startDownloads(context);
}
Log.v(LOGTAG, "Done");
}
protected void startDownloads(Context context) {
DownloadContentService.startDownloads(context);
}
}

View File

@ -23,6 +23,8 @@ public class DownloadContent {
private static final String KEY_KIND = "kind";
private static final String KEY_SIZE = "size";
private static final String KEY_STATE = "state";
private static final String KEY_FAILURES = "failures";
private static final String KEY_LAST_FAILURE_TYPE = "last_failure_type";
@IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED, STATE_IGNORED})
public @interface State {}
@ -50,6 +52,8 @@ public class DownloadContent {
private final String kind;
private final long size;
private int state;
private int failures;
private int lastFailureType;
private DownloadContent(@NonNull String id, @NonNull String location, @NonNull String filename,
@NonNull String checksum, @NonNull String downloadChecksum, @NonNull long lastModified,
@ -93,6 +97,10 @@ public class DownloadContent {
return location;
}
public long getLastModified() {
return lastModified;
}
public String getFilename() {
return filename;
}
@ -117,6 +125,24 @@ public class DownloadContent {
return TYPE_ASSET_ARCHIVE.equals(type);
}
/* package-private */ int getFailures() {
return failures;
}
/* package-private */ void rememberFailure(int failureType) {
if (lastFailureType != failureType) {
lastFailureType = failureType;
failures = 1;
} else {
failures++;
}
}
/* package-private */ void resetFailures() {
failures = 0;
lastFailureType = 0;
}
public static DownloadContent fromJSON(JSONObject object) throws JSONException {
return new Builder()
.setId(object.getString(KEY_ID))
@ -129,6 +155,7 @@ public class DownloadContent {
.setKind(object.getString(KEY_KIND))
.setSize(object.getLong(KEY_SIZE))
.setState(object.getInt(KEY_STATE))
.setFailures(object.optInt(KEY_FAILURES), object.optInt(KEY_LAST_FAILURE_TYPE))
.build();
}
@ -144,6 +171,12 @@ public class DownloadContent {
object.put(KEY_KIND, kind);
object.put(KEY_SIZE, size);
object.put(KEY_STATE, state);
if (failures > 0) {
object.put(KEY_FAILURES, failures);
object.put(KEY_LAST_FAILURE_TYPE, lastFailureType);
}
return object;
}
@ -162,11 +195,16 @@ public class DownloadContent {
private String kind;
private long size;
private int state;
private int failures;
private int lastFailureType;
public DownloadContent build() {
DownloadContent content = new DownloadContent(id, location, filename, checksum, downloadChecksum,
lastModified, type, kind, size);
content.setState(state);
content.failures = failures;
content.lastFailureType = lastFailureType;
return content;
}
@ -219,5 +257,12 @@ public class DownloadContent {
this.state = state;
return this;
}
/* package-private */ Builder setFailures(int failures, int lastFailureType) {
this.failures = failures;
this.lastFailureType = lastFailureType;
return this;
}
}
}

View File

@ -5,8 +5,6 @@
package org.mozilla.gecko.dlc.catalog;
import org.mozilla.gecko.dlc.catalog.DownloadContentBootstrap;
import android.content.Context;
import android.support.v4.util.AtomicFile;
import android.util.Log;
@ -33,18 +31,25 @@ public class DownloadContentCatalog {
private static final String LOGTAG = "GeckoDLCCatalog";
private static final String FILE_NAME = "download_content_catalog";
private static final int MAX_FAILURES_UNTIL_PERMANENTLY_FAILED = 10;
private final AtomicFile file; // Guarded by 'file'
private List<DownloadContent> content; // Guarded by 'this'
private boolean hasLoadedCatalog; // Guarded by 'this
private boolean hasCatalogChanged; // Guarded by 'this'
public DownloadContentCatalog(Context context) {
content = Collections.emptyList();
file = new AtomicFile(new File(context.getApplicationInfo().dataDir, FILE_NAME));
this(new AtomicFile(new File(context.getApplicationInfo().dataDir, FILE_NAME)));
startLoadFromDisk();
}
// For injecting mocked AtomicFile objects during test
protected DownloadContentCatalog(AtomicFile file) {
this.content = Collections.emptyList();
this.file = file;
}
public synchronized List<DownloadContent> getContentWithoutState() {
awaitLoadingCatalogLocked();
@ -104,6 +109,7 @@ public class DownloadContentCatalog {
public synchronized void markAsDownloaded(DownloadContent content) {
content.setState(DownloadContent.STATE_DOWNLOADED);
content.resetFailures();
hasCatalogChanged = true;
}
@ -117,6 +123,17 @@ public class DownloadContentCatalog {
hasCatalogChanged = true;
}
public synchronized void rememberFailure(DownloadContent content, int failureType) {
if (content.getFailures() >= MAX_FAILURES_UNTIL_PERMANENTLY_FAILED) {
Log.d(LOGTAG, "Maximum number of failures reached. Marking content has permanently failed.");
markAsPermanentlyFailed(content);
} else {
content.rememberFailure(failureType);
hasCatalogChanged = true;
}
}
public void persistChanges() {
new Thread(LOGTAG + "-Persist") {
public void run() {
@ -145,14 +162,18 @@ public class DownloadContentCatalog {
}
}
private synchronized void loadFromDisk() {
protected synchronized boolean hasCatalogChanged() {
return hasCatalogChanged;
}
protected synchronized void loadFromDisk() {
Log.d(LOGTAG, "Loading from disk");
if (hasLoadedCatalog) {
return;
}
List<DownloadContent> content = new ArrayList<DownloadContent>();
List<DownloadContent> content = new ArrayList<>();
try {
JSONArray array;
@ -180,15 +201,19 @@ public class DownloadContentCatalog {
Log.d(LOGTAG, "Can't read catalog due to IOException", e);
}
this.content = content;
this.hasLoadedCatalog = true;
onCatalogLoaded(content);
notifyAll();
Log.d(LOGTAG, "Loaded " + content.size() + " elements");
}
private synchronized void writeToDisk() {
protected void onCatalogLoaded(List<DownloadContent> content) {
this.content = content;
this.hasLoadedCatalog = true;
}
protected synchronized void writeToDisk() {
if (!hasCatalogChanged) {
Log.v(LOGTAG, "Not persisting: Catalog has not changed");
return;

View File

@ -301,6 +301,7 @@ class SearchEngineRow extends AnimatedHeightLayout {
*/
private void updateFromSavedSearches(List<String> savedSuggestions, boolean animate, int suggestionStartIndex, int recycledSuggestionCount) {
if (savedSuggestions == null || savedSuggestions.isEmpty()) {
hideRecycledSuggestions(suggestionStartIndex, recycledSuggestionCount);
return;
}
@ -402,6 +403,11 @@ class SearchEngineRow extends AnimatedHeightLayout {
updateFromSavedSearches(searchHistorySuggestions, animate, 0, recycledSuggestionCount);
} else if (searchSuggestionsEnabled) {
updateFromSearchEngine(animate, searchEngineSuggestions, recycledSuggestionCount, 0);
} else {
// The current search term is treated separately from the suggestions list, hence we can
// recycle ALL suggestion items here. (We always show the current search term, i.e. 1 item,
// in front of the search engine suggestions and/or the search history.)
hideRecycledSuggestions(0, recycledSuggestionCount);
}
}

View File

@ -248,11 +248,14 @@ gbjar.sources += ['java/org/mozilla/gecko/' + x for x in [
'distribution/Distribution.java',
'distribution/ReferrerDescriptor.java',
'distribution/ReferrerReceiver.java',
'dlc/BaseAction.java',
'dlc/catalog/DownloadContent.java',
'dlc/catalog/DownloadContentBootstrap.java',
'dlc/catalog/DownloadContentCatalog.java',
'dlc/DownloadContentHelper.java',
'dlc/DownloadAction.java',
'dlc/DownloadContentService.java',
'dlc/StudyAction.java',
'dlc/VerifyAction.java',
'DoorHangerPopup.java',
'DownloadsIntegration.java',
'DynamicToolbar.java',

View File

@ -0,0 +1,560 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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/. */
package org.mozilla.gecko.dlc;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mozilla.gecko.background.testhelpers.TestRunner;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import android.content.Context;
import org.robolectric.RuntimeEnvironment;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import ch.boye.httpclientandroidlib.HttpEntity;
import ch.boye.httpclientandroidlib.HttpResponse;
import ch.boye.httpclientandroidlib.HttpStatus;
import ch.boye.httpclientandroidlib.StatusLine;
import ch.boye.httpclientandroidlib.client.HttpClient;
import ch.boye.httpclientandroidlib.client.methods.HttpGet;
import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
import static org.mockito.Matchers.*;
import static org.mockito.Mockito.*;
/**
* DownloadAction: Download content that has been scheduled during "study" or "verify".
*/
@RunWith(TestRunner.class)
public class TestDownloadAction {
private static final String TEST_URL = "http://example.org";
/**
* Scenario: The current network is metered.
*
* Verify that:
* * No download is performed on a metered network
*/
@Test
public void testNothingIsDoneOnMeteredNetwork() throws Exception {
DownloadAction action = spy(new DownloadAction(null));
doReturn(true).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
action.perform(RuntimeEnvironment.application, null);
verify(action, never()).buildHttpClient();
verify(action, never()).download(any(HttpClient.class), anyString(), any(File.class));
}
/**
* Scenario: No (connected) network is available.
*
* Verify that:
* * No download is performed
*/
@Test
public void testNothingIsDoneIfNoNetworkIsAvailable() throws Exception {
DownloadAction action = spy(new DownloadAction(null));
doReturn(false).when(action).isConnectedToNetwork(RuntimeEnvironment.application);
action.perform(RuntimeEnvironment.application, null);
verify(action, never()).isActiveNetworkMetered(any(Context.class));
verify(action, never()).buildHttpClient();
verify(action, never()).download(any(HttpClient.class), anyString(), any(File.class));
}
/**
* Scenario: Content is scheduled for download but already exists locally (with correct checksum).
*
* Verify that:
* * No download is performed for existing file
* * Content is marked as downloaded in the catalog
*/
@Test
public void testExistingAndVerifiedFilesAreNotDownloadedAgain() throws Exception {
DownloadContent content = new DownloadContent.Builder().build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
DownloadAction action = spy(new DownloadAction(null));
doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
File file = mock(File.class);
doReturn(true).when(file).exists();
doReturn(file).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
doReturn(true).when(action).verify(eq(file), anyString());
action.perform(RuntimeEnvironment.application, catalog);
verify(action, never()).download(any(HttpClient.class), anyString(), any(File.class));
verify(catalog).markAsDownloaded(content);
}
/**
* Scenario: Server returns a server error (HTTP 500).
*
* Verify that:
* * Situation is treated as recoverable (RecoverableDownloadContentException)
*/
@Test(expected=BaseAction.RecoverableDownloadContentException.class)
public void testServerErrorsAreRecoverable() throws Exception {
HttpClient client = mockHttpClient(500, "");
File temporaryFile = mock(File.class);
doReturn(false).when(temporaryFile).exists();
DownloadAction action = spy(new DownloadAction(null));
action.download(client, TEST_URL, temporaryFile);
verify(client).execute(any(HttpUriRequest.class));
}
/**
* Scenario: Server returns a client error (HTTP 404).
*
* Verify that:
* * Situation is treated as unrecoverable (UnrecoverableDownloadContentException)
*/
@Test(expected=BaseAction.UnrecoverableDownloadContentException.class)
public void testClientErrorsAreUnrecoverable() throws Exception {
HttpClient client = mockHttpClient(404, "");
File temporaryFile = mock(File.class);
doReturn(false).when(temporaryFile).exists();
DownloadAction action = spy(new DownloadAction(null));
action.download(client, TEST_URL, temporaryFile);
verify(client).execute(any(HttpUriRequest.class));
}
/**
* Scenario: A successful download has been performed.
*
* Verify that:
* * The content will be extracted to the destination
* * The content is marked as downloaded in the catalog
*/
@Test
public void testSuccessfulDownloadsAreMarkedAsDownloaded() throws Exception {
DownloadContent content = new DownloadContent.Builder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
DownloadAction action = spy(new DownloadAction(null));
doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
File file = mockNotExistingFile();
doReturn(file).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
doReturn(false).when(action).verify(eq(file), anyString());
doNothing().when(action).download(any(HttpClient.class), anyString(), eq(file));
doReturn(true).when(action).verify(eq(file), anyString());
doNothing().when(action).extract(eq(file), eq(file), anyString());
action.perform(RuntimeEnvironment.application, catalog);
verify(action).buildHttpClient();
verify(action).download(any(HttpClient.class), anyString(), eq(file));
verify(action).extract(eq(file), eq(file), anyString());
verify(catalog).markAsDownloaded(content);
}
/**
* Scenario: Pretend a partially downloaded file already exists.
*
* Verify that:
* * Range header is set in request
* * Content will be appended to existing file
* * Content will be marked as downloaded in catalog
*/
@Test
public void testResumingDownloadFromExistingFile() throws Exception {
DownloadContent content = new DownloadContent.Builder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setSize(4223)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
DownloadAction action = spy(new DownloadAction(null));
doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
File temporaryFile = mockFileWithSize(1337L);
doReturn(temporaryFile).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
doReturn(outputStream).when(action).openFile(eq(temporaryFile), anyBoolean());
HttpClient client = mockHttpClient(HttpStatus.SC_PARTIAL_CONTENT, "HelloWorld");
doReturn(client).when(action).buildHttpClient();
File destinationFile = mockNotExistingFile();
doReturn(destinationFile).when(action).getDestinationFile(RuntimeEnvironment.application, content);
doReturn(true).when(action).verify(eq(temporaryFile), anyString());
doNothing().when(action).extract(eq(temporaryFile), eq(destinationFile), anyString());
action.perform(RuntimeEnvironment.application, catalog);
ArgumentCaptor<HttpGet> argument = ArgumentCaptor.forClass(HttpGet.class);
verify(client).execute(argument.capture());
HttpGet request = argument.getValue();
Assert.assertTrue(request.containsHeader("Range"));
Assert.assertEquals("bytes=1337-", request.getFirstHeader("Range").getValue());
Assert.assertEquals("HelloWorld", new String(outputStream.toByteArray(), "UTF-8"));
verify(action).openFile(eq(temporaryFile), eq(true));
verify(catalog).markAsDownloaded(content);
verify(temporaryFile).delete();
}
/**
* Scenario: Download fails with IOException.
*
* Verify that:
* * Partially downloaded file will not be deleted
* * Content will not be marked as downloaded in catalog
*/
@Test
public void testTemporaryFileIsNotDeletedAfterDownloadAborted() throws Exception {
DownloadContent content = new DownloadContent.Builder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setSize(4223)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
DownloadAction action = spy(new DownloadAction(null));
doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
File temporaryFile = mockFileWithSize(1337L);
doReturn(temporaryFile).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
ByteArrayOutputStream outputStream = spy(new ByteArrayOutputStream());
doReturn(outputStream).when(action).openFile(eq(temporaryFile), anyBoolean());
doThrow(IOException.class).when(outputStream).write(any(byte[].class), anyInt(), anyInt());
HttpClient client = mockHttpClient(HttpStatus.SC_PARTIAL_CONTENT, "HelloWorld");
doReturn(client).when(action).buildHttpClient();
doReturn(mockNotExistingFile()).when(action).getDestinationFile(RuntimeEnvironment.application, content);
action.perform(RuntimeEnvironment.application, catalog);
verify(catalog, never()).markAsDownloaded(content);
verify(action, never()).verify(any(File.class), anyString());
verify(temporaryFile, never()).delete();
}
/**
* Scenario: Partially downloaded file is already complete.
*
* Verify that:
* * No download request is made
* * File is treated as completed and will be verified and extracted
* * Content is marked as downloaded in catalog
*/
@Test
public void testNoRequestIsSentIfFileIsAlreadyComplete() throws Exception {
DownloadContent content = new DownloadContent.Builder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setSize(1337L)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
DownloadAction action = spy(new DownloadAction(null));
doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
File temporaryFile = mockFileWithSize(1337L);
doReturn(temporaryFile).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
File destinationFile = mockNotExistingFile();
doReturn(destinationFile).when(action).getDestinationFile(RuntimeEnvironment.application, content);
doReturn(true).when(action).verify(eq(temporaryFile), anyString());
doNothing().when(action).extract(eq(temporaryFile), eq(destinationFile), anyString());
action.perform(RuntimeEnvironment.application, catalog);
verify(action, never()).download(any(HttpClient.class), anyString(), eq(temporaryFile));
verify(action).verify(eq(temporaryFile), anyString());
verify(action).extract(eq(temporaryFile), eq(destinationFile), anyString());
verify(catalog).markAsDownloaded(content);
}
/**
* Scenario: Download is completed but verification (checksum) failed.
*
* Verify that:
* * Downloaded file is deleted
* * File will not be extracted
* * Content is not marked as downloaded in the catalog
*/
@Test
public void testTemporaryFileWillBeDeletedIfVerificationFails() throws Exception {
DownloadContent content = new DownloadContent.Builder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setSize(1337L)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Collections.singletonList(content)).when(catalog).getScheduledDownloads();
DownloadAction action = spy(new DownloadAction(null));
doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
doNothing().when(action).download(any(HttpClient.class), anyString(), any(File.class));
doReturn(false).when(action).verify(any(File.class), anyString());
File temporaryFile = mockNotExistingFile();
doReturn(temporaryFile).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
File destinationFile = mockNotExistingFile();
doReturn(destinationFile).when(action).getDestinationFile(RuntimeEnvironment.application, content);
action.perform(RuntimeEnvironment.application, catalog);
verify(temporaryFile).delete();
verify(action, never()).extract(any(File.class), any(File.class), anyString());
verify(catalog, never()).markAsDownloaded(content);
}
/**
* Scenario: Not enough storage space for content is available.
*
* Verify that:
* * No download will per performed
*/
@Test
public void testNoDownloadIsPerformedIfNotEnoughStorageIsAvailable() throws Exception {
DownloadContent content = createFontWithSize(1337L);
DownloadContentCatalog catalog = mockCatalogWithScheduledDownloads(content);
DownloadAction action = spy(new DownloadAction(null));
doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
doReturn(true).when(action).isConnectedToNetwork(RuntimeEnvironment.application);
File temporaryFile = mockNotExistingFile();
doReturn(temporaryFile).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
File destinationFile = mockNotExistingFile();
doReturn(destinationFile).when(action).getDestinationFile(RuntimeEnvironment.application, content);
doReturn(true).when(action).hasEnoughDiskSpace(content, destinationFile, temporaryFile);
verify(action, never()).buildHttpClient();
verify(action, never()).download(any(HttpClient.class), anyString(), any(File.class));
verify(action, never()).verify(any(File.class), anyString());
verify(catalog, never()).markAsDownloaded(content);
}
/**
* Scenario: Not enough storage space for temporary file available.
*
* Verify that:
* * hasEnoughDiskSpace() returns false
*/
@Test
public void testWithNotEnoughSpaceForTemporaryFile() {
DownloadContent content = createFontWithSize(2048);
File destinationFile = mockNotExistingFile();
File temporaryFile = mockNotExistingFileWithUsableSpace(1024);
DownloadAction action = new DownloadAction(null);
Assert.assertFalse(action.hasEnoughDiskSpace(content, destinationFile, temporaryFile));
}
/**
* Scenario: Not enough storage space for destination file available.
*
* Verify that:
* * hasEnoughDiskSpace() returns false
*/
@Test
public void testWithNotEnoughSpaceForDestinationFile() {
DownloadContent content = createFontWithSize(2048);
File destinationFile = mockNotExistingFileWithUsableSpace(1024);
File temporaryFile = mockNotExistingFile();
DownloadAction action = new DownloadAction(null);
Assert.assertFalse(action.hasEnoughDiskSpace(content, destinationFile, temporaryFile));
}
/**
* Scenario: Enough storage space for temporary and destination file available.
*
* Verify that:
* * hasEnoughDiskSpace() returns true
*/
@Test
public void testWithEnoughSpaceForEverything() {
DownloadContent content = createFontWithSize(2048);
File destinationFile = mockNotExistingFileWithUsableSpace(4096);
File temporaryFile = mockNotExistingFileWithUsableSpace(4096);
DownloadAction action = new DownloadAction(null);
Assert.assertTrue(action.hasEnoughDiskSpace(content, destinationFile, temporaryFile));
}
/**
* Scenario: Download failed with network I/O error.
*
* Verify that:
* * Error is not counted as failure
*/
@Test
public void testNetworkErrorIsNotCountedAsFailure() throws Exception {
DownloadContent content = createFont();
DownloadContentCatalog catalog = mockCatalogWithScheduledDownloads(content);
DownloadAction action = spy(new DownloadAction(null));
doReturn(true).when(action).isConnectedToNetwork(RuntimeEnvironment.application);
doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
doReturn(mockNotExistingFile()).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
doReturn(mockNotExistingFile()).when(action).getDestinationFile(RuntimeEnvironment.application, content);
doReturn(true).when(action).hasEnoughDiskSpace(eq(content), any(File.class), any(File.class));
HttpClient client = mock(HttpClient.class);
doThrow(IOException.class).when(client).execute(any(HttpUriRequest.class));
doReturn(client).when(action).buildHttpClient();
action.perform(RuntimeEnvironment.application, catalog);
verify(catalog, never()).rememberFailure(eq(content), anyInt());
verify(catalog, never()).markAsDownloaded(content);
}
/**
* Scenario: Disk IO Error when extracting file.
*
* Verify that:
* * Error is counted as failure
* * After multiple errors the content is marked as permanently failed
*/
@Test
public void testDiskIOErrorIsCountedAsFailure() throws Exception {
DownloadContent content = createFont();
DownloadContentCatalog catalog = mockCatalogWithScheduledDownloads(content);
doCallRealMethod().when(catalog).rememberFailure(eq(content), anyInt());
doCallRealMethod().when(catalog).markAsPermanentlyFailed(content);
Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
DownloadAction action = spy(new DownloadAction(null));
doReturn(true).when(action).isConnectedToNetwork(RuntimeEnvironment.application);
doReturn(false).when(action).isActiveNetworkMetered(RuntimeEnvironment.application);
doReturn(mockNotExistingFile()).when(action).createTemporaryFile(RuntimeEnvironment.application, content);
doReturn(mockNotExistingFile()).when(action).getDestinationFile(RuntimeEnvironment.application, content);
doReturn(true).when(action).hasEnoughDiskSpace(eq(content), any(File.class), any(File.class));
doNothing().when(action).download(any(HttpClient.class), anyString(), any(File.class));
doReturn(true).when(action).verify(any(File.class), anyString());
File destinationFile = mock(File.class);
doReturn(false).when(destinationFile).exists();
File parentFile = mock(File.class);
doReturn(false).when(parentFile).mkdirs();
doReturn(false).when(parentFile).exists();
doReturn(parentFile).when(destinationFile).getParentFile();
doReturn(destinationFile).when(action).getDestinationFile(RuntimeEnvironment.application, content);
for (int i = 0; i < 10; i++) {
action.perform(RuntimeEnvironment.application, catalog);
Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
}
action.perform(RuntimeEnvironment.application, catalog);
Assert.assertEquals(DownloadContent.STATE_FAILED, content.getState());
verify(catalog, times(11)).rememberFailure(eq(content), anyInt());
}
private DownloadContent createFont() {
return createFontWithSize(102400L);
}
private DownloadContent createFontWithSize(long size) {
return new DownloadContent.Builder()
.setKind(DownloadContent.KIND_FONT)
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setSize(size)
.build();
}
private DownloadContentCatalog mockCatalogWithScheduledDownloads(DownloadContent... content) {
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
doReturn(Arrays.asList(content)).when(catalog).getScheduledDownloads();
return catalog;
}
private static File mockNotExistingFile() {
return mockFileWithUsableSpace(false, 0, Long.MAX_VALUE);
}
private static File mockNotExistingFileWithUsableSpace(long usableSpace) {
return mockFileWithUsableSpace(false, 0, usableSpace);
}
private static File mockFileWithSize(long length) {
return mockFileWithUsableSpace(true, length, Long.MAX_VALUE);
}
private static File mockFileWithUsableSpace(boolean exists, long length, long usableSpace) {
File file = mock(File.class);
doReturn(exists).when(file).exists();
doReturn(length).when(file).length();
File parentFile = mock(File.class);
doReturn(usableSpace).when(parentFile).getUsableSpace();
doReturn(parentFile).when(file).getParentFile();
return file;
}
private static HttpClient mockHttpClient(int statusCode, String content) throws Exception {
StatusLine status = mock(StatusLine.class);
doReturn(statusCode).when(status).getStatusCode();
HttpEntity entity = mock(HttpEntity.class);
doReturn(new ByteArrayInputStream(content.getBytes("UTF-8"))).when(entity).getContent();
HttpResponse response = mock(HttpResponse.class);
doReturn(status).when(response).getStatusLine();
doReturn(entity).when(response).getEntity();
HttpClient client = mock(HttpClient.class);
doReturn(response).when(client).execute(any(HttpUriRequest.class));
return client;
}
}

View File

@ -0,0 +1,118 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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/. */
package org.mozilla.gecko.dlc;
import android.content.Context;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mozilla.gecko.background.testhelpers.TestRunner;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import org.robolectric.RuntimeEnvironment;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* StudyAction: Scan the catalog for "new" content available for download.
*/
@RunWith(TestRunner.class)
public class TestStudyAction {
/**
* Scenario: Catalog is empty.
*
* Verify that:
* * No download is scheduled
* * Download action is not started
*/
@Test
public void testPerformWithEmptyCatalog() {
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.getContentWithoutState()).thenReturn(new ArrayList<DownloadContent>());
StudyAction action = spy(new StudyAction());
action.perform(RuntimeEnvironment.application, catalog);
verify(catalog).getContentWithoutState();
verify(catalog, never()).markAsDownloaded(any(DownloadContent.class));
verify(action, never()).startDownloads(any(Context.class));
}
/**
* Scenario: Catalog contains two items that have not been downloaded yet.
*
* Verify that:
* * Both items are scheduled to be downloaded
*/
@Test
public void testPerformWithNewContent() {
DownloadContent content1 = new DownloadContent.Builder()
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setKind(DownloadContent.KIND_FONT)
.build();
DownloadContent content2 = new DownloadContent.Builder()
.setType(DownloadContent.TYPE_ASSET_ARCHIVE)
.setKind(DownloadContent.KIND_FONT)
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.getContentWithoutState()).thenReturn(Arrays.asList(content1, content2));
StudyAction action = spy(new StudyAction());
action.perform(RuntimeEnvironment.application, catalog);
verify(catalog).scheduleDownload(content1);
verify(catalog).scheduleDownload(content2);
}
/**
* Scenario: Catalog contains item that are scheduled for download.
*
* Verify that:
* * Download action is started
*/
@Test
public void testStartingDownloadsAfterScheduling() {
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.hasScheduledDownloads()).thenReturn(true);
StudyAction action = spy(new StudyAction());
action.perform(RuntimeEnvironment.application, catalog);
verify(action).startDownloads(any(Context.class));
}
/**
* Scenario: Catalog contains unknown content.
*
* Verify that:
* * Unknown content is not scheduled for download.
*/
@Test
public void testPerformWithUnknownContent() {
DownloadContent content = new DownloadContent.Builder()
.setType("Unknown-Type")
.setKind("Unknown-Kind")
.build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.getContentWithoutState()).thenReturn(Collections.singletonList(content));
StudyAction action = spy(new StudyAction());
action.perform(RuntimeEnvironment.application, catalog);
verify(catalog, never()).scheduleDownload(content);
}
}

View File

@ -0,0 +1,122 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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/. */
package org.mozilla.gecko.dlc;
import android.content.Context;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mozilla.gecko.background.testhelpers.TestRunner;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import org.robolectric.RuntimeEnvironment;
import java.io.File;
import java.util.Collections;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* VerifyAction: Validate downloaded content. Does it still exist and does it have the correct checksum?
*/
@RunWith(TestRunner.class)
public class TestVerifyAction {
/**
* Scenario: Downloaded file does not exist anymore.
*
* Verify that:
* * Content is re-scheduled for download.
*/
@Test
public void testReschedulingIfFileDoesNotExist() throws Exception {
DownloadContent content = new DownloadContent.Builder().build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
File file = mock(File.class);
when(file.exists()).thenReturn(false);
VerifyAction action = spy(new VerifyAction());
doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
action.perform(RuntimeEnvironment.application, catalog);
verify(catalog).scheduleDownload(content);
}
/**
* Scenario: Content has been scheduled for download.
*
* Verify that:
* * Download action is started
*/
@Test
public void testStartingDownloadsAfterScheduling() {
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.hasScheduledDownloads()).thenReturn(true);
VerifyAction action = spy(new VerifyAction());
action.perform(RuntimeEnvironment.application, catalog);
verify(action).startDownloads(any(Context.class));
}
/**
* Scenario: Checksum of existing file does not match expectation.
*
* Verify that:
* * Content is re-scheduled for download.
*/
@Test
public void testReschedulingIfVerificationFailed() throws Exception {
DownloadContent content = new DownloadContent.Builder().build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
File file = mock(File.class);
when(file.exists()).thenReturn(true);
VerifyAction action = spy(new VerifyAction());
doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
doReturn(false).when(action).verify(eq(file), anyString());
action.perform(RuntimeEnvironment.application, catalog);
verify(catalog).scheduleDownload(content);
}
/**
* Scenario: Downloaded file exists and has the correct checksum.
*
* Verify that:
* * No download is scheduled
* * Download action is not started
*/
@Test
public void testSuccessfulVerification() throws Exception {
DownloadContent content = new DownloadContent.Builder().build();
DownloadContentCatalog catalog = mock(DownloadContentCatalog.class);
when(catalog.getDownloadedContent()).thenReturn(Collections.singletonList(content));
File file = mock(File.class);
when(file.exists()).thenReturn(true);
VerifyAction action = spy(new VerifyAction());
doReturn(file).when(action).getDestinationFile(RuntimeEnvironment.application, content);
doReturn(true).when(action).verify(eq(file), anyString());
verify(catalog, never()).scheduleDownload(content);
verify(action, never()).startDownloads(RuntimeEnvironment.application);
}
}

View File

@ -0,0 +1,69 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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/. */
package org.mozilla.gecko.dlc.catalog;
import org.json.JSONException;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mozilla.gecko.background.testhelpers.TestRunner;
@RunWith(TestRunner.class)
public class TestDownloadContent {
/**
* Verify that the values passed to the builder are all set on the DownloadContent object.
*/
@Test
public void testBuilder() {
DownloadContent content = createTestContent();
Assert.assertEquals("Some-ID", content.getId());
Assert.assertEquals("/somewhere/something", content.getLocation());
Assert.assertEquals("some.file", content.getFilename());
Assert.assertEquals("Some-checksum", content.getChecksum());
Assert.assertEquals("Some-download-checksum", content.getDownloadChecksum());
Assert.assertEquals(4223, content.getLastModified());
Assert.assertEquals("Some-type", content.getType());
Assert.assertEquals("Some-kind", content.getKind());
Assert.assertEquals(27, content.getSize());
Assert.assertEquals(DownloadContent.STATE_SCHEDULED, content.getState());
}
/**
* Verify that a DownloadContent object exported to JSON and re-imported from JSON does not change.
*/
public void testJSONSerializationAndDeserialization() throws JSONException {
DownloadContent content = DownloadContent.fromJSON(createTestContent().toJSON());
Assert.assertEquals("Some-ID", content.getId());
Assert.assertEquals("/somewhere/something", content.getLocation());
Assert.assertEquals("some.file", content.getFilename());
Assert.assertEquals("Some-checksum", content.getChecksum());
Assert.assertEquals("Some-download-checksum", content.getDownloadChecksum());
Assert.assertEquals(4223, content.getLastModified());
Assert.assertEquals("Some-type", content.getType());
Assert.assertEquals("Some-kind", content.getKind());
Assert.assertEquals(27, content.getSize());
Assert.assertEquals(DownloadContent.STATE_SCHEDULED, content.getState());
}
/**
* Create a DownloadContent object with arbitrary data.
*/
private DownloadContent createTestContent() {
return new DownloadContent.Builder()
.setId("Some-ID")
.setLocation("/somewhere/something")
.setFilename("some.file")
.setChecksum("Some-checksum")
.setDownloadChecksum("Some-download-checksum")
.setLastModified(4223)
.setType("Some-type")
.setKind("Some-kind")
.setSize(27)
.setState(DownloadContent.STATE_SCHEDULED)
.build();
}
}

View File

@ -0,0 +1,261 @@
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* 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/. */
package org.mozilla.gecko.dlc.catalog;
import android.support.v4.util.AtomicFile;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mozilla.gecko.background.testhelpers.TestRunner;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.util.Arrays;
import java.util.Collections;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
@RunWith(TestRunner.class)
public class TestDownloadContentCatalog {
/**
* Scenario: Create a new, fresh catalog.
*
* Verify that:
* * Catalog has not changed
* * Unchanged catalog will not be saved to disk
*/
@Test
public void testUntouchedCatalogHasNotChangedAndWillNotBePersisted() throws Exception {
AtomicFile file = mock(AtomicFile.class);
doReturn("[]".getBytes("UTF-8")).when(file).readFully();
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(file));
catalog.loadFromDisk();
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
catalog.writeToDisk();
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
verify(file, never()).startWrite();
}
/**
* Scenario: Create a new, fresh catalog.
*
* Verify that:
* * Catalog is bootstrapped with items.
*/
@Test
public void testCatalogIsBootstrapedIfFileDoesNotExist() throws Exception {
AtomicFile file = mock(AtomicFile.class);
doThrow(FileNotFoundException.class).when(file).readFully();
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(file));
catalog.loadFromDisk();
Assert.assertTrue("Catalog is not empty", catalog.getContentWithoutState().size() > 0);
}
/**
* Scenario: Schedule downloading an item from the catalog.
*
* Verify that:
* * Catalog has changed
*/
@Test
public void testCatalogHasChangedWhenDownloadIsScheduled() throws Exception {
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
DownloadContent content = new DownloadContent.Builder().build();
catalog.onCatalogLoaded(Collections.singletonList(content));
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
catalog.scheduleDownload(content);
Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
}
/**
* Scenario: Mark an item in the catalog as downloaded.
*
* Verify that:
* * Catalog has changed
*/
@Test
public void testCatalogHasChangedWhenContentIsDownloaded() throws Exception {
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
DownloadContent content = new DownloadContent.Builder().build();
catalog.onCatalogLoaded(Collections.singletonList(content));
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
catalog.markAsDownloaded(content);
Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
}
/**
* Scenario: Mark an item in the catalog as permanently failed.
*
* Verify that:
* * Catalog has changed
*/
@Test
public void testCatalogHasChangedIfDownloadHasFailedPermanently() throws Exception {
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
DownloadContent content = new DownloadContent.Builder().build();
catalog.onCatalogLoaded(Collections.singletonList(content));
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
catalog.markAsPermanentlyFailed(content);
Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
}
/**
* Scenario: Mark an item in the catalog as ignored.
*
* Verify that:
* * Catalog has changed
*/
@Test
public void testCatalogHasChangedIfContentIsIgnored() throws Exception {
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
DownloadContent content = new DownloadContent.Builder().build();
catalog.onCatalogLoaded(Collections.singletonList(content));
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
catalog.markAsIgnored(content);
Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
}
/**
* Scenario: A changed catalog is written to disk.
*
* Verify that:
* * Before write: Catalog has changed
* * After write: Catalog has not changed.
*/
@Test
public void testCatalogHasNotChangedAfterWritingToDisk() throws Exception {
AtomicFile file = mock(AtomicFile.class);
doReturn(mock(FileOutputStream.class)).when(file).startWrite();
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(file));
DownloadContent content = new DownloadContent.Builder().build();
catalog.onCatalogLoaded(Collections.singletonList(content));
catalog.scheduleDownload(content);
Assert.assertTrue("Catalog has changed", catalog.hasCatalogChanged());
catalog.writeToDisk();
Assert.assertFalse("Catalog has not changed", catalog.hasCatalogChanged());
}
/**
* Scenario: A catalog with multiple items in different states.
*
* Verify that:
* * getContentWithoutState(), getDownloadedContent() and getScheduledDownloads() returns
* the correct items depenending on their state.
*/
@Test
public void testContentClassification() {
DownloadContentCatalog catalog = spy(new DownloadContentCatalog(mock(AtomicFile.class)));
DownloadContent content1 = new DownloadContent.Builder().setState(DownloadContent.STATE_NONE).build();
DownloadContent content2 = new DownloadContent.Builder().setState(DownloadContent.STATE_NONE).build();
DownloadContent content3 = new DownloadContent.Builder().setState(DownloadContent.STATE_SCHEDULED).build();
DownloadContent content4 = new DownloadContent.Builder().setState(DownloadContent.STATE_SCHEDULED).build();
DownloadContent content5 = new DownloadContent.Builder().setState(DownloadContent.STATE_SCHEDULED).build();
DownloadContent content6 = new DownloadContent.Builder().setState(DownloadContent.STATE_DOWNLOADED).build();
DownloadContent content7 = new DownloadContent.Builder().setState(DownloadContent.STATE_FAILED).build();
DownloadContent content8 = new DownloadContent.Builder().setState(DownloadContent.STATE_IGNORED).build();
DownloadContent content9 = new DownloadContent.Builder().setState(DownloadContent.STATE_IGNORED).build();
catalog.onCatalogLoaded(Arrays.asList(content1, content2, content3, content4, content5, content6,
content7, content8, content9));
Assert.assertEquals(2, catalog.getContentWithoutState().size());
Assert.assertEquals(1, catalog.getDownloadedContent().size());
Assert.assertEquals(3, catalog.getScheduledDownloads().size());
Assert.assertTrue(catalog.getContentWithoutState().contains(content1));
Assert.assertTrue(catalog.getContentWithoutState().contains(content2));
Assert.assertTrue(catalog.getDownloadedContent().contains(content6));
Assert.assertTrue(catalog.getScheduledDownloads().contains(content3));
Assert.assertTrue(catalog.getScheduledDownloads().contains(content4));
Assert.assertTrue(catalog.getScheduledDownloads().contains(content5));
}
/**
* Scenario: Calling rememberFailure() on a catalog with varying values
*/
@Test
public void testRememberingFailures() {
DownloadContentCatalog catalog = new DownloadContentCatalog(mock(AtomicFile.class));
Assert.assertFalse(catalog.hasCatalogChanged());
DownloadContent content = new DownloadContent.Builder().build();
Assert.assertEquals(0, content.getFailures());
catalog.rememberFailure(content, 42);
Assert.assertEquals(1, content.getFailures());
Assert.assertTrue(catalog.hasCatalogChanged());
catalog.rememberFailure(content, 42);
Assert.assertEquals(2, content.getFailures());
// Failure counter is reset if different failure has been reported
catalog.rememberFailure(content, 23);
Assert.assertEquals(1, content.getFailures());
// Failure counter is reset after successful download
catalog.markAsDownloaded(content);
Assert.assertEquals(0, content.getFailures());
}
/**
* Scenario: Content has failed multiple times with the same failure type.
*
* Verify that:
* * Content is marked as permanently failed
*/
@Test
public void testContentWillBeMarkedAsPermanentlyFailedAfterMultipleFailures() {
DownloadContentCatalog catalog = new DownloadContentCatalog(mock(AtomicFile.class));
DownloadContent content = new DownloadContent.Builder().build();
Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
for (int i = 0; i < 10; i++) {
catalog.rememberFailure(content, 42);
Assert.assertEquals(i + 1, content.getFailures());
Assert.assertEquals(DownloadContent.STATE_NONE, content.getState());
}
catalog.rememberFailure(content, 42);
Assert.assertEquals(10, content.getFailures());
Assert.assertEquals(DownloadContent.STATE_FAILED, content.getState());
}
}

View File

@ -138,7 +138,10 @@ this.FxAccountsWebChannel.prototype = {
*/
let listener = (webChannelId, message, sendingContext) => {
if (message) {
log.debug("FxAccountsWebChannel message received", message);
log.debug("FxAccountsWebChannel message received", message.command);
if (logPII) {
log.debug("FxAccountsWebChannel message details", message);
}
let command = message.command;
let data = message.data;

View File

@ -0,0 +1,12 @@
.. _import-browserjs-globals:
========================
import-browserjs-globals
========================
Rule Details
------------
When included files from the main browser UI scripts will be loaded and any
declared globals will be defined for the current file. This is mostly useful for
browser-chrome mochitests that call browser functions.

View File

@ -8,19 +8,28 @@
var escope = require("escope");
var espree = require("espree");
var estraverse = require("estraverse");
var path = require("path");
var fs = require("fs");
var regexes = [
/^(?:Cu|Components\.utils)\.import\(".*\/(.*?)\.jsm?"\);?$/,
/^loader\.lazyImporter\(\w+, "(\w+)"/,
/^loader\.lazyRequireGetter\(\w+, "(\w+)"/,
/^loader\.lazyServiceGetter\(\w+, "(\w+)"/,
/^XPCOMUtils\.defineLazyModuleGetter\(\w+, "(\w+)"/,
/^DevToolsUtils\.defineLazyModuleGetter\(\w+, "(\w+)"/,
/^loader\.lazyGetter\(\w+, "(\w+)"/,
/^XPCOMUtils\.defineLazyGetter\(\w+, "(\w+)"/,
/^XPCOMUtils\.defineLazyServiceGetter\(\w+, "(\w+)"/,
/^DevToolsUtils\.defineLazyGetter\(\w+, "(\w+)"/,
var definitions = [
/^loader\.lazyGetter\(this, "(\w+)"/,
/^loader\.lazyImporter\(this, "(\w+)"/,
/^loader\.lazyServiceGetter\(this, "(\w+)"/,
/^loader\.lazyRequireGetter\(this, "(\w+)"/,
/^XPCOMUtils\.defineLazyGetter\(this, "(\w+)"/,
/^XPCOMUtils\.defineLazyModuleGetter\(this, "(\w+)"/,
/^XPCOMUtils\.defineLazyServiceGetter\(this, "(\w+)"/,
/^XPCOMUtils\.defineConstant\(this, "(\w+)"/,
/^DevToolsUtils\.defineLazyModuleGetter\(this, "(\w+)"/,
/^DevToolsUtils\.defineLazyGetter\(this, "(\w+)"/,
/^Object\.defineProperty\(this, "(\w+)"/,
/^Reflect\.defineProperty\(this, "(\w+)"/,
/^this\.__defineGetter__\("(\w+)"/,
];
var imports = [
/^(?:Cu|Components\.utils)\.import\(".*\/(.*?)\.jsm?"(?:, this)?\)/,
];
module.exports = {
@ -43,55 +52,120 @@ module.exports = {
},
/**
* Gets the source text of an AST node.
* A simplistic conversion of some AST nodes to a standard string form.
*
* @param {ASTNode} node
* The AST node representing the source text.
* @param {ASTContext} context
* The current context.
* @param {Object} node
* The AST node to convert.
*
* @return {String}
* The source text representing the AST node.
* The JS source for the node.
*/
getSource: function(node, context) {
return context.getSource(node).replace(/[\r\n]+\s*/g, " ")
.replace(/\s*=\s*/g, " = ")
.replace(/\s+\./g, ".")
.replace(/,\s+/g, ", ")
.replace(/;\n(\d+)/g, ";$1")
.replace(/\s+/g, " ");
getASTSource: function(node) {
switch (node.type) {
case "MemberExpression":
if (node.computed)
throw new Error("getASTSource unsupported computed MemberExpression");
return this.getASTSource(node.object) + "." + this.getASTSource(node.property);
case "ThisExpression":
return "this";
case "Identifier":
return node.name;
case "Literal":
return JSON.stringify(node.value);
case "CallExpression":
var args = node.arguments.map(a => this.getASTSource(a)).join(", ");
return this.getASTSource(node.callee) + "(" + args + ")";
case "ObjectExpression":
return "{}";
case "ExpressionStatement":
return this.getASTSource(node.expression) + ";";
case "FunctionExpression":
return "function() {}";
default:
throw new Error("getASTSource unsupported node type: " + node.type);
}
},
/**
* Gets the variable name from an import source
* e.g. Cu.import("path/to/someName") will return "someName."
* Attempts to convert an ExpressionStatement to a likely global variable
* definition.
*
* Some valid input strings:
* - Cu.import("resource://devtools/client/shared/widgets/ViewHelpers.jsm");
* - loader.lazyImporter(this, "name1");
* - loader.lazyRequireGetter(this, "name2"
* - loader.lazyServiceGetter(this, "name3"
* - XPCOMUtils.defineLazyModuleGetter(this, "setNamedTimeout", ...)
* - loader.lazyGetter(this, "toolboxStrings"
* - XPCOMUtils.defineLazyGetter(this, "clipboardHelper"
* @param {Object} node
* The AST node to convert.
*
* @param {String} source
* The source representing an import statement.
*
* @return {String}
* The variable name imported.
* @return {String or null}
* The variable name defined.
*/
getVarNameFromImportSource: function(source) {
for (var i = 0; i < regexes.length; i++) {
var regex = regexes[i];
var matches = source.match(regex);
convertExpressionToGlobal: function(node, isGlobal) {
try {
var source = this.getASTSource(node);
}
catch (e) {
return null;
}
if (matches) {
var name = matches[1];
for (var reg of definitions) {
var match = source.match(reg);
if (match) {
// Must be in the global scope
if (!isGlobal) {
return null;
}
return name;
return match[1];
}
}
for (reg of imports) {
var match = source.match(reg);
if (match) {
// The two argument form is only acceptable in the global scope
if (node.expression.arguments.length > 1 && !isGlobal) {
return null;
}
return match[1];
}
}
return null;
},
/**
* Walks over an AST and calls a callback for every ExpressionStatement found.
*
* @param {Object} ast
* The AST to traverse.
*
* @return {Function}
* The callback to call for each ExpressionStatement.
*/
expressionTraverse: function(ast, callback) {
var helpers = this;
var parents = new Map();
// Walk the parents of a node to see if any are functions
function isGlobal(node) {
var parent = parents.get(node);
while (parent) {
if (parent.type == "FunctionExpression" ||
parent.type == "FunctionDeclaration") {
return false;
}
parent = parents.get(parent);
}
return true;
}
estraverse.traverse(ast, {
enter: function(node, parent) {
parents.set(node, parent);
if (node.type == "ExpressionStatement") {
callback(node, isGlobal(node));
}
}
});
},
/**
@ -113,6 +187,15 @@ module.exports = {
result.push(name);
}
var helpers = this;
this.expressionTraverse(ast, function(node, isGlobal) {
var name = helpers.convertExpressionToGlobal(node, isGlobal);
if (name) {
result.push(name);
}
});
return result;
},
@ -142,25 +225,45 @@ module.exports = {
}
},
/**
* Get the single line text represented by a particular AST node.
*
* @param {ASTNode} node
* The AST node representing the source text.
* @param {String} text
* The text representing the AST node.
*
* @return {String}
* A single line version of the string represented by node.
*/
getTextForNode: function(node, text) {
var source = text.substr(node.range[0], node.range[1] - node.range[0]);
// Caches globals found in a file so we only have to parse a file once.
globalCache: new Map(),
return source.replace(/[\r\n]+\s*/g, "")
.replace(/\s*=\s*/g, " = ")
.replace(/\s+\./g, ".")
.replace(/,\s+/g, ", ")
.replace(/;\n(\d+)/g, ";$1");
/**
* Finds all the globals defined in a given file.
*
* @param {String} fileName
* The file to parse for globals.
*/
getGlobalsForFile: function(fileName) {
// If the file can't be found, let the error go up to the caller so it can
// be logged as an error in the current file.
var content = fs.readFileSync(fileName, "utf8");
if (this.globalCache.has(fileName)) {
return this.globalCache.get(fileName);
}
// Parse the content and get the globals from the ast.
var ast = this.getAST(content);
var globalVars = this.getGlobals(ast);
this.globalCache.set(fileName, globalVars);
return globalVars;
},
/**
* Adds a set of globals to a context.
*
* @param {Array} globalVars
* An array of global variable names.
* @param {ASTContext} context
* The current context.
*/
addGlobals: function(globalVars, context) {
for (var i = 0; i < globalVars.length; i++) {
var varName = globalVars[i];
this.addVarToScope(varName, context);
}
},
/**
@ -238,6 +341,49 @@ module.exports = {
return /.*[\\/]browser_.+\.js$/.test(pathAndFilename);
},
/**
* Check whether we are in a test of some kind.
*
* @param {RuleContext} scope
* You should pass this from within a rule
* e.g. helpers.getIsTest(this)
*
* @return {Boolean}
* True or false
*/
getIsTest: function(scope) {
var pathAndFilename = scope.getFilename();
if (/.*[\\/]test_.+\.js$/.test(pathAndFilename)) {
return true;
}
return this.getIsBrowserMochitest(scope);
},
/**
* Gets the root directory of the repository by walking up directories until
* a .eslintignore file is found.
* @param {ASTContext} context
* The current context.
*
* @return {String} The absolute path of the repository directory
*/
getRootDir: function(context) {
var fileName = this.getAbsoluteFilePath(context);
var dirName = path.dirname(fileName);
while (dirName && !fs.existsSync(path.join(dirName, ".eslintignore"))) {
dirName = path.dirname(dirName);
}
if (!dirName) {
throw new Error("Unable to find root of repository");
}
return dirName;
},
/**
* ESLint may be executed from various places: from mach, at the root of the
* repository, or from a directory in the repository when, for instance,

View File

@ -21,6 +21,7 @@ module.exports = {
"components-imports": require("../lib/rules/components-imports"),
"import-globals-from": require("../lib/rules/import-globals-from"),
"import-headjs-globals": require("../lib/rules/import-headjs-globals"),
"import-browserjs-globals": require("../lib/rules/import-browserjs-globals"),
"mark-test-function-used": require("../lib/rules/mark-test-function-used"),
"no-aArgs": require("../lib/rules/no-aArgs"),
"no-cpows-in-tests": require("../lib/rules/no-cpows-in-tests"),
@ -31,6 +32,7 @@ module.exports = {
"components-imports": 0,
"import-globals-from": 0,
"import-headjs-globals": 0,
"import-browserjs-globals": 0,
"mark-test-function-used": 0,
"no-aArgs": 0,
"no-cpows-in-tests": 0,

View File

@ -22,8 +22,8 @@ module.exports = function(context) {
return {
ExpressionStatement: function(node) {
var source = helpers.getSource(node, context);
var name = helpers.getVarNameFromImportSource(source);
var scope = context.getScope();
var name = helpers.convertExpressionToGlobal(node, scope.type == "global");
if (name) {
helpers.addVarToScope(name, context);

View File

@ -0,0 +1,78 @@
/**
* @fileoverview Import globals from browser.js.
*
* 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";
// -----------------------------------------------------------------------------
// Rule Definition
// -----------------------------------------------------------------------------
var fs = require("fs");
var path = require("path");
var helpers = require("../helpers");
const SCRIPTS = [
"toolkit/components/printing/content/printUtils.js",
"toolkit/content/viewZoomOverlay.js",
"browser/components/places/content/browserPlacesViews.js",
"browser/base/content/browser.js",
"browser/components/downloads/content/downloads.js",
"browser/components/downloads/content/indicator.js",
"browser/components/customizableui/content/panelUI.js",
"toolkit/obsolete/content/inlineSpellCheckUI.js",
"toolkit/components/viewsource/content/viewSourceUtils.js",
"browser/base/content/browser-addons.js",
"browser/base/content/browser-ctrlTab.js",
"browser/base/content/browser-customization.js",
"browser/base/content/browser-devedition.js",
"browser/base/content/browser-eme.js",
"browser/base/content/browser-feeds.js",
"browser/base/content/browser-fullScreen.js",
"browser/base/content/browser-fullZoom.js",
"browser/base/content/browser-gestureSupport.js",
"browser/base/content/browser-places.js",
"browser/base/content/browser-plugins.js",
"browser/base/content/browser-safebrowsing.js",
"browser/base/content/browser-sidebar.js",
"browser/base/content/browser-social.js",
"browser/base/content/browser-syncui.js",
"browser/base/content/browser-tabsintitlebar.js",
"browser/base/content/browser-thumbnails.js",
"browser/base/content/browser-trackingprotection.js",
"browser/base/content/browser-data-submission-info-bar.js",
"browser/base/content/browser-fxaccounts.js",
];
module.exports = function(context) {
return {
Program: function(node) {
if (!helpers.getIsBrowserMochitest(this)) {
return;
}
let root = helpers.getRootDir(context);
for (let script of SCRIPTS) {
let fileName = path.join(root, script);
try {
let globals = helpers.getGlobalsForFile(fileName);
helpers.addGlobals(globals, context);
}
catch (e) {
context.report(
node,
"Could not load globals from file {{filePath}}: {{error}}",
{
filePath: path.relative(root, fileName),
error: e
}
);
}
}
}
};
};

View File

@ -19,25 +19,6 @@ var helpers = require("../helpers");
var path = require("path");
module.exports = function(context) {
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function importGlobalsFrom(filePath) {
// If the file can't be found, let the error go up to the caller so it can
// be logged as an error in the current file.
var content = fs.readFileSync(filePath, "utf8");
// Parse the content and get the globals from the ast.
var ast = helpers.getAST(content);
var globalVars = helpers.getGlobals(ast);
for (var i = 0; i < globalVars.length; i++) {
var varName = globalVars[i];
helpers.addVarToScope(varName, context);
}
}
// ---------------------------------------------------------------------------
// Public
// ---------------------------------------------------------------------------
@ -60,7 +41,8 @@ module.exports = function(context) {
}
try {
importGlobalsFrom(filePath);
let globals = helpers.getGlobalsForFile(filePath);
helpers.addGlobals(globals, context);
} catch (e) {
context.report(
node,

View File

@ -87,15 +87,17 @@ module.exports = function(context) {
return {
Program: function() {
if (!helpers.getIsBrowserMochitest(this)) {
if (!helpers.getIsTest(this)) {
return;
}
var currentFilePath = helpers.getAbsoluteFilePath(context);
var dirName = path.dirname(currentFilePath);
var fullHeadjsPath = path.resolve(dirName, "head.js");
checkFile([currentFilePath, fullHeadjsPath]);
if (fs.existsSync(fullHeadjsPath)) {
let globals = helpers.getGlobalsForFile(fullHeadjsPath);
helpers.addGlobals(globals, context);
}
}
};
};

View File

@ -18,6 +18,7 @@
"dependencies": {
"escope": "^3.2.0",
"espree": "^2.2.4",
"estraverse": "^4.1.1",
"sax": "^1.1.4"
},
"engines": {

View File

@ -0,0 +1,48 @@
// Parent config file for all browser-chrome files.
{
"rules": {
// Head files want to define globals so don't warn for unused globals
"no-unused-vars": [2, {"vars": "local", "args": "none"}],
"mozilla/import-headjs-globals": 1,
"mozilla/import-browserjs-globals": 1,
},
"env": {
"browser": true,
},
// All globals made available in the test environment.
"globals": {
"add_task": false,
"Assert": false,
"BrowserTestUtils": false,
"ContentTask": false,
"EventUtils": false,
"executeSoon": false,
"export_assertions": false,
"finish": false,
"getRootDirectory": false,
"getTestFilePath": false,
"gTestPath": false,
"info": false,
"is": false,
"isnot": false,
"ok": false,
"promise": false,
"registerCleanupFunction": false,
"requestLongerTimeout": false,
"SimpleTest": false,
"SpecialPowers": false,
"todo": false,
"todo_is": false,
"todo_isnot": false,
"waitForClipboard": false,
"waitForExplicitFinish": false,
"waitForFocus": false,
"gBrowser": false,
"gNavToolbox": false,
"gURLBar": false,
"gNavigatorBundle": false,
"content": false,
}
}

View File

@ -0,0 +1,40 @@
// Parent config file for all mochitest files.
{
rules: {
// Head files want to define globals so don't warn for unused globals
"no-unused-vars": [2, {"vars": "local", "args": "none"}],
"mozilla/import-headjs-globals": 1,
},
"env": {
"browser": true,
},
// All globals made available in the test environment.
"globals": {
"add_task": false,
"Assert": false,
"EventUtils": false,
"executeSoon": false,
"export_assertions": false,
"finish": false,
"getRootDirectory": false,
"getTestFilePath": false,
"gTestPath": false,
"info": false,
"is": false,
"isnot": false,
"ok": false,
"promise": false,
"registerCleanupFunction": false,
"requestLongerTimeout": false,
"SimpleTest": false,
"SpecialPowers": false,
"todo": false,
"todo_is": false,
"todo_isnot": false,
"waitForClipboard": false,
"waitForExplicitFinish": false,
"waitForFocus": false,
}
}

View File

@ -0,0 +1,40 @@
// Parent config file for all mochitest files.
{
rules: {
// Head files want to define globals so don't warn for unused globals
"no-unused-vars": [2, {"vars": "local", "args": "none"}],
"mozilla/import-headjs-globals": 1,
},
"env": {
"browser": true,
},
// All globals made available in the test environment.
"globals": {
"add_task": false,
"Assert": false,
"EventUtils": false,
"executeSoon": false,
"export_assertions": false,
"finish": false,
"getRootDirectory": false,
"getTestFilePath": false,
"gTestPath": false,
"info": false,
"is": false,
"isnot": false,
"ok": false,
"promise": false,
"registerCleanupFunction": false,
"requestLongerTimeout": false,
"SimpleTest": false,
"SpecialPowers": false,
"todo": false,
"todo_is": false,
"todo_isnot": false,
"waitForClipboard": false,
"waitForExplicitFinish": false,
"waitForFocus": false,
}
}

View File

@ -0,0 +1,43 @@
// Parent config file for all xpcshell files.
{
rules: {
// Head files want to define globals so don't warn for unused globals
"no-unused-vars": [2, {"vars": "local", "args": "none"}],
"mozilla/import-headjs-globals": 1,
},
// All globals made available in the test environment.
"globals": {
"add_task": false,
"add_test": false,
"Assert": false,
"deepEqual": false,
"do_check_eq": false,
"do_check_false": false,
"do_check_neq": false,
"do_check_null": false,
"do_check_true": false,
"do_execute_soon": false,
"do_get_cwd": false,
"do_get_file": false,
"do_get_idle": false,
"do_get_profile": false,
"do_load_module": false,
"do_parse_document": false,
"do_print": false,
"do_register_cleanup": false,
"do_test_finished": false,
"do_test_pending": false,
"do_throw": false,
"do_timeout": false,
"equal": false,
"load": false,
"notDeepEqual": false,
"notEqual": false,
"notStrictEqual": false,
"ok": false,
"run_next_test": false,
"run_test": false,
"strictEqual": false,
}
}

View File

@ -101,7 +101,7 @@
// "no-mixed-spaces-and-tabs": [2, "smart-tabs"],
// No unnecessary spacing
// "no-multi-spaces": [2, { exceptions: { "AssignmentExpression": true, "VariableDeclarator": true, "ArrayExpression": true } }],
// "no-multi-spaces": [2, { exceptions: { "AssignmentExpression": true, "VariableDeclarator": true, "ArrayExpression": true, "ObjectExpression": true } }],
// No reassigning native JS objects
// "no-native-reassign": 2,

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/chrome.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/mochitest/browser.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/mochitest.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../../testing/xpcshell/xpcshell.eslintrc"
]
}

View File

@ -0,0 +1,5 @@
{
"extends": [
"../../../../testing/mochitest/chrome.eslintrc"
]
}

Some files were not shown because too many files have changed in this diff Show More