Merge fx-team to m-c. a=merge
authorRyan VanderMeulen <ryanvm@gmail.com>
Fri, 25 Mar 2016 11:36:26 -0400
changeset 290368 b2dbee5ca727e87bdaeab9ab60fb83df2a9846a2
parent 290312 b45ee3e065b7c9defd8877d01fe948db18230c87 (current diff)
parent 290367 936973deb8ad63904d5c273060a87aa373cef7f1 (diff)
child 290369 542fffb3ce3d941e223a33753539c7c83b32d8f5
child 290450 51c718100df0fdd8d921eb62bcc180cfa765d9c0
push id74220
push userryanvm@gmail.com
push dateFri, 25 Mar 2016 15:38:07 +0000
treeherdermozilla-inbound@542fffb3ce3d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone48.0a1
first release with
nightly linux32
b2dbee5ca727 / 48.0a1 / 20160325083832 / files
nightly linux64
b2dbee5ca727 / 48.0a1 / 20160325083832 / files
nightly mac
b2dbee5ca727 / 48.0a1 / 20160325083832 / files
nightly win32
b2dbee5ca727 / 48.0a1 / 20160325083832 / files
nightly win64
b2dbee5ca727 / 48.0a1 / 20160325083832 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge fx-team to m-c. a=merge
mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckAction.java
mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeAction.java
mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/SubscriptionStorage.java
toolkit/components/telemetry/Histograms.json
--- a/devtools/client/animationinspector/components/animation-time-block.js
+++ b/devtools/client/animationinspector/components/animation-time-block.js
@@ -52,25 +52,32 @@ AnimationTimeBlock.prototype = {
     let {state} = this.animation;
 
     // Create a container element to hold the delay and iterations.
     // It is positioned according to its delay (divided by the playbackrate),
     // and its width is according to its duration (divided by the playbackrate).
     let {x, iterationW, delayX, delayW, negativeDelayW} =
       TimeScale.getAnimationDimensions(animation);
 
+    // background properties for .iterations element
+    let backgroundIterations = TimeScale.getIterationsBackgroundData(animation);
+
     createNode({
       parent: this.containerEl,
       attributes: {
         "class": "iterations" + (state.iterationCount ? "" : " infinite"),
         // Individual iterations are represented by setting the size of the
         // repeating linear-gradient.
+        // The background-size, background-position, background-repeat represent
+        // iterationCount and iterationStart.
         "style": `left:${x}%;
                   width:${iterationW}%;
-                  background-size:${100 / (state.iterationCount || 1)}% 100%;`
+                  background-size:${backgroundIterations.size}% 100%;
+                  background-position:${backgroundIterations.position}% 0;
+                  background-repeat:${backgroundIterations.repeat};`
       }
     });
 
     // The animation name is displayed over the iterations.
     // Note that in case of negative delay, it is pushed towards the right so
     // the delay element does not overlap.
     createNode({
       parent: createNode({
@@ -124,16 +131,25 @@ AnimationTimeBlock.prototype = {
     // Adding the iteration count (the infinite symbol, or an integer).
     if (state.iterationCount !== 1) {
       text += L10N.getStr("player.animationIterationCountLabel") + " ";
       text += state.iterationCount ||
               L10N.getStr("player.infiniteIterationCountText");
       text += "\n";
     }
 
+    // Adding the iteration start.
+    if (state.iterationStart !== 0) {
+      let iterationStartTime = state.iterationStart * state.duration / 1000;
+      text += L10N.getFormatStr("player.animationIterationStartLabel",
+                                state.iterationStart,
+                                L10N.numberWithDecimals(iterationStartTime, 2));
+      text += "\n";
+    }
+
     // Adding the playback rate if it's different than 1.
     if (state.playbackRate !== 1) {
       text += L10N.getStr("player.animationRateLabel") + " ";
       text += state.playbackRate;
       text += "\n";
     }
 
     // Adding a note that the animation is running on the compositor thread if
--- a/devtools/client/animationinspector/components/keyframes.js
+++ b/devtools/client/animationinspector/components/keyframes.js
@@ -38,23 +38,29 @@ Keyframes.prototype = {
     this.containerEl = this.keyframesEl = this.animation = null;
   },
 
   render: function({keyframes, propertyName, animation}) {
     this.keyframes = keyframes;
     this.propertyName = propertyName;
     this.animation = animation;
 
+    let iterationStartOffset =
+      animation.state.iterationStart % 1 == 0
+      ? 0
+      : 1 - animation.state.iterationStart % 1;
+
     this.keyframesEl.classList.add(animation.state.type);
     for (let frame of this.keyframes) {
+      let offset = frame.offset + iterationStartOffset;
       createNode({
         parent: this.keyframesEl,
         attributes: {
           "class": "frame",
-          "style": `left:${frame.offset * 100}%;`,
+          "style": `left:${offset * 100}%;`,
           "data-offset": frame.offset,
           "data-property": propertyName,
           "title": frame.value
         }
       });
     }
   },
 
--- a/devtools/client/animationinspector/test/browser.ini
+++ b/devtools/client/animationinspector/test/browser.ini
@@ -3,16 +3,17 @@ tags = devtools
 subsuite = devtools
 skip-if = e10s && debug # bug 1252283
 support-files =
   doc_body_animation.html
   doc_frame_script.js
   doc_keyframes.html
   doc_modify_playbackRate.html
   doc_negative_animation.html
+  doc_script_animation.html
   doc_simple_animation.html
   doc_multiple_animation_types.html
   head.js
 
 [browser_animation_animated_properties_displayed.js]
 [browser_animation_click_selects_animation.js]
 [browser_animation_controller_exposes_document_currentTime.js]
 skip-if = os == "linux" && !debug # Bug 1234567
@@ -33,16 +34,17 @@ skip-if = os == "linux" && !debug # Bug 
 [browser_animation_same_nb_of_playerWidgets_and_playerFronts.js]
 [browser_animation_shows_player_on_valid_node.js]
 [browser_animation_spacebar_toggles_animations.js]
 [browser_animation_spacebar_toggles_node_animations.js]
 [browser_animation_target_highlight_select.js]
 [browser_animation_target_highlighter_lock.js]
 [browser_animation_timeline_currentTime.js]
 [browser_animation_timeline_header.js]
+[browser_animation_timeline_iterationStart.js]
 [browser_animation_timeline_pause_button.js]
 skip-if = os == "linux" && bits == 32 # Bug 1220974
 [browser_animation_timeline_rate_selector.js]
 [browser_animation_timeline_rewind_button.js]
 [browser_animation_timeline_scrubber_exists.js]
 [browser_animation_timeline_scrubber_movable.js]
 [browser_animation_timeline_scrubber_moves.js]
 [browser_animation_timeline_shows_delay.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/animationinspector/test/browser_animation_timeline_iterationStart.js
@@ -0,0 +1,84 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that the iteration start is displayed correctly in time blocks.
+
+add_task(function*() {
+  yield addTab(URL_ROOT + "doc_script_animation.html");
+  let {panel} = yield openAnimationInspector();
+  let timelineComponent = panel.animationsTimelineComponent;
+  let timeBlockComponents = timelineComponent.timeBlocks;
+  let detailsComponents = timelineComponent.details;
+
+  for (let i = 0; i < timeBlockComponents.length; i++) {
+    info(`Expand time block ${i} so its keyframes are visible`);
+    yield clickOnAnimation(panel, i);
+
+    info(`Check the state of time block ${i}`);
+    let {containerEl, animation: {state}} = timeBlockComponents[i];
+
+    checkAnimationTooltip(containerEl, state);
+    checkIterationBackground(containerEl, state);
+
+    // Get the first set of keyframes (there's only one animated property
+    // anyway), and the first frame element from there, we're only interested in
+    // its offset.
+    let keyframeComponent = detailsComponents[i].keyframeComponents[0];
+    let frameEl = keyframeComponent.keyframesEl.querySelector(".frame");
+    checkKeyframeOffset(containerEl, frameEl, state);
+  }
+});
+
+function checkAnimationTooltip(el, {iterationStart, duration}) {
+  info("Check an animation's iterationStart data in its tooltip");
+  let title = el.querySelector(".name").getAttribute("title");
+
+  let iterationStartTime = iterationStart * duration / 1000;
+  let iterationStartTimeString = iterationStartTime.toLocaleString(undefined, {
+    maximumFractionDigits: 2,
+    minimumFractionDigits: 2
+  }).replace(".", "\\.");
+  let iterationStartString = iterationStart.toString().replace(".", "\\.");
+
+  let regex = new RegExp("Iteration start: " + iterationStartString +
+                         " \\(" + iterationStartTimeString + "s\\)");
+  ok(title.match(regex), "The tooltip shows the expected iteration start");
+}
+
+function checkIterationBackground(el, {iterationCount, iterationStart}) {
+  info("Check the background-image used to display iterations is offset " +
+       "correctly to represent the iterationStart");
+
+  let iterationsEl = el.querySelector(".iterations");
+  let start = getIterationStartFromBackground(iterationsEl, iterationCount);
+  is(start, iterationStart % 1,
+     "The right background-position for iteration start");
+}
+
+function getIterationStartFromBackground(el, iterationCount) {
+  if (iterationCount == 1) {
+    let size = parseFloat(/([.\d]+)%/.exec(el.style.backgroundSize)[1]);
+    return 1 - size / 100;
+  }
+
+  let size = parseFloat(/([.\d]+)%/.exec(el.style.backgroundSize)[1]);
+  let position = parseFloat(/([-\d]+)%/.exec(el.style.backgroundPosition)[1]);
+  let iterationStartW = -position / size * (100 - size);
+  let rounded = Math.round(iterationStartW * 100);
+  return rounded / 10000;
+}
+
+function checkKeyframeOffset(timeBlockEl, frameEl, {iterationStart}) {
+  info("Check that the first keyframe is offset correctly");
+
+  let start = getIterationStartFromLeft(frameEl);
+  is(start, iterationStart % 1, "The frame offset for iteration start");
+}
+
+function getIterationStartFromLeft(el) {
+  let left = 100 - parseFloat(/(\d+)%/.exec(el.style.left)[1]);
+  return left / 100;
+}
--- a/devtools/client/animationinspector/test/browser_animation_timeline_shows_time_info.js
+++ b/devtools/client/animationinspector/test/browser_animation_timeline_shows_time_info.js
@@ -1,18 +1,18 @@
 /* vim: set ts=2 et sw=2 tw=80: */
 /* Any copyright is dedicated to the Public Domain.
  http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 requestLongerTimeout(2);
 
-// Check that the timeline displays animations' duration, delay and iteration
-// counts in tooltips.
+// Check that the timeline displays animations' duration, delay iteration
+// counts and iteration start in tooltips.
 
 add_task(function*() {
   yield addTab(URL_ROOT + "doc_simple_animation.html");
   let {panel, controller} = yield openAnimationInspector();
 
   info("Getting the animation element from the panel");
   let timelineEl = panel.animationsTimelineComponent.rootWrapperEl;
   let timeBlockNameEls = timelineEl.querySelectorAll(".time-block .name");
@@ -25,10 +25,12 @@ add_task(function*() {
     let title = el.getAttribute("title");
     ok(title.match(/Delay: [\d.-]+s/), "The tooltip shows the delay");
     ok(title.match(/Duration: [\d.]+s/), "The tooltip shows the duration");
     if (controller.animationPlayers[i].state.iterationCount !== 1) {
       ok(title.match(/Repeats: /), "The tooltip shows the iterations");
     } else {
       ok(!title.match(/Repeats: /), "The tooltip doesn't show the iterations");
     }
+    ok(!title.match(/Iteration start:/),
+      "The tooltip doesn't show the iteration start");
   });
 });
new file mode 100644
--- /dev/null
+++ b/devtools/client/animationinspector/test/doc_script_animation.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <style>
+  #target1 {
+    width: 50px;
+    height: 50px;
+    background: red;
+  }
+
+  #target2 {
+    width: 50px;
+    height: 50px;
+    background: green;
+  }
+
+  #target3 {
+    width: 50px;
+    height: 50px;
+    background: blue;
+  }
+  </style>
+</head>
+<body>
+  <div id="target1"></div>
+  <div id="target2"></div>
+  <div id="target3"></div>
+
+  <script>
+    "use strict";
+
+    document.getElementById("target1").animate([{ opacity: 0 }, { opacity: 1 }],
+                                               { duration: 100,
+                                                 iterations: 2,
+                                                 iterationStart: 0.25,
+                                                 fill: "both" }
+                                              );
+
+    document.getElementById("target2").animate([{ opacity: 0 }, { opacity: 1 }],
+                                               { duration: 100,
+                                                 iterations: 1,
+                                                 iterationStart: 0.25,
+                                                 fill: "both" }
+                                              );
+
+    document.getElementById("target3").animate([{ opacity: 0 }, { opacity: 1 }],
+                                               { duration: 100,
+                                                 iterations: 1.5,
+                                                 iterationStart: 2.5,
+                                                 fill: "both" }
+                                              );
+  </script>
+</body>
+</html>
--- a/devtools/client/animationinspector/utils.js
+++ b/devtools/client/animationinspector/utils.js
@@ -304,12 +304,37 @@ var TimeScale = {
     // The start position of the delay.
     let delayX = delay < 0 ? x : this.startTimeToDistance(start);
     // The width of the delay.
     let delayW = this.durationToDistance(Math.abs(delay) / rate);
     // The width of the delay if it is negative, 0 otherwise.
     let negativeDelayW = delay < 0 ? delayW : 0;
 
     return {x, w, iterationW, delayX, delayW, negativeDelayW};
+  },
+
+  /**
+   * Given an animation, get the background data for .iterations element.
+   * This background represents iterationCount and iterationStart.
+   * Returns three properties.
+   * 1. size: x of background-size (%)
+   * 2. position: x of background-position (%)
+   * 3. repeat: background-repeat (string)
+   */
+  getIterationsBackgroundData: function({state}) {
+    let iterationCount = state.iterationCount || 1;
+    let iterationStartW = state.iterationStart % 1 * 100;
+    let background = {};
+    if (iterationCount == 1) {
+      background.size = 100 - iterationStartW;
+      background.position = 0;
+      background.repeat = "no-repeat";
+    } else {
+      background.size = 1 / iterationCount * 100;
+      background.position = -iterationStartW * background.size /
+                              (100 - background.size);
+      background.repeat = "repeat-x";
+    }
+    return background;
   }
 };
 
 exports.TimeScale = TimeScale;
--- a/devtools/client/debugger/content/views/sources-view.js
+++ b/devtools/client/debugger/content/views/sources-view.js
@@ -58,16 +58,17 @@ function SourcesView(controller, Debugge
   this._onStopBlackBoxing = this._onStopBlackBoxing.bind(this);
   this._onBreakpointRemoved = this._onBreakpointRemoved.bind(this);
   this._onBreakpointClick = this._onBreakpointClick.bind(this);
   this._onBreakpointCheckboxClick = this._onBreakpointCheckboxClick.bind(this);
   this._onConditionalPopupShowing = this._onConditionalPopupShowing.bind(this);
   this._onConditionalPopupShown = this._onConditionalPopupShown.bind(this);
   this._onConditionalPopupHiding = this._onConditionalPopupHiding.bind(this);
   this._onConditionalTextboxKeyPress = this._onConditionalTextboxKeyPress.bind(this);
+  this._onEditorContextMenuOpen = this._onEditorContextMenuOpen.bind(this);
   this._onCopyUrlCommand = this._onCopyUrlCommand.bind(this);
   this._onNewTabCommand = this._onNewTabCommand.bind(this);
 }
 
 SourcesView.prototype = Heritage.extend(WidgetMethods, {
   /**
    * Initialization function, called when the debugger is started.
    */
@@ -129,16 +130,18 @@ SourcesView.prototype = Heritage.extend(
     // Sort known source groups towards the end of the list
     this.widget.groupSortPredicate = function(a, b) {
       if ((a in KNOWN_SOURCE_GROUPS) == (b in KNOWN_SOURCE_GROUPS)) {
         return a.localeCompare(b);
       }
       return (a in KNOWN_SOURCE_GROUPS) ? 1 : -1;
     };
 
+    this.DebuggerView.editor.on("popupOpen", this._onEditorContextMenuOpen);
+
     this._addCommands();
   },
 
   /**
    * Destruction function, called when the debugger is closed.
    */
   destroy: function() {
     dumpn("Destroying the SourcesView");
@@ -146,16 +149,17 @@ SourcesView.prototype = Heritage.extend(
     this.widget.removeEventListener("select", this._onSourceSelect, false);
     this._stopBlackBoxButton.removeEventListener("click", this._onStopBlackBoxing, false);
     this._cbPanel.removeEventListener("popupshowing", this._onConditionalPopupShowing, false);
     this._cbPanel.removeEventListener("popupshowing", this._onConditionalPopupShown, false);
     this._cbPanel.removeEventListener("popuphiding", this._onConditionalPopupHiding, false);
     this._cbTextbox.removeEventListener("keypress", this._onConditionalTextboxKeyPress, false);
     this._copyUrlMenuItem.removeEventListener("command", this._onCopyUrlCommand, false);
     this._newTabMenuItem.removeEventListener("command", this._onNewTabCommand, false);
+    this.DebuggerView.editor.off("popupOpen", this._onEditorContextMenuOpen, false);
   },
 
   empty: function() {
     WidgetMethods.empty.call(this);
     this._unnamedSourceIndex = 0;
     this._selectedBreakpoint = null;
   },
 
@@ -1073,16 +1077,39 @@ SourcesView.prototype = Heritage.extend(
   /**
    * The click listener for the "stop black boxing" button.
    */
   _onStopBlackBoxing: Task.async(function*() {
     this.actions.blackbox(getSelectedSource(this.getState()), false);
   }),
 
   /**
+   * The source editor's contextmenu handler.
+   * - Toggles "Add Conditional Breakpoint" and "Edit Conditional Breakpoint" items
+   */
+  _onEditorContextMenuOpen: function(message, ev, popup) {
+    let actor = this.selectedValue;
+    let line = this.DebuggerView.editor.getCursor().line + 1;
+    let location = { actor, line };
+
+    let breakpoint = getBreakpoint(this.getState(), location);
+    let addConditionalBreakpointMenuItem = popup.querySelector("#se-dbg-cMenu-addConditionalBreakpoint");
+    let editConditionalBreakpointMenuItem = popup.querySelector("#se-dbg-cMenu-editConditionalBreakpoint");
+
+    if (breakpoint && !!breakpoint.condition) {
+      editConditionalBreakpointMenuItem.removeAttribute("hidden");
+      addConditionalBreakpointMenuItem.setAttribute("hidden", true);
+    }
+    else {
+      addConditionalBreakpointMenuItem.removeAttribute("hidden");
+      editConditionalBreakpointMenuItem.setAttribute("hidden", true);
+    }
+  },
+
+  /**
    * The click listener for a breakpoint container.
    */
   _onBreakpointClick: function(e) {
     let sourceItem = this.getItemForElement(e.target);
     let breakpointItem = this.getItemForElement.call(sourceItem, e.target);
     let attachment = breakpointItem.attachment;
     let bp = getBreakpoint(this.getState(), attachment);
     if (bp) {
--- a/devtools/client/debugger/debugger-view.js
+++ b/devtools/client/debugger/debugger-view.js
@@ -62,30 +62,30 @@ var DebuggerView = {
   initialize: function() {
     if (this._startup) {
       return this._startup;
     }
     const deferred = promise.defer();
     this._startup = deferred.promise;
 
     this._initializePanes();
+    this._initializeEditor(deferred.resolve);
     this.Toolbar.initialize();
     this.Options.initialize();
     this.Filtering.initialize();
     this.StackFrames.initialize();
     this.StackFramesClassicList.initialize();
     this.Workers.initialize();
     this.Sources.initialize();
     this.VariableBubble.initialize();
     this.WatchExpressions.initialize();
     this.EventListeners.initialize();
     this.GlobalSearch.initialize();
     this._initializeVariablesView();
 
-    this._initializeEditor(deferred.resolve);
     this._editorSource = {};
 
     document.title = L10N.getStr("DebuggerWindowTitle");
 
     this.editor.on("cursorActivity", this.Sources._onEditorCursorActivity);
 
     this.controller = DebuggerController;
     const getState = this.controller.getState;
--- a/devtools/client/debugger/debugger.xul
+++ b/devtools/client/debugger/debugger.xul
@@ -49,16 +49,20 @@
       <menuitem id="se-dbg-cMenu-addBreakpoint"
                 label="&debuggerUI.seMenuBreak;"
                 key="addBreakpointKey"
                 command="addBreakpointCommand"/>
       <menuitem id="se-dbg-cMenu-addConditionalBreakpoint"
                 label="&debuggerUI.seMenuCondBreak;"
                 key="addConditionalBreakpointKey"
                 command="addConditionalBreakpointCommand"/>
+      <menuitem id="se-dbg-cMenu-editConditionalBreakpoint"
+                label="&debuggerUI.seEditMenuCondBreak;"
+                key="addConditionalBreakpointKey"
+                command="addConditionalBreakpointCommand"/>
       <menuitem id="se-dbg-cMenu-addAsWatch"
                 label="&debuggerUI.seMenuAddWatch;"
                 key="addWatchExpressionKey"
                 command="addWatchExpressionCommand"/>
       <menuseparator/>
       <menuitem id="cMenu_copy"/>
       <menuseparator/>
       <menuitem id="cMenu_selectAll"/>
--- a/devtools/client/framework/toolbox-highlighter-utils.js
+++ b/devtools/client/framework/toolbox-highlighter-utils.js
@@ -263,17 +263,21 @@ exports.getHighlighterUtils = function(t
 
     // Note that if isRemoteHighlightable is true, there's no need to hide the
     // highlighter as the walker uses setTimeout to hide it after some time
     if (isNodeFrontHighlighted && forceHide && toolbox.highlighter && isRemoteHighlightable()) {
       isNodeFrontHighlighted = false;
       yield toolbox.highlighter.hideBoxModel();
     }
 
-    toolbox.emit("node-unhighlight");
+    // unhighlight is called when destroying the toolbox, which means that by
+    // now, the toolbox reference might have been nullified already.
+    if (toolbox) {
+      toolbox.emit("node-unhighlight");
+    }
   });
 
   /**
    * If the main, box-model, highlighter isn't enough, or if multiple
    * highlighters are needed in parallel, this method can be used to return a
    * new instance of a highlighter actor, given a type.
    * The type of the highlighter passed must be known by the server.
    * The highlighter actor returned will have the show(nodeFront) and hide()
--- a/devtools/client/inspector/rules/views/text-property-editor.js
+++ b/devtools/client/inspector/rules/views/text-property-editor.js
@@ -332,25 +332,28 @@ TextPropertyEditor.prototype = {
     } else {
       this.element.removeAttribute("dirty");
     }
 
     const sharedSwatchClass = "ruleview-swatch ";
     const colorSwatchClass = "ruleview-colorswatch";
     const bezierSwatchClass = "ruleview-bezierswatch";
     const filterSwatchClass = "ruleview-filterswatch";
+    const angleSwatchClass = "ruleview-angleswatch";
 
     let outputParser = this.ruleView._outputParser;
     let parserOptions = {
       colorSwatchClass: sharedSwatchClass + colorSwatchClass,
       colorClass: "ruleview-color",
       bezierSwatchClass: sharedSwatchClass + bezierSwatchClass,
       bezierClass: "ruleview-bezier",
       filterSwatchClass: sharedSwatchClass + filterSwatchClass,
       filterClass: "ruleview-filter",
+      angleSwatchClass: sharedSwatchClass + angleSwatchClass,
+      angleClass: "ruleview-angle",
       defaultColorType: !propDirty,
       urlClass: "theme-link",
       baseURI: this.sheetURI
     };
     let frag = outputParser.parseCssProperty(name, val, parserOptions);
     this.valueSpan.innerHTML = "";
     this.valueSpan.appendChild(frag);
 
--- a/devtools/client/inspector/shared/test/browser_styleinspector_output-parser.js
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_output-parser.js
@@ -9,16 +9,17 @@
 // This is more of a unit test than a mochitest-browser test, but can't be
 // tested with an xpcshell test as the output-parser requires the DOM to work.
 
 var {OutputParser} = require("devtools/client/shared/output-parser");
 
 const COLOR_CLASS = "color-class";
 const URL_CLASS = "url-class";
 const CUBIC_BEZIER_CLASS = "bezier-class";
+const ANGLE_CLASS = "angle-class";
 
 const TEST_DATA = [
   {
     name: "width",
     value: "100%",
     test: fragment => {
       is(countAll(fragment), 0);
       is(fragment.textContent, "100%");
@@ -155,21 +156,24 @@ const TEST_DATA = [
       is(allSwatches[3].textContent, "#F06");
       is(allSwatches[4].textContent, "red");
     }
   },
   {
     name: "background",
     value: "-moz-radial-gradient(center 45deg, circle closest-side, orange 0%, red 100%)",
     test: fragment => {
-      is(countAll(fragment), 4);
-      let allSwatches = fragment.querySelectorAll("." + COLOR_CLASS);
-      is(allSwatches.length, 2);
-      is(allSwatches[0].textContent, "orange");
-      is(allSwatches[1].textContent, "red");
+      is(countAll(fragment), 6);
+      let colorSwatches = fragment.querySelectorAll("." + COLOR_CLASS);
+      is(colorSwatches.length, 2);
+      is(colorSwatches[0].textContent, "orange");
+      is(colorSwatches[1].textContent, "red");
+      let angleSwatches = fragment.querySelectorAll("." + ANGLE_CLASS);
+      is(angleSwatches.length, 1);
+      is(angleSwatches[0].textContent, "45deg");
     }
   },
   {
     name: "background",
     value: "white  url(http://test.com/wow_such_image.png) no-repeat top left",
     test: fragment => {
       is(countAll(fragment), 3);
       is(countUrls(fragment), 1);
@@ -291,16 +295,17 @@ add_task(function*() {
   for (let i = 0; i < TEST_DATA.length; i++) {
     let data = TEST_DATA[i];
     info("Output-parser test data " + i + ". {" + data.name + " : " +
       data.value + ";}");
     data.test(parser.parseCssProperty(data.name, data.value, {
       colorClass: COLOR_CLASS,
       urlClass: URL_CLASS,
       bezierClass: CUBIC_BEZIER_CLASS,
+      angleClass: ANGLE_CLASS,
       defaultColorType: false
     }));
   }
 });
 
 function countAll(fragment) {
   return fragment.querySelectorAll("*").length;
 }
--- a/devtools/client/jar.mn
+++ b/devtools/client/jar.mn
@@ -149,16 +149,17 @@ devtools.jar:
     skin/dark-theme.css (themes/dark-theme.css)
     skin/light-theme.css (themes/light-theme.css)
     skin/firebug-theme.css (themes/firebug-theme.css)
     skin/toolbars.css (themes/toolbars.css)
     skin/variables.css (themes/variables.css)
     skin/images/add.svg (themes/images/add.svg)
     skin/images/filters.svg (themes/images/filters.svg)
     skin/images/filter-swatch.svg (themes/images/filter-swatch.svg)
+    skin/images/angle-swatch.svg (themes/images/angle-swatch.svg)
     skin/images/pseudo-class.svg (themes/images/pseudo-class.svg)
     skin/images/controls.png (themes/images/controls.png)
     skin/images/controls@2x.png (themes/images/controls@2x.png)
     skin/images/animation-fast-track.svg (themes/images/animation-fast-track.svg)
     skin/images/performance-icons.svg (themes/images/performance-icons.svg)
     skin/widgets.css (themes/widgets.css)
     skin/images/power.svg (themes/images/power.svg)
     skin/images/filetypes/dir-close.svg (themes/images/filetypes/dir-close.svg)
--- a/devtools/client/jsonview/components/json-panel.js
+++ b/devtools/client/jsonview/components/json-panel.js
@@ -54,39 +54,53 @@ define(function(require, exports, module
       if (!this.props.searchFilter) {
         return true;
       }
 
       let json = JSON.stringify(object).toLowerCase();
       return json.indexOf(this.props.searchFilter) >= 0;
     },
 
-    render: function() {
-      let content;
-      let data = this.props.data;
+    renderValue: props => {
+      let member = props.member;
 
+      // Hide object summary when object is expanded (bug 1244912).
+      if (typeof member.value == "object" && member.open) {
+        return null;
+      }
+
+      // Render the value (summary) using Reps library.
+      return Rep(props);
+    },
+
+    renderTree: function() {
       // Append custom column for displaying values. This column
       // Take all available horizontal space.
       let columns = [{
         id: "value",
         width: "100%"
       }];
 
+      // Render tree component.
+      return TreeView({
+        object: this.props.data,
+        mode: "tiny",
+        onFilter: this.onFilter.bind(this),
+        columns: columns,
+        renderValue: this.renderValue
+      });
+    },
+
+    render: function() {
+      let content;
+      let data = this.props.data;
+
       try {
         if (typeof data == "object") {
-          // Render tree component. Use Reps to render JSON values.
-          content = TreeView({
-            object: this.props.data,
-            mode: "tiny",
-            onFilter: this.onFilter.bind(this),
-            columns: columns,
-            renderValue: props => {
-              return Rep(props);
-            }
-          });
+          content = this.renderTree();
         } else {
           content = div({className: "jsonParseError"},
             data + ""
           );
         }
       } catch (err) {
         content = div({className: "jsonParseError"},
           err + ""
--- a/devtools/client/jsonview/test/browser_jsonview_valid_json.js
+++ b/devtools/client/jsonview/test/browser_jsonview_valid_json.js
@@ -14,9 +14,17 @@ add_task(function* () {
 
   let countBefore = yield getElementCount(".jsonPanelBox .treeTable .treeRow");
   ok(countBefore == 1, "There must be one row");
 
   yield expandJsonNode(".jsonPanelBox .treeTable .treeLabel");
 
   let countAfter = yield getElementCount(".jsonPanelBox .treeTable .treeRow");
   ok(countAfter == 3, "There must be three rows");
+
+  let objectCellCount = yield getElementCount(
+    ".jsonPanelBox .treeTable .objectCell");
+  ok(objectCellCount == 1, "There must be one object cell");
+
+  let objectCellText = yield getElementText(
+    ".jsonPanelBox .treeTable .objectCell");
+  ok(objectCellText == "", "The summary is hidden when object is expanded");
 });
--- a/devtools/client/locales/en-US/animationinspector.properties
+++ b/devtools/client/locales/en-US/animationinspector.properties
@@ -59,16 +59,24 @@ player.animationIterationCountLabel=Repe
 player.infiniteIterationCount=&#8734;
 
 # LOCALIZATION NOTE (player.infiniteIterationCountText):
 # See player.infiniteIterationCount for a description of what this is.
 # Unlike player.infiniteIterationCount, this string isn't used in HTML, but in
 # a tooltip.
 player.infiniteIterationCountText=∞
 
+# LOCALIZATION NOTE (player.animationIterationStartLabel):
+# This string is displayed in a tooltip that appears when hovering over
+# animations in the timeline. It is the label displayed before the animation
+# iterationStart value.
+# %1$S will be replaced by the original itaration start value
+# %2$S will be replaced by the actual time of itaration start
+player.animationIterationStartLabel=Iteration start: %1$S (%2$Ss)
+
 # LOCALIZATION NOTE (player.timeLabel):
 # This string is displayed in each animation player widget, to indicate either
 # how long (in seconds) the animation lasts, or what is the animation's current
 # time (in seconds too);
 player.timeLabel=%Ss
 
 # LOCALIZATION NOTE (player.playbackRateLabel):
 # This string is displayed in each animation player widget, as the label of
--- a/devtools/client/locales/en-US/debugger.dtd
+++ b/devtools/client/locales/en-US/debugger.dtd
@@ -162,16 +162,21 @@
 <!ENTITY debuggerUI.seMenuBreak.key "B">
 
 <!-- LOCALIZATION NOTE (debuggerUI.seMenuCondBreak): This is the text that
   -  appears in the source editor context menu for adding a conditional
   -  breakpoint. -->
 <!ENTITY debuggerUI.seMenuCondBreak     "Add Conditional Breakpoint">
 <!ENTITY debuggerUI.seMenuCondBreak.key "B">
 
+<!-- LOCALIZATION NOTE (debuggerUI.seMenuBreak): This is the text that
+  -  appears in the source editor context menu for editing a breakpoint. -->
+<!ENTITY debuggerUI.seEditMenuCondBreak     "Edit Conditional Breakpoint">
+<!ENTITY debuggerUI.seEditMenuCondBreak.key "B">
+
 <!-- LOCALIZATION NOTE (debuggerUI.tabs.*): This is the text that
   -  appears in the debugger's side pane tabs. -->
 <!ENTITY debuggerUI.tabs.workers        "Workers">
 <!ENTITY debuggerUI.tabs.sources        "Sources">
 <!ENTITY debuggerUI.tabs.traces         "Traces">
 <!ENTITY debuggerUI.tabs.callstack      "Call Stack">
 <!ENTITY debuggerUI.tabs.variables      "Variables">
 <!ENTITY debuggerUI.tabs.events         "Events">
--- a/devtools/client/memory/reducers/diffing.js
+++ b/devtools/client/memory/reducers/diffing.js
@@ -1,13 +1,14 @@
 /* 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";
 
+const Immutable = require("devtools/client/shared/vendor/immutable");
 const { immutableUpdate, assert } = require("devtools/shared/DevToolsUtils");
 const { actions, diffingState, viewState } = require("../constants");
 const { snapshotIsDiffable } = require("../utils");
 
 const handlers = Object.create(null);
 
 handlers[actions.CHANGE_VIEW] = function (diffing, { view }) {
   if (view === viewState.DIFFING) {
@@ -77,17 +78,17 @@ handlers[actions.TAKE_CENSUS_DIFF_END] =
   assert(action.second.id === diffing.secondSnapshotId,
          "Second snapshot's id should match");
 
   return immutableUpdate(diffing, {
     state: diffingState.TOOK_DIFF,
     census: {
       report: action.report,
       parentMap: action.parentMap,
-      expanded: new Set(),
+      expanded: Immutable.Set(),
       inverted: action.inverted,
       filter: action.filter,
       display: action.display,
     }
   });
 };
 
 handlers[actions.DIFFING_ERROR] = function (diffing, action) {
@@ -100,37 +101,30 @@ handlers[actions.DIFFING_ERROR] = functi
 handlers[actions.EXPAND_DIFFING_CENSUS_NODE] = function (diffing, { node }) {
   assert(diffing, "Should be diffing if expanding diffing's census nodes");
   assert(diffing.state === diffingState.TOOK_DIFF,
          "Should have taken the census diff if expanding nodes");
   assert(diffing.census, "Should have a census");
   assert(diffing.census.report, "Should have a census report");
   assert(diffing.census.expanded, "Should have a census's expanded set");
 
-  // Warning: mutable operations couched in an immutable update ahead :'( This
-  // at least lets us use referential equality on the census model itself,
-  // even though the expanded set is mutated in place.
-  const expanded = diffing.census.expanded;
-  expanded.add(node.id);
+  const expanded = diffing.census.expanded.add(node.id);
   const census = immutableUpdate(diffing.census, { expanded });
   return immutableUpdate(diffing, { census });
 };
 
 handlers[actions.COLLAPSE_DIFFING_CENSUS_NODE] = function (diffing, { node }) {
   assert(diffing, "Should be diffing if expanding diffing's census nodes");
   assert(diffing.state === diffingState.TOOK_DIFF,
          "Should have taken the census diff if expanding nodes");
   assert(diffing.census, "Should have a census");
   assert(diffing.census.report, "Should have a census report");
   assert(diffing.census.expanded, "Should have a census's expanded set");
 
-  // Warning: mutable operations couched in an immutable update ahead :'( See
-  // above comment in the EXPAND_DIFFING_CENSUS_NODE handler.
-  const expanded = diffing.census.expanded;
-  expanded.delete(node.id);
+  const expanded = diffing.census.expanded.delete(node.id);
   const census = immutableUpdate(diffing.census, { expanded });
   return immutableUpdate(diffing, { census });
 };
 
 handlers[actions.FOCUS_DIFFING_CENSUS_NODE] = function (diffing, { node }) {
   assert(diffing, "Should be diffing.");
   assert(diffing.census, "Should have a census");
   const census = immutableUpdate(diffing.census, { focused: node });
--- a/devtools/client/memory/reducers/snapshots.js
+++ b/devtools/client/memory/reducers/snapshots.js
@@ -1,13 +1,14 @@
 /* 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";
 
+const Immutable = require("devtools/client/shared/vendor/immutable");
 const { immutableUpdate, assert } = require("devtools/shared/DevToolsUtils");
 const {
   actions,
   snapshotState: states,
   dominatorTreeState,
   viewState,
 } = require("../constants");
 const DominatorTreeNode = require("devtools/shared/heapsnapshot/DominatorTreeNode");
@@ -69,17 +70,17 @@ handlers[actions.TAKE_CENSUS_START] = fu
 handlers[actions.TAKE_CENSUS_END] = function (snapshots, { id,
                                                            report,
                                                            parentMap,
                                                            display,
                                                            filter }) {
   const census = {
     report,
     parentMap,
-    expanded: new Set(),
+    expanded: Immutable.Set(),
     display,
     filter,
   };
 
   return snapshots.map(snapshot => {
     return snapshot.id === id
       ? immutableUpdate(snapshot, { state: states.SAVED_CENSUS, census })
       : snapshot;
@@ -91,40 +92,33 @@ handlers[actions.EXPAND_CENSUS_NODE] = f
     if (snapshot.id !== id) {
       return snapshot;
     }
 
     assert(snapshot.census, "Should have a census");
     assert(snapshot.census.report, "Should have a census report");
     assert(snapshot.census.expanded, "Should have a census's expanded set");
 
-    // Warning: mutable operations couched in an immutable update ahead :'( This
-    // at least lets us use referential equality on the census model itself,
-    // even though the expanded set is mutated in place.
-    const expanded = snapshot.census.expanded;
-    expanded.add(node.id);
+    const expanded = snapshot.census.expanded.add(node.id);
     const census = immutableUpdate(snapshot.census, { expanded });
     return immutableUpdate(snapshot, { census });
   });
 };
 
 handlers[actions.COLLAPSE_CENSUS_NODE] = function (snapshots, { id, node }) {
   return snapshots.map(snapshot => {
     if (snapshot.id !== id) {
       return snapshot;
     }
 
     assert(snapshot.census, "Should have a census");
     assert(snapshot.census.report, "Should have a census report");
     assert(snapshot.census.expanded, "Should have a census's expanded set");
 
-    // Warning: mutable operations couched in an immutable update ahead :'( See
-    // above comment in the EXPAND_CENSUS_NODE handler.
-    const expanded = snapshot.census.expanded;
-    expanded.delete(node.id);
+    const expanded = snapshot.census.expanded.delete(node.id);
     const census = immutableUpdate(snapshot.census, { expanded });
     return immutableUpdate(snapshot, { census });
   });
 };
 
 handlers[actions.FOCUS_CENSUS_NODE] = function (snapshots, { id, node }) {
   return snapshots.map(snapshot => {
     if (snapshot.id !== id) {
@@ -219,57 +213,50 @@ handlers[actions.FETCH_DOMINATOR_TREE_EN
 
     assert(snapshot.dominatorTree, "Should have a dominator tree model");
     assert(snapshot.dominatorTree.state == dominatorTreeState.FETCHING,
            "Should be in the FETCHING state");
 
     const dominatorTree = immutableUpdate(snapshot.dominatorTree, {
       state: dominatorTreeState.LOADED,
       root,
-      expanded: new Set(),
+      expanded: Immutable.Set(),
     });
 
     return immutableUpdate(snapshot, { dominatorTree });
   });
 };
 
 handlers[actions.EXPAND_DOMINATOR_TREE_NODE] = function (snapshots, { id, node }) {
   return snapshots.map(snapshot => {
     if (snapshot.id !== id) {
       return snapshot;
     }
 
     assert(snapshot.dominatorTree, "Should have a dominator tree");
     assert(snapshot.dominatorTree.expanded,
            "Should have the dominator tree's expanded set");
 
-    // Warning: mutable operations couched in an immutable update ahead :'( This
-    // at least lets us use referential equality on the dominatorTree model itself,
-    // even though the expanded set is mutated in place.
-    const expanded = snapshot.dominatorTree.expanded;
-    expanded.add(node.nodeId);
+    const expanded = snapshot.dominatorTree.expanded.add(node.nodeId);
     const dominatorTree = immutableUpdate(snapshot.dominatorTree, { expanded });
     return immutableUpdate(snapshot, { dominatorTree });
   });
 };
 
 handlers[actions.COLLAPSE_DOMINATOR_TREE_NODE] = function (snapshots, { id, node }) {
   return snapshots.map(snapshot => {
     if (snapshot.id !== id) {
       return snapshot;
     }
 
     assert(snapshot.dominatorTree, "Should have a dominator tree");
     assert(snapshot.dominatorTree.expanded,
            "Should have the dominator tree's expanded set");
 
-    // Warning: mutable operations couched in an immutable update ahead :'( See
-    // above comment in the EXPAND_DOMINATOR_TREE_NODE handler.
-    const expanded = snapshot.dominatorTree.expanded;
-    expanded.delete(node.nodeId);
+    const expanded = snapshot.dominatorTree.expanded.delete(node.nodeId);
     const dominatorTree = immutableUpdate(snapshot.dominatorTree, { expanded });
     return immutableUpdate(snapshot, { dominatorTree });
   });
 };
 
 handlers[actions.FOCUS_DOMINATOR_TREE_NODE] = function (snapshots, { id, node }) {
   return snapshots.map(snapshot => {
     if (snapshot.id !== id) {
--- a/devtools/client/shared/components/tree/tree-view.css
+++ b/devtools/client/shared/components/tree/tree-view.css
@@ -14,17 +14,17 @@
   --tree-header-sorted-background: #AAC3DC;
 }
 
 /******************************************************************************/
 /* TreeView Table*/
 
 .treeTable .treeLabelCell {
   padding: 2px 0 2px 0px;
-  vertical-align: middle;
+  vertical-align: top;
   white-space: nowrap;
 }
 
 .treeTable .treeValueCell {
   padding: 2px 0 2px 5px;
   overflow: hidden;
 }
 
--- a/devtools/client/shared/output-parser.js
+++ b/devtools/client/shared/output-parser.js
@@ -1,15 +1,16 @@
 /* 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";
 
 const {Cc, Ci, Cu} = require("chrome");
+const {angleUtils} = require("devtools/shared/css-angle");
 const {colorUtils} = require("devtools/shared/css-color");
 const Services = require("Services");
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 
 const BEZIER_KEYWORDS = ["linear", "ease-in-out", "ease-in", "ease-out",
                          "ease"];
 
@@ -19,16 +20,31 @@ const COLOR_TAKING_FUNCTIONS = ["linear-
                                 "repeating-linear-gradient",
                                 "-moz-repeating-linear-gradient",
                                 "radial-gradient",
                                 "-moz-radial-gradient",
                                 "repeating-radial-gradient",
                                 "-moz-repeating-radial-gradient",
                                 "drop-shadow"];
 
+// Functions that accept an angle argument.
+const ANGLE_TAKING_FUNCTIONS = ["linear-gradient",
+                                "-moz-linear-gradient",
+                                "repeating-linear-gradient",
+                                "-moz-repeating-linear-gradient",
+                                "rotate",
+                                "rotateX",
+                                "rotateY",
+                                "rotateZ",
+                                "rotate3d",
+                                "skew",
+                                "skewX",
+                                "skewY",
+                                "hue-rotate"];
+
 loader.lazyGetter(this, "DOMUtils", function() {
   return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
 });
 
 /**
  * This module is used to process text for output by developer tools. This means
  * linking JS files with the debugger, CSS files with the style editor, JS
  * functions with the debugger, placing color swatches next to colors and
@@ -43,17 +59,19 @@ loader.lazyGetter(this, "DOMUtils", func
  *   let parser = new OutputParser(document);
  *
  *   parser.parseCssProperty("color", "red"); // Returns document fragment.
  */
 function OutputParser(document) {
   this.parsed = [];
   this.doc = document;
   this.colorSwatches = new WeakMap();
-  this._onSwatchMouseDown = this._onSwatchMouseDown.bind(this);
+  this.angleSwatches = new WeakMap();
+  this._onColorSwatchMouseDown = this._onColorSwatchMouseDown.bind(this);
+  this._onAngleSwatchMouseDown = this._onAngleSwatchMouseDown.bind(this);
 }
 
 exports.OutputParser = OutputParser;
 
 OutputParser.prototype = {
   /**
    * Parse a CSS property value given a property name.
    *
@@ -62,17 +80,17 @@ OutputParser.prototype = {
    * @param  {String} value
    *         CSS Property value
    * @param  {Object} [options]
    *         Options object. For valid options and default values see
    *         _mergeOptions().
    * @return {DocumentFragment}
    *         A document fragment containing color swatches etc.
    */
-  parseCssProperty: function(name, value, options={}) {
+  parseCssProperty: function(name, value, options = {}) {
     options = this._mergeOptions(options);
 
     options.expectCubicBezier =
       safeCssPropertySupportsType(name, DOMUtils.TYPE_TIMING_FUNCTION);
     options.expectFilter = name === "filter";
     options.supportsColor =
       safeCssPropertySupportsType(name, DOMUtils.TYPE_COLOR) ||
       safeCssPropertySupportsType(name, DOMUtils.TYPE_GRADIENT);
@@ -135,50 +153,56 @@ OutputParser.prototype = {
    * @param  {String} text
    *         Text to parse.
    * @param  {Object} [options]
    *         Options object. For valid options and default values see
    *         _mergeOptions().
    * @return {DocumentFragment}
    *         A document fragment.
    */
-  _parse: function(text, options={}) {
+  _parse: function(text, options = {}) {
     text = text.trim();
     this.parsed.length = 0;
 
     let tokenStream = DOMUtils.getCSSLexer(text);
     let parenDepth = 0;
     let outerMostFunctionTakesColor = false;
 
     let colorOK = function() {
       return options.supportsColor ||
         (options.expectFilter && parenDepth === 1 &&
          outerMostFunctionTakesColor);
     };
 
+    let angleOK = function(angle) {
+      return /^-?\d+\.?\d*(deg|rad|grad|turn)$/gi.test(angle);
+    };
+
     while (true) {
       let token = tokenStream.nextToken();
       if (!token) {
         break;
       }
       if (token.tokenType === "comment") {
         continue;
       }
 
       switch (token.tokenType) {
         case "function": {
-          if (COLOR_TAKING_FUNCTIONS.indexOf(token.text) >= 0) {
-            // The function can accept a color argument, and we know
-            // it isn't special in some other way.  So, we let it
-            // through to the ordinary parsing loop so that colors
+          if (COLOR_TAKING_FUNCTIONS.includes(token.text) ||
+              ANGLE_TAKING_FUNCTIONS.includes(token.text)) {
+            // The function can accept a color or an angle argument, and we know
+            // it isn't special in some other way. So, we let it
+            // through to the ordinary parsing loop so that the value
             // can be handled in a single place.
             this._appendTextNode(text.substring(token.startOffset,
                                                 token.endOffset));
             if (parenDepth === 0) {
-              outerMostFunctionTakesColor = true;
+              outerMostFunctionTakesColor = COLOR_TAKING_FUNCTIONS.includes(
+                token.text);
             }
             ++parenDepth;
           } else {
             let functionText = this._collectFunctionText(token, text,
                                                          tokenStream);
 
             if (options.expectCubicBezier && token.text === "cubic-bezier") {
               this._appendCubicBezier(functionText, options);
@@ -192,49 +216,61 @@ OutputParser.prototype = {
         }
 
         case "ident":
           if (options.expectCubicBezier &&
               BEZIER_KEYWORDS.indexOf(token.text) >= 0) {
             this._appendCubicBezier(token.text, options);
           } else if (colorOK() && DOMUtils.isValidCSSColor(token.text)) {
             this._appendColor(token.text, options);
+          } else if (angleOK(token.text)) {
+            this._appendAngle(token.text, options);
           } else {
             this._appendTextNode(text.substring(token.startOffset,
                                                 token.endOffset));
           }
           break;
 
         case "id":
         case "hash": {
           let original = text.substring(token.startOffset, token.endOffset);
           if (colorOK() && DOMUtils.isValidCSSColor(original)) {
             this._appendColor(original, options);
           } else {
             this._appendTextNode(original);
           }
           break;
         }
-
+        case "dimension":
+          let value = text.substring(token.startOffset, token.endOffset);
+          if (angleOK(value)) {
+            this._appendAngle(value, options);
+          } else {
+            this._appendTextNode(value);
+          }
+          break;
         case "url":
         case "bad_url":
           this._appendURL(text.substring(token.startOffset, token.endOffset),
                           token.text, options);
           break;
 
         case "symbol":
           if (token.text === "(") {
             ++parenDepth;
-          } else if (token.token === ")") {
+          } else if (token.text === ")") {
             --parenDepth;
+            if (parenDepth === 0) {
+              outerMostFunctionTakesColor = false;
+            }
           }
           // falls through
         default:
-          this._appendTextNode(text.substring(token.startOffset,
-                                              token.endOffset));
+          this._appendTextNode(
+            text.substring(token.startOffset, token.endOffset));
           break;
       }
     }
 
     let result = this._toDOM();
 
     if (options.expectFilter && !options.filterSwatch) {
       result = this._wrapFilter(text, options, result);
@@ -249,17 +285,17 @@ OutputParser.prototype = {
    * @param {String} bezier
    *        The cubic-bezier timing function
    * @param {Object} options
    *        Options object. For valid options and default values see
    *        _mergeOptions()
    */
   _appendCubicBezier: function(bezier, options) {
     let container = this._createNode("span", {
-       "data-bezier": bezier
+      "data-bezier": bezier
     });
 
     if (options.bezierSwatchClass) {
       let swatch = this._createNode("span", {
         class: options.bezierSwatchClass
       });
       container.appendChild(swatch);
     }
@@ -268,16 +304,58 @@ OutputParser.prototype = {
       class: options.bezierClass
     }, bezier);
 
     container.appendChild(value);
     this.parsed.push(container);
   },
 
   /**
+   * Append a angle value to the output
+   *
+   * @param {String} angle
+   *        angle to append
+   * @param {Object} options
+   *        Options object. For valid options and default values see
+   *        _mergeOptions()
+   */
+  _appendAngle: function(angle, options) {
+    let angleObj = new angleUtils.CssAngle(angle);
+    let container = this._createNode("span", {
+      "data-angle": angle
+    });
+
+    if (options.angleSwatchClass) {
+      let swatch = this._createNode("span", {
+        class: options.angleSwatchClass
+      });
+      this.angleSwatches.set(swatch, angleObj);
+      swatch.addEventListener("mousedown", this._onAngleSwatchMouseDown, false);
+
+      // Add click listener to stop event propagation when shift key is pressed
+      // in order to prevent the value input to be focused.
+      // Bug 711942 will add a tooltip to edit angle values and we should
+      // be able to move this listener to Tooltip.js when it'll be implemented.
+      swatch.addEventListener("click", function(event) {
+        if (event.shiftKey) {
+          event.stopPropagation();
+        }
+      }, false);
+      container.appendChild(swatch);
+    }
+
+    let value = this._createNode("span", {
+      class: options.angleClass
+    }, angle);
+
+    container.appendChild(value);
+    this.parsed.push(container);
+  },
+
+  /**
    * Check if a CSS property supports a specific value.
    *
    * @param  {String} name
    *         CSS Property name to check
    * @param  {String} value
    *         CSS Property value to check
    */
   _cssPropertySupportsValue: function(name, value) {
@@ -312,17 +390,17 @@ OutputParser.prototype = {
       });
 
       if (options.colorSwatchClass) {
         let swatch = this._createNode("span", {
           class: options.colorSwatchClass,
           style: "background-color:" + color
         });
         this.colorSwatches.set(swatch, colorObj);
-        swatch.addEventListener("mousedown", this._onSwatchMouseDown, false);
+        swatch.addEventListener("mousedown", this._onColorSwatchMouseDown, false);
         container.appendChild(swatch);
       }
 
       if (options.defaultColorType) {
         color = colorObj.toString();
         container.dataset.color = color;
       }
 
@@ -366,31 +444,46 @@ OutputParser.prototype = {
       class: options.filterClass
     });
     value.appendChild(nodes);
     container.appendChild(value);
 
     return container;
   },
 
-  _onSwatchMouseDown: function(event) {
+  _onColorSwatchMouseDown: function(event) {
     // Prevent text selection in the case of shift-click or double-click.
     event.preventDefault();
 
     if (!event.shiftKey) {
       return;
     }
 
     let swatch = event.target;
     let color = this.colorSwatches.get(swatch);
     let val = color.nextColorUnit();
 
     swatch.nextElementSibling.textContent = val;
   },
 
+  _onAngleSwatchMouseDown: function(event) {
+    // Prevent text selection in the case of shift-click or double-click.
+    event.preventDefault();
+
+    if (!event.shiftKey) {
+      return;
+    }
+
+    let swatch = event.target;
+    let angle = this.angleSwatches.get(swatch);
+    let val = angle.nextAngleUnit();
+
+    swatch.nextElementSibling.textContent = val;
+  },
+
   /**
    * A helper function that sanitizes a possibly-unterminated URL.
    */
   _sanitizeURL: function(url) {
     // Re-lex the URL and add any needed termination characters.
     let urlTokenizer = DOMUtils.getCSSLexer(url);
     // Just read until EOF; there will only be a single token.
     while (urlTokenizer.nextToken()) {
@@ -538,16 +631,19 @@ OutputParser.prototype = {
    *           - defaultColorType: true // Convert colors to the default type
    *                                    // selected in the options panel.
    *           - colorSwatchClass: ""   // The class to use for color swatches.
    *           - colorClass: ""         // The class to use for the color value
    *                                    // that follows the swatch.
    *           - bezierSwatchClass: ""  // The class to use for bezier swatches.
    *           - bezierClass: ""        // The class to use for the bezier value
    *                                    // that follows the swatch.
+   *           - angleSwatchClass: ""   // The class to use for angle swatches.
+   *           - angleClass: ""         // The class to use for the angle value
+   *                                    // that follows the swatch.
    *           - supportsColor: false   // Does the CSS property support colors?
    *           - urlClass: ""           // The class to be used for url() links.
    *           - baseURI: ""            // A string or nsIURI used to resolve
    *                                    // relative links.
    *           - filterSwatch: false    // A special case for parsing a
    *                                    // "filter" property, causing the
    *                                    // parser to skip the call to
    *                                    // _wrapFilter.  Used only for
@@ -557,16 +653,18 @@ OutputParser.prototype = {
    */
   _mergeOptions: function(overrides) {
     let defaults = {
       defaultColorType: true,
       colorSwatchClass: "",
       colorClass: "",
       bezierSwatchClass: "",
       bezierClass: "",
+      angleSwatchClass: "",
+      angleClass: "",
       supportsColor: false,
       urlClass: "",
       baseURI: "",
       filterSwatch: false
     };
 
     if (typeof overrides.baseURI === "string") {
       overrides.baseURI = Services.io.newURI(overrides.baseURI, null, null);
--- a/devtools/client/shared/test/browser.ini
+++ b/devtools/client/shared/test/browser.ini
@@ -14,16 +14,17 @@ support-files =
   html-mdn-css-no-summary.html
   html-mdn-css-no-summary-or-syntax.html
   html-mdn-css-no-syntax.html
   html-mdn-css-syntax-old-style.html
   leakhunt.js
   test-actor.js
   test-actor-registry.js
 
+[browser_css_angle.js]
 [browser_css_color.js]
 [browser_cubic-bezier-01.js]
 [browser_cubic-bezier-02.js]
 [browser_cubic-bezier-03.js]
 [browser_cubic-bezier-04.js]
 [browser_cubic-bezier-05.js]
 [browser_cubic-bezier-06.js]
 [browser_filter-editor-01.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/test/browser_css_angle.js
@@ -0,0 +1,126 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from head.js */
+"use strict";
+
+const TEST_URI = "data:text/html;charset=utf-8,browser_css_angle.js";
+var {angleUtils} = require("devtools/shared/css-angle");
+
+add_task(function*() {
+  yield addTab("about:blank");
+  let [host] = yield createHost("bottom", TEST_URI);
+
+  info("Starting the test");
+  testAngleUtils();
+
+  host.destroy();
+  gBrowser.removeCurrentTab();
+});
+
+function testAngleUtils() {
+  let data = getTestData();
+
+  for (let {authored, deg, rad, grad, turn} of data) {
+    let angle = new angleUtils.CssAngle(authored);
+
+    // Check all values.
+    info("Checking values for " + authored);
+    is(angle.deg, deg, "color.deg === deg");
+    is(angle.rad, rad, "color.rad === rad");
+    is(angle.grad, grad, "color.grad === grad");
+    is(angle.turn, turn, "color.turn === turn");
+
+    testToString(angle, deg, rad, grad, turn);
+  }
+}
+
+function testToString(angle, deg, rad, grad, turn) {
+  angle.angleUnit = angleUtils.CssAngle.ANGLEUNIT.deg;
+  is(angle.toString(), deg, "toString() with deg type");
+
+  angle.angleUnit = angleUtils.CssAngle.ANGLEUNIT.rad;
+  is(angle.toString(), rad, "toString() with rad type");
+
+  angle.angleUnit = angleUtils.CssAngle.ANGLEUNIT.grad;
+  is(angle.toString(), grad, "toString() with grad type");
+
+  angle.angleUnit = angleUtils.CssAngle.ANGLEUNIT.turn;
+  is(angle.toString(), turn, "toString() with turn type");
+}
+
+function getTestData() {
+  return [{
+    authored: "0deg",
+    deg: "0deg",
+    rad: "0rad",
+    grad: "0grad",
+    turn: "0turn"
+  }, {
+    authored: "180deg",
+    deg: "180deg",
+    rad: "3.14rad",
+    grad: "200grad",
+    turn: "0.5turn"
+  }, {
+    authored: "180DEG",
+    deg: "180DEG",
+    rad: "3.14RAD",
+    grad: "200GRAD",
+    turn: "0.5TURN"
+  }, {
+    authored: `-${Math.PI}rad`,
+    deg: "-180deg",
+    rad: `-${Math.PI}rad`,
+    grad: "-200grad",
+    turn: "-0.5turn"
+  }, {
+    authored: `-${Math.PI}RAD`,
+    deg: "-180DEG",
+    rad: `-${Math.PI}RAD`,
+    grad: "-200GRAD",
+    turn: "-0.5TURN"
+  }, {
+    authored: "100grad",
+    deg: "90deg",
+    rad: "1.57rad",
+    grad: "100grad",
+    turn: "0.25turn"
+  }, {
+    authored: "100GRAD",
+    deg: "90DEG",
+    rad: "1.57RAD",
+    grad: "100GRAD",
+    turn: "0.25TURN"
+  }, {
+    authored: "-1turn",
+    deg: "-360deg",
+    rad: "-6.28rad",
+    grad: "-400grad",
+    turn: "-1turn"
+  }, {
+    authored: "-10TURN",
+    deg: "-3600DEG",
+    rad: "-62.83RAD",
+    grad: "-4000GRAD",
+    turn: "-10TURN"
+  }, {
+    authored: "inherit",
+    deg: "inherit",
+    rad: "inherit",
+    grad: "inherit",
+    turn: "inherit"
+  }, {
+    authored: "initial",
+    deg: "initial",
+    rad: "initial",
+    grad: "initial",
+    turn: "initial"
+  }, {
+    authored: "unset",
+    deg: "unset",
+    rad: "unset",
+    grad: "unset",
+    turn: "unset"
+  }];
+}
--- a/devtools/client/shared/test/browser_outputparser.js
+++ b/devtools/client/shared/test/browser_outputparser.js
@@ -17,16 +17,17 @@ function* performTest() {
   let [host, , doc] = yield createHost("bottom", "data:text/html," +
     "<h1>browser_outputParser.js</h1><div></div>");
 
   let parser = new OutputParser(doc);
   testParseCssProperty(doc, parser);
   testParseCssVar(doc, parser);
   testParseURL(doc, parser);
   testParseFilter(doc, parser);
+  testParseAngle(doc, parser);
 
   host.destroy();
 }
 
 // Class name used in color swatch.
 var COLOR_TEST_CLASS = "test-class";
 
 // Create a new CSS color-parsing test.  |name| is the name of the CSS
@@ -96,18 +97,18 @@ function testParseCssProperty(doc, parse
                    "blur(1px) drop-shadow(0 0 0 ",
                    {name: "blue"},
                    ") url(red.svg#blue)</span></span>"]),
 
     makeColorTest("color", "currentColor", ["currentColor"]),
 
     // Test a very long property.
     makeColorTest("background-image",
-                  "linear-gradient(0deg, transparent 0, transparent 5%,#F00 0, #F00 10%,#FF0 0, #FF0 15%,#0F0 0, #0F0 20%,#0FF 0, #0FF 25%,#00F 0, #00F 30%,#800 0, #800 35%,#880 0, #880 40%,#080 0, #080 45%,#088 0, #088 50%,#008 0, #008 55%,#FFF 0, #FFF 60%,#EEE 0, #EEE 65%,#CCC 0, #CCC 70%,#999 0, #999 75%,#666 0, #666 80%,#333 0, #333 85%,#111 0, #111 90%,#000 0, #000 95%,transparent 0, transparent 100%)",
-                  ["linear-gradient(0deg, ", {name: "transparent"},
+                  "linear-gradient(to left, transparent 0, transparent 5%,#F00 0, #F00 10%,#FF0 0, #FF0 15%,#0F0 0, #0F0 20%,#0FF 0, #0FF 25%,#00F 0, #00F 30%,#800 0, #800 35%,#880 0, #880 40%,#080 0, #080 45%,#088 0, #088 50%,#008 0, #008 55%,#FFF 0, #FFF 60%,#EEE 0, #EEE 65%,#CCC 0, #CCC 70%,#999 0, #999 75%,#666 0, #666 80%,#333 0, #333 85%,#111 0, #111 90%,#000 0, #000 95%,transparent 0, transparent 100%)",
+                  ["linear-gradient(to left, ", {name: "transparent"},
                    " 0, ", {name: "transparent"},
                    " 5%,", {name: "#F00"},
                    " 0, ", {name: "#F00"},
                    " 10%,", {name: "#FF0"},
                    " 0, ", {name: "#FF0"},
                    " 15%,", {name: "#0F0"},
                    " 0, ", {name: "#0F0"},
                    " 20%,", {name: "#0FF"},
@@ -254,8 +255,25 @@ function testParseFilter(doc, parser) {
   let frag = parser.parseCssProperty("filter", "something invalid", {
     filterSwatchClass: "test-filterswatch"
   });
 
   let swatchCount = frag.querySelectorAll(".test-filterswatch").length;
   is(swatchCount, 1, "filter swatch was created");
 }
 
+function testParseAngle(doc, parser) {
+  let frag = parser.parseCssProperty("image-orientation", "90deg", {
+    angleSwatchClass: "test-angleswatch"
+  });
+
+  let swatchCount = frag.querySelectorAll(".test-angleswatch").length;
+  is(swatchCount, 1, "angle swatch was created");
+
+  frag = parser.parseCssProperty("background-image",
+    "linear-gradient(90deg, red, blue", {
+      angleSwatchClass: "test-angleswatch"
+    });
+
+  swatchCount = frag.querySelectorAll(".test-angleswatch").length;
+  is(swatchCount, 1, "angle swatch was created");
+}
+
--- a/devtools/client/sourceeditor/editor.js
+++ b/devtools/client/sourceeditor/editor.js
@@ -332,16 +332,18 @@ Editor.prototype = {
         if (!this.config.contextMenu) {
           return;
         }
 
         let popup = this.config.contextMenu;
         if (typeof popup == "string") {
           popup = el.ownerDocument.getElementById(this.config.contextMenu);
         }
+
+        this.emit("popupOpen",  ev, popup);
         popup.openPopupAtScreen(ev.screenX, ev.screenY, true);
       }, false);
 
       // Intercept the find and find again keystroke on CodeMirror, to avoid
       // the browser's search
 
       let findKey = L10N.GetStringFromName("find.commandkey");
       let findAgainKey = L10N.GetStringFromName("findAgain.commandkey");
--- a/devtools/client/themes/animationinspector.css
+++ b/devtools/client/themes/animationinspector.css
@@ -325,23 +325,21 @@ body {
   height: 100%;
   box-sizing: border-box;
 
   /* Iterations of the animation are displayed with a repeating linear-gradient
      which size is dynamically changed from JS. The gradient only draws 1px
      borders between each iteration. These borders must have the same color as
      the border of this element */
   background-image:
-    linear-gradient(to right,
+    linear-gradient(to left,
                     var(--timeline-border-color) 0,
                     var(--timeline-border-color) 1px,
                     transparent 1px,
                     transparent 2px);
-  background-repeat: repeat-x;
-  background-position: -1px 0;
   border: 1px solid var(--timeline-border-color);
   /* Border-right is already handled by the gradient */
   border-width: 1px 0 1px 1px;
 
   /* The background color is set independently */
   background-color: var(--timeline-background-color);
 }
 
new file mode 100644
--- /dev/null
+++ b/devtools/client/themes/images/angle-swatch.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12px" height="12px">
+  <mask id="angle-mask">
+    <rect width="100%" height="100%" fill="#fff"/>
+    <polygon points="6 6, 12 12, 0 12, 0 0, 6 0, 6 6"/>
+  </mask>
+  <mask id="circle-mask">
+    <circle cx="6" cy="6" r="6" fill="#fff"/>
+  </mask>
+  <circle cx="6" cy="6" r="6" fill="#fff"/>
+  <circle cx="6" cy="6" r="6" mask="url(#angle-mask)" fill="#aeb0b1"/>
+  <line x1="6" y1="0" x2="6" y2="6" stroke-width="0.5" stroke="rgba(0,0,0,0.5)"></line>
+  <line x1="6" y1="6" x2="12" y2="12" stroke-width="0.5" stroke="rgba(0,0,0,0.5)" mask="url(#circle-mask)"></line>
+</svg>
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -315,16 +315,21 @@
   background-size: 1em;
 }
 
 .ruleview-filterswatch {
   background: url("chrome://devtools/skin/images/filter-swatch.svg");
   background-size: 1em;
 }
 
+.ruleview-angleswatch {
+  background: url("chrome://devtools/skin/images/angle-swatch.svg");
+  background-size: 1em;
+}
+
 @media (min-resolution: 1.1dppx) {
   .ruleview-bezierswatch {
     background: url("chrome://devtools/skin/images/cubic-bezier-swatch@2x.png");
     background-size: 1em;
   }
 }
 
 .ruleview-overridden {
--- a/devtools/server/actors/animation.js
+++ b/devtools/server/actors/animation.js
@@ -188,16 +188,25 @@ var AnimationPlayerActor = ActorClass({
    * infinitely.
    */
   getIterationCount: function() {
     let iterations = this.player.effect.getComputedTiming().iterations;
     return iterations === "Infinity" ? null : iterations;
   },
 
   /**
+   * Get the animation iterationStart from this player, in ratio.
+   * That is offset of starting position of the animation.
+   * @return {Number}
+   */
+  getIterationStart: function() {
+    return this.player.effect.getComputedTiming().iterationStart;
+  },
+
+  /**
    * Return the current start of the Animation.
    * @return {Object}
    */
   getState: function() {
     // Remember the startTime each time getState is called, it may be useful
     // when animations get paused. As in, when an animation gets paused, its
     // startTime goes back to null, but the front-end might still be interested
     // in knowing what the previous startTime was. So everytime it is set,
@@ -216,16 +225,17 @@ var AnimationPlayerActor = ActorClass({
       previousStartTime: this.previousStartTime,
       currentTime: this.player.currentTime,
       playState: this.player.playState,
       playbackRate: this.player.playbackRate,
       name: this.getName(),
       duration: this.getDuration(),
       delay: this.getDelay(),
       iterationCount: this.getIterationCount(),
+      iterationStart: this.getIterationStart(),
       // animation is hitting the fast path or not. Returns false whenever the
       // animation is paused as it is taken off the compositor then.
       isRunningOnCompositor: this.player.isRunningOnCompositor,
       // The document timeline's currentTime is being sent along too. This is
       // not strictly related to the node's animationPlayer, but is useful to
       // know the current time of the animation with respect to the document's.
       documentCurrentTime: this.node.ownerDocument.timeline.currentTime
     };
@@ -281,22 +291,23 @@ var AnimationPlayerActor = ActorClass({
       if (hasCurrentAnimation(removedAnimations)) {
         // Reset the local copy of the state on removal, since the animation can
         // be kept on the client and re-added, its state needs to be sent in
         // full.
         this.currentState = null;
       }
 
       if (hasCurrentAnimation(changedAnimations)) {
-        // Only consider the state has having changed if any of delay, duration
-        // or iterationcount has changed (for now at least).
+        // Only consider the state has having changed if any of delay, duration,
+        // iterationcount or iterationStart has changed (for now at least).
         let newState = this.getState();
         let oldState = this.currentState;
         hasChanged = newState.delay !== oldState.delay ||
                      newState.iterationCount !== oldState.iterationCount ||
+                     newState.iterationStart !== oldState.iterationStart ||
                      newState.duration !== oldState.duration;
         break;
       }
     }
 
     if (hasChanged) {
       events.emit(this, "changed", this.getCurrentState());
     }
@@ -427,16 +438,17 @@ var AnimationPlayerFront = FrontClass(An
       previousStartTime: this._form.previousStartTime,
       currentTime: this._form.currentTime,
       playState: this._form.playState,
       playbackRate: this._form.playbackRate,
       name: this._form.name,
       duration: this._form.duration,
       delay: this._form.delay,
       iterationCount: this._form.iterationCount,
+      iterationStart: this._form.iterationStart,
       isRunningOnCompositor: this._form.isRunningOnCompositor,
       documentCurrentTime: this._form.documentCurrentTime
     };
   },
 
   /**
    * Executed when the AnimationPlayerActor emits a "changed" event. Used to
    * update the local knowledge of the state.
new file mode 100644
--- /dev/null
+++ b/devtools/shared/css-angle.js
@@ -0,0 +1,348 @@
+/* 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";
+
+const {Cc, Ci} = require("chrome");
+
+const SPECIALVALUES = new Set([
+  "initial",
+  "inherit",
+  "unset"
+]);
+
+/**
+ * This module is used to convert between various angle units.
+ *
+ * Usage:
+ *   let {require} = Cu.import("resource://devtools/shared/Loader.jsm", {});
+ *   let {angleUtils} = require("devtools/shared/css-angle");
+ *   let angle = new angleUtils.CssAngle("180deg");
+ *
+ *   angle.authored === "180deg"
+ *   angle.valid === true
+ *   angle.rad === "3,14rad"
+ *   angle.grad === "200grad"
+ *   angle.turn === "0.5turn"
+ *
+ *   angle.toString() === "180deg"; // Outputs the angle value and its unit
+ *   // Angle objects can be reused
+ *   angle.newAngle("-1TURN") === "-1TURN"; // true
+ */
+
+function CssAngle(angleValue) {
+  this.newAngle(angleValue);
+}
+
+module.exports.angleUtils = {
+  CssAngle: CssAngle,
+  classifyAngle: classifyAngle
+};
+
+CssAngle.ANGLEUNIT = {
+  "deg": "deg",
+  "rad": "rad",
+  "grad": "grad",
+  "turn": "turn"
+};
+
+CssAngle.prototype = {
+  _angleUnit: null,
+  _angleUnitUppercase: false,
+
+  // The value as-authored.
+  authored: null,
+  // A lower-cased copy of |authored|.
+  lowerCased: null,
+
+  get angleUnit() {
+    if (this._angleUnit === null) {
+      this._angleUnit = classifyAngle(this.authored);
+    }
+    return this._angleUnit;
+  },
+
+  set angleUnit(unit) {
+    this._angleUnit = unit;
+  },
+
+  get valid() {
+    return /^-?\d+\.?\d*(deg|rad|grad|turn)$/gi.test(this.authored);
+  },
+
+  get specialValue() {
+    return SPECIALVALUES.has(this.lowerCased) ? this.authored : null;
+  },
+
+  get deg() {
+    let invalidOrSpecialValue = this._getInvalidOrSpecialValue();
+    if (invalidOrSpecialValue !== false) {
+      return invalidOrSpecialValue;
+    }
+
+    let angleUnit = classifyAngle(this.authored);
+    if (angleUnit === CssAngle.ANGLEUNIT.deg) {
+      // The angle is valid and is in degree.
+      return this.authored;
+    }
+
+    let degValue;
+    if (angleUnit === CssAngle.ANGLEUNIT.rad) {
+      // The angle is valid and is in radian.
+      degValue = this.authoredAngleValue / (Math.PI / 180);
+    }
+
+    if (angleUnit === CssAngle.ANGLEUNIT.grad) {
+      // The angle is valid and is in gradian.
+      degValue = this.authoredAngleValue * 0.9;
+    }
+
+    if (angleUnit === CssAngle.ANGLEUNIT.turn) {
+      // The angle is valid and is in turn.
+      degValue = this.authoredAngleValue * 360;
+    }
+
+    let unitStr = CssAngle.ANGLEUNIT.deg;
+    if (this._angleUnitUppercase === true) {
+      unitStr = unitStr.toUpperCase();
+    }
+    return `${Math.round(degValue * 100) / 100}${unitStr}`;
+  },
+
+  get rad() {
+    let invalidOrSpecialValue = this._getInvalidOrSpecialValue();
+    if (invalidOrSpecialValue !== false) {
+      return invalidOrSpecialValue;
+    }
+
+    let unit = classifyAngle(this.authored);
+    if (unit === CssAngle.ANGLEUNIT.rad) {
+      // The angle is valid and is in radian.
+      return this.authored;
+    }
+
+    let radValue;
+    if (unit === CssAngle.ANGLEUNIT.deg) {
+      // The angle is valid and is in degree.
+      radValue = this.authoredAngleValue * (Math.PI / 180);
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.grad) {
+      // The angle is valid and is in gradian.
+      radValue = this.authoredAngleValue * 0.9 * (Math.PI / 180);
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.turn) {
+      // The angle is valid and is in turn.
+      radValue = this.authoredAngleValue * 360 * (Math.PI / 180);
+    }
+
+    let unitStr = CssAngle.ANGLEUNIT.rad;
+    if (this._angleUnitUppercase === true) {
+      unitStr = unitStr.toUpperCase();
+    }
+    return `${Math.round(radValue * 100) / 100}${unitStr}`;
+  },
+
+  get grad() {
+    let invalidOrSpecialValue = this._getInvalidOrSpecialValue();
+    if (invalidOrSpecialValue !== false) {
+      return invalidOrSpecialValue;
+    }
+
+    let unit = classifyAngle(this.authored);
+    if (unit === CssAngle.ANGLEUNIT.grad) {
+      // The angle is valid and is in gradian
+      return this.authored;
+    }
+
+    let gradValue;
+    if (unit === CssAngle.ANGLEUNIT.deg) {
+      // The angle is valid and is in degree
+      gradValue = this.authoredAngleValue / 0.9;
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.rad) {
+      // The angle is valid and is in radian
+      gradValue = this.authoredAngleValue / 0.9 / (Math.PI / 180);
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.turn) {
+      // The angle is valid and is in turn
+      gradValue = this.authoredAngleValue * 400;
+    }
+
+    let unitStr = CssAngle.ANGLEUNIT.grad;
+    if (this._angleUnitUppercase === true) {
+      unitStr = unitStr.toUpperCase();
+    }
+    return `${Math.round(gradValue * 100) / 100}${unitStr}`;
+  },
+
+  get turn() {
+    let invalidOrSpecialValue = this._getInvalidOrSpecialValue();
+    if (invalidOrSpecialValue !== false) {
+      return invalidOrSpecialValue;
+    }
+
+    let unit = classifyAngle(this.authored);
+    if (unit === CssAngle.ANGLEUNIT.turn) {
+      // The angle is valid and is in turn
+      return this.authored;
+    }
+
+    let turnValue;
+    if (unit === CssAngle.ANGLEUNIT.deg) {
+      // The angle is valid and is in degree
+      turnValue = this.authoredAngleValue / 360;
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.rad) {
+      // The angle is valid and is in radian
+      turnValue = (this.authoredAngleValue / (Math.PI / 180)) / 360;
+    }
+
+    if (unit === CssAngle.ANGLEUNIT.grad) {
+      // The angle is valid and is in gradian
+      turnValue = this.authoredAngleValue / 400;
+    }
+
+    let unitStr = CssAngle.ANGLEUNIT.turn;
+    if (this._angleUnitUppercase === true) {
+      unitStr = unitStr.toUpperCase();
+    }
+    return `${Math.round(turnValue * 100) / 100}${unitStr}`;
+  },
+
+  /**
+   * Check whether the angle value is in the special list e.g.
+   * inherit or invalid.
+   *
+   * @return {String|Boolean}
+   *         - If the current angle is a special value e.g. "inherit" then
+   *           return the angle.
+   *         - If the angle is invalid return an empty string.
+   *         - If the angle is a regular angle e.g. 90deg so we return false
+   *           to indicate that the angle is neither invalid nor special.
+   */
+  _getInvalidOrSpecialValue: function() {
+    if (this.specialValue) {
+      return this.specialValue;
+    }
+    if (!this.valid) {
+      return "";
+    }
+    return false;
+  },
+
+  /**
+   * Change angle
+   *
+   * @param  {String} angle
+   *         Any valid angle value + unit string
+   */
+  newAngle: function(angle) {
+    // Store a lower-cased version of the angle to help with format
+    // testing.  The original text is kept as well so it can be
+    // returned when needed.
+    this.lowerCased = angle.toLowerCase();
+    this._angleUnitUppercase = (angle === angle.toUpperCase());
+    this.authored = angle;
+
+    let reg = new RegExp(
+      `(${Object.keys(CssAngle.ANGLEUNIT).join("|")})$`, "i");
+    let unitStartIdx = angle.search(reg);
+    this.authoredAngleValue = angle.substring(0, unitStartIdx);
+    this.authoredAngleUnit = angle.substring(unitStartIdx, angle.length);
+
+    return this;
+  },
+
+  nextAngleUnit: function() {
+    // Get a reordered array from the formats object
+    // to have the current format at the front so we can cycle through.
+    let formats = Object.keys(CssAngle.ANGLEUNIT);
+    let putOnEnd = formats.splice(0, formats.indexOf(this.angleUnit));
+    formats = formats.concat(putOnEnd);
+    let currentDisplayedValue = this[formats[0]];
+
+    for (let format of formats) {
+      if (this[format].toLowerCase() !== currentDisplayedValue.toLowerCase()) {
+        this.angleUnit = CssAngle.ANGLEUNIT[format];
+        break;
+      }
+    }
+    return this.toString();
+  },
+
+  /**
+   * Return a string representing a angle
+   */
+  toString: function() {
+    let angle;
+
+    switch (this.angleUnit) {
+      case CssAngle.ANGLEUNIT.deg:
+        angle = this.deg;
+        break;
+      case CssAngle.ANGLEUNIT.rad:
+        angle = this.rad;
+        break;
+      case CssAngle.ANGLEUNIT.grad:
+        angle = this.grad;
+        break;
+      case CssAngle.ANGLEUNIT.turn:
+        angle = this.turn;
+        break;
+      default:
+        angle = this.deg;
+    }
+
+    if (this._angleUnitUppercase &&
+        this.angleUnit != CssAngle.ANGLEUNIT.authored) {
+      angle = angle.toUpperCase();
+    }
+    return angle;
+  },
+
+  /**
+   * This method allows comparison of CssAngle objects using ===.
+   */
+  valueOf: function() {
+    return this.deg;
+  },
+};
+
+/**
+ * Given a color, classify its type as one of the possible angle
+ * units, as known by |CssAngle.angleUnit|.
+ *
+ * @param  {String} value
+ *         The angle, in any form accepted by CSS.
+ * @return {String}
+ *         The angle classification, one of "deg", "rad", "grad", or "turn".
+ */
+function classifyAngle(value) {
+  value = value.toLowerCase();
+  if (value.endsWith("deg")) {
+    return CssAngle.ANGLEUNIT.deg;
+  }
+
+  if (value.endsWith("grad")) {
+    return CssAngle.ANGLEUNIT.grad;
+  }
+
+  if (value.endsWith("rad")) {
+    return CssAngle.ANGLEUNIT.rad;
+  }
+  if (value.endsWith("turn")) {
+    return CssAngle.ANGLEUNIT.turn;
+  }
+
+  return CssAngle.ANGLEUNIT.deg;
+}
+
+loader.lazyGetter(this, "DOMUtils", function() {
+  return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
+});
--- a/devtools/shared/gcli/commands/cookie.js
+++ b/devtools/shared/gcli/commands/cookie.js
@@ -13,35 +13,31 @@
  * However, server-running commands have no way of accessing the parent process
  * for now.
  *
  * So, because these cookie commands, as of today, only run in the developer
  * toolbar (the gcli command bar), and because this toolbar is only available on
  * a local Firefox desktop tab (not in webide or the browser toolbox), we can
  * make the commands run on the client.
  * This way, they'll always run in the parent process.
- *
- * Note that the commands also need access to the content (see
- * context.environment.document) which means that as long as they run on the
- * client, they'll be using CPOWs (when e10s is enabled).
  */
 
 const { Ci, Cc } = require("chrome");
 const l10n = require("gcli/l10n");
 const URL = require("sdk/url").URL;
 
 XPCOMUtils.defineLazyGetter(this, "cookieMgr", function() {
   return Cc["@mozilla.org/cookiemanager;1"].getService(Ci.nsICookieManager2);
 });
 
 /**
  * Check host value and remove port part as it is not used
  * for storing cookies.
  *
- * Parameter will usually be context.environment.document.location.host
+ * Parameter will usually be `new URL(context.environment.target.url).host`
  */
 function sanitizeHost(host) {
   if (host == null || host == "") {
     throw new Error(l10n.lookup("cookieListOutNonePage"));
   }
   return host.split(":")[0];
 }
 
@@ -81,18 +77,18 @@ exports.items = [
     description: l10n.lookup("cookieListDesc"),
     manual: l10n.lookup("cookieListManual"),
     returnType: "cookies",
     exec: function(args, context) {
       if (context.environment.target.isRemote) {
         throw new Error("The cookie gcli commands only work in a local tab, " +
                         "see bug 1221488");
       }
-      let host = sanitizeHost(context.environment.document.location.host);
-
+      let host = new URL(context.environment.target.url).host;
+      host = sanitizeHost(host);
       let enm = cookieMgr.getCookiesFromHost(host);
 
       let cookies = [];
       while (enm.hasMoreElements()) {
         let cookie = enm.getNext().QueryInterface(Ci.nsICookie);
         if (isCookieAtHost(cookie, host)) {
           cookies.push({
             host: cookie.host,
@@ -123,17 +119,18 @@ exports.items = [
         description: l10n.lookup("cookieRemoveKeyDesc"),
       }
     ],
     exec: function(args, context) {
       if (context.environment.target.isRemote) {
         throw new Error("The cookie gcli commands only work in a local tab, " +
                         "see bug 1221488");
       }
-      let host = sanitizeHost(context.environment.document.location.host);
+      let host = new URL(context.environment.target.url).host;
+      host = sanitizeHost(host);
       let enm = cookieMgr.getCookiesFromHost(host);
 
       while (enm.hasMoreElements()) {
         let cookie = enm.getNext().QueryInterface(Ci.nsICookie);
         if (isCookieAtHost(cookie, host)) {
           if (cookie.name == args.name) {
             cookieMgr.remove(cookie.host, cookie.name, cookie.path,
                              cookie.originAttributes, false);
@@ -263,17 +260,18 @@ exports.items = [
         ]
       }
     ],
     exec: function(args, context) {
       if (context.environment.target.isRemote) {
         throw new Error("The cookie gcli commands only work in a local tab, " +
                         "see bug 1221488");
       }
-      let host = sanitizeHost(context.environment.document.location.host);
+      let host = new URL(context.environment.target.url).host;
+      host = sanitizeHost(host);
       let time = Date.parse(args.expires) / 1000;
 
       cookieMgr.add(args.domain ? "." + args.domain : host,
                     args.path ? args.path : "/",
                     args.name,
                     args.value,
                     args.secure,
                     args.httpOnly,
--- a/devtools/shared/gcli/commands/security.js
+++ b/devtools/shared/gcli/commands/security.js
@@ -138,35 +138,35 @@ exports.items = [
       return outPolicies;
     }
   },
   {
     item: "converter",
     from: "securityCSPInfo",
     to: "view",
     exec: function(cspInfo, context) {
-      var uri = context.environment.document.documentURI;
+      var url = context.environment.target.url;
 
       if (cspInfo.length == 0) {
         return context.createView({
           html:
             "<table class='gcli-csp-detail' cellspacing='10' valign='top'>" +
             "  <tr>" +
             "    <td> <img src='chrome://browser/content/gcli_sec_bad.svg' width='20px' /> </td> " +
-            "    <td>" + NO_CSP_ON_PAGE_MSG + " <b>" + uri + "</b></td>" +
+            "    <td>" + NO_CSP_ON_PAGE_MSG + " <b>" + url + "</b></td>" +
             "  </tr>" +
             "</table>"});
       }
 
       return context.createView({
         html:
           "<table class='gcli-csp-detail' cellspacing='10' valign='top'>" +
           // iterate all policies
           "  <tr foreach='csp in ${cspinfo}' >" +
-          "    <td> ${csp.header} <b>" + uri + "</b><br/><br/>" +
+          "    <td> ${csp.header} <b>" + url + "</b><br/><br/>" +
           "      <table class='gcli-csp-dir-detail' valign='top'>" +
           // >> iterate all directives
           "        <tr foreach='dir in ${csp.directives}' >" +
           "          <td valign='top'> ${dir.dirValue} </td>" +
           "          <td valign='top'>" +
           "            <table class='gcli-csp-src-detail' valign='top'>" +
           // >> >> iterate all srs
           "              <tr foreach='src in ${dir.dirSrc}' >" +
--- a/devtools/shared/moz.build
+++ b/devtools/shared/moz.build
@@ -34,16 +34,17 @@ MOCHITEST_CHROME_MANIFESTS += ['tests/mo
 XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']
 
 JAR_MANIFESTS += ['jar.mn']
 
 DevToolsModules(
     'async-storage.js',
     'async-utils.js',
     'content-observer.js',
+    'css-angle.js',
     'css-color.js',
     'deprecated-sync-thenables.js',
     'DevToolsUtils.js',
     'event-emitter.js',
     'event-parsers.js',
     'indentation.js',
     'Loader.jsm',
     'Parser.jsm',
new file mode 100644
--- /dev/null
+++ b/devtools/shared/tests/unit/test_cssAngle.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test classifyAngle.
+
+"use strict";
+
+const {angleUtils} = require("devtools/shared/css-angle");
+
+const CLASSIFY_TESTS = [
+  { input: "180deg", output: "deg" },
+  { input: "-180deg", output: "deg" },
+  { input: "180DEG", output: "deg" },
+  { input: "200rad", output: "rad" },
+  { input: "-200rad", output: "rad" },
+  { input: "200RAD", output: "rad" },
+  { input: "0.5grad", output: "grad" },
+  { input: "-0.5grad", output: "grad" },
+  { input: "0.5GRAD", output: "grad" },
+  { input: "0.33turn", output: "turn" },
+  { input: "0.33TURN", output: "turn" },
+  { input: "-0.33turn", output: "turn" }
+];
+
+function run_test() {
+  for (let test of CLASSIFY_TESTS) {
+    let result = angleUtils.classifyAngle(test.input);
+    equal(result, test.output, "test classifyAngle(" + test.input + ")");
+  }
+}
--- a/devtools/shared/tests/unit/xpcshell.ini
+++ b/devtools/shared/tests/unit/xpcshell.ini
@@ -14,14 +14,15 @@ support-files =
 [test_fetch-resource.js]
 [test_indentation.js]
 [test_independent_loaders.js]
 [test_invisible_loader.js]
 [test_safeErrorString.js]
 [test_defineLazyPrototypeGetter.js]
 [test_async-utils.js]
 [test_consoleID.js]
+[test_cssAngle.js]
 [test_cssColor.js]
 [test_prettifyCSS.js]
 [test_require_lazy.js]
 [test_require.js]
 [test_stack.js]
 [test_executeSoon.js]
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -353,16 +353,28 @@
             android:name="org.mozilla.gecko.dlc.DownloadContentService">
         </service>
 
         <service
             android:exported="false"
             android:name="org.mozilla.gecko.feeds.FeedService">
         </service>
 
+        <receiver
+            android:name="org.mozilla.gecko.feeds.FeedAlarmReceiver"
+            android:exported="false" />
+
+        <receiver
+            android:name="org.mozilla.gecko.BootReceiver"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="android.intent.action.BOOT_COMPLETED"></action>
+            </intent-filter>
+        </receiver>
+
         <service
           android:name="org.mozilla.gecko.telemetry.TelemetryUploadService"
           android:exported="false"/>
 
 #include ../services/manifests/FxAccountAndroidManifest_services.xml.in
 
         <service
             android:name="org.mozilla.gecko.tabqueue.TabReceivedService"
--- a/mobile/android/base/FennecManifest_permissions.xml.in
+++ b/mobile/android/base/FennecManifest_permissions.xml.in
@@ -24,16 +24,17 @@
 
     <uses-permission android:name="@ANDROID_PACKAGE_NAME@.permission.PER_ANDROID_PACKAGE" />
 
     <uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
     <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
     <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
     <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
     <!-- READ_EXTERNAL_STORAGE was added in API 16, and is only enforced in API
          19+.  We declare it so that the bouncer APK and the main APK have the
          same set of permissions. -->
     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
     <uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT"/>
     <uses-permission android:name="com.android.launcher.permission.UNINSTALL_SHORTCUT"/>
     <uses-permission android:name="com.android.browser.permission.READ_HISTORY_BOOKMARKS"/>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/BootReceiver.java
@@ -0,0 +1,27 @@
+/* -*- 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;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import org.mozilla.gecko.feeds.FeedService;
+
+/**
+ * This broadcast receiver receives ACTION_BOOT_COMPLETED broadcasts and starts components that should
+ * run after the device has booted.
+ */
+public class BootReceiver extends BroadcastReceiver {
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        if (intent == null || !intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) {
+            return; // This is not the broadcast you are looking for.
+        }
+
+        FeedService.setup(context);
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -1,16 +1,18 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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;
 
 import android.Manifest;
+import android.app.DownloadManager;
+import android.os.Environment;
 import android.support.annotation.NonNull;
 import org.json.JSONArray;
 import org.mozilla.gecko.adjust.AdjustHelperInterface;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.AppConstants.Versions;
 import org.mozilla.gecko.DynamicToolbar.VisibilityTransition;
 import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
 import org.mozilla.gecko.Tabs.TabEvents;
@@ -19,16 +21,18 @@ import org.mozilla.gecko.animation.ViewH
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.db.SuggestedSites;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.dlc.DownloadContentService;
 import org.mozilla.gecko.favicons.Favicons;
 import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
 import org.mozilla.gecko.favicons.decoders.IconDirectoryEntry;
+import org.mozilla.gecko.feeds.FeedService;
+import org.mozilla.gecko.feeds.action.CheckForUpdatesAction;
 import org.mozilla.gecko.firstrun.FirstrunAnimationContainer;
 import org.mozilla.gecko.gfx.DynamicToolbarAnimator;
 import org.mozilla.gecko.gfx.DynamicToolbarAnimator.PinReason;
 import org.mozilla.gecko.gfx.ImmutableViewportMetrics;
 import org.mozilla.gecko.gfx.LayerView;
 import org.mozilla.gecko.home.BrowserSearch;
 import org.mozilla.gecko.home.HomeBanner;
 import org.mozilla.gecko.home.HomeConfig;
@@ -191,16 +195,18 @@ public class BrowserApp extends GeckoApp
     private static final String STATE_ABOUT_HOME_TOP_PADDING = "abouthome_top_padding";
 
     private static final String BROWSER_SEARCH_TAG = "browser_search";
 
     // Request ID for startActivityForResult.
     private static final int ACTIVITY_REQUEST_PREFERENCES = 1001;
     private static final int ACTIVITY_REQUEST_TAB_QUEUE = 2001;
 
+    public static final String ACTION_VIEW_MULTIPLE = AppConstants.ANDROID_PACKAGE_NAME + ".action.VIEW_MULTIPLE";
+
     @RobocopTarget
     public static final String EXTRA_SKIP_STARTPANE = "skipstartpane";
     private static final String EOL_NOTIFIED = "eol_notified";
 
     private BrowserSearch mBrowserSearch;
     private View mBrowserSearchContainer;
 
     public ViewGroup mBrowserChrome;
@@ -676,16 +682,17 @@ public class BrowserApp extends GeckoApp
             "Menu:Update",
             "LightweightTheme:Update",
             "Search:Keyword",
             "Prompt:ShowTop");
 
         EventDispatcher.getInstance().registerGeckoThreadListener((NativeEventListener)this,
             "CharEncoding:Data",
             "CharEncoding:State",
+            "Download:AndroidDownloadManager",
             "Experiments:GetActive",
             "Favicon:CacheLoad",
             "Feedback:MaybeLater",
             "Menu:Add",
             "Menu:Remove",
             "Sanitize:ClearHistory",
             "Sanitize:ClearSyncedTabs",
             "Settings:Show",
@@ -1413,16 +1420,17 @@ public class BrowserApp extends GeckoApp
             "Menu:Update",
             "LightweightTheme:Update",
             "Search:Keyword",
             "Prompt:ShowTop");
 
         EventDispatcher.getInstance().unregisterGeckoThreadListener((NativeEventListener) this,
             "CharEncoding:Data",
             "CharEncoding:State",
+            "Download:AndroidDownloadManager",
             "Experiments:GetActive",
             "Favicon:CacheLoad",
             "Feedback:MaybeLater",
             "Menu:Add",
             "Menu:Remove",
             "Sanitize:ClearHistory",
             "Sanitize:ClearSyncedTabs",
             "Settings:Show",
@@ -1760,16 +1768,48 @@ public class BrowserApp extends GeckoApp
             if (Restrictions.isRestrictedProfile(this)) {
                 for (Restrictable rest : RestrictedProfileConfiguration.getVisibleRestrictions()) {
                     int value = Restrictions.isAllowed(this, rest) ? 1 : 0;
                     Telemetry.addToKeyedHistogram("FENNEC_RESTRICTED_PROFILE_RESTRICTIONS", rest.name(), value);
                 }
             }
         } else if ("Updater:Launch".equals(event)) {
             handleUpdaterLaunch();
+        } else if ("Download:AndroidDownloadManager".equals(event)) {
+            // Downloading via Android's download manager
+
+            final String uri = message.getString("uri");
+            final String filename = message.getString("filename");
+            final String mimeType = message.getString("mimeType");
+
+            final DownloadManager.Request request = new DownloadManager.Request(Uri.parse(uri));
+            request.setMimeType(mimeType);
+
+            try {
+                request.setDestinationInExternalFilesDir(this, Environment.DIRECTORY_DOWNLOADS, filename);
+            } catch (IllegalStateException e) {
+                Log.e(LOGTAG, "Cannot create download directory");
+                return;
+            }
+
+            request.allowScanningByMediaScanner();
+            request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
+            request.addRequestHeader("User-Agent", HardwareUtils.isTablet() ?
+                    AppConstants.USER_AGENT_FENNEC_TABLET :
+                    AppConstants.USER_AGENT_FENNEC_MOBILE);
+
+            try {
+                DownloadManager manager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
+                manager.enqueue(request);
+
+                Log.d(LOGTAG, "Enqueued download (Download Manager)");
+            } catch (RuntimeException e) {
+                Log.e(LOGTAG, "Download failed: " + e);
+            }
+
         } else {
             super.handleMessage(event, message, callback);
         }
     }
 
     private void getFaviconFromCache(final EventCallback callback, final String url) {
         final OnFaviconLoadedListener listener = new OnFaviconLoadedListener() {
             @Override
@@ -1901,16 +1941,18 @@ public class BrowserApp extends GeckoApp
                         }
                     }, oneSecondInMillis);
                 }
 
                 if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
                     DownloadContentService.startVerification(this);
                 }
 
+                FeedService.setup(this);
+
                 super.handleMessage(event, message);
             } else if (event.equals("Gecko:Ready")) {
                 // Handle this message in GeckoApp, but also enable the Settings
                 // menuitem, which is specific to BrowserApp.
                 super.handleMessage(event, message);
                 final Menu menu = mMenu;
                 ThreadUtils.postToUiThread(new Runnable() {
                     @Override
@@ -3676,16 +3718,17 @@ public class BrowserApp extends GeckoApp
      */
     @Override
     protected void onNewIntent(Intent intent) {
         String action = intent.getAction();
 
         final boolean isViewAction = Intent.ACTION_VIEW.equals(action);
         final boolean isBookmarkAction = GeckoApp.ACTION_HOMESCREEN_SHORTCUT.equals(action);
         final boolean isTabQueueAction = TabQueueHelper.LOAD_URLS_ACTION.equals(action);
+        final boolean isViewMultipleAction = ACTION_VIEW_MULTIPLE.equals(action);
 
         if (mInitialized && (isViewAction || isBookmarkAction)) {
             // Dismiss editing mode if the user is loading a URL from an external app.
             mBrowserToolbar.cancelEdit();
 
             // Hide firstrun-pane if the user is loading a URL from an external app.
             hideFirstrunPager(TelemetryContract.Method.NONE);
 
@@ -3716,16 +3759,29 @@ public class BrowserApp extends GeckoApp
             ThreadUtils.postToBackgroundThread(new Runnable() {
                 @Override
                 public void run() {
                     openQueuedTabs();
                 }
             });
         }
 
+        // Custom intent action for opening multiple URLs at once
+        if (isViewMultipleAction) {
+            List<String> urls = intent.getStringArrayListExtra("urls");
+            if (urls != null) {
+                openUrls(urls);
+            }
+
+            // Launched from a "content notification"
+            if (intent.hasExtra(CheckForUpdatesAction.EXTRA_CONTENT_NOTIFICATION)) {
+                Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, "content_update");
+            }
+        }
+
         if (!mInitialized || !Intent.ACTION_MAIN.equals(action)) {
             return;
         }
 
         // Check to see how many times the app has been launched.
         final String keyName = getPackageName() + ".feedback_launch_count";
         final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
 
@@ -3743,16 +3799,32 @@ public class BrowserApp extends GeckoApp
                     GeckoAppShell.notifyObservers("Feedback:Show", null);
                 }
             }
         } finally {
             StrictMode.setThreadPolicy(savedPolicy);
         }
     }
 
+    private void openUrls(List<String> urls) {
+        try {
+            JSONArray array = new JSONArray();
+            for (String url : urls) {
+                array.put(url);
+            }
+
+            JSONObject object = new JSONObject();
+            object.put("urls", array);
+
+            GeckoAppShell.notifyObservers("Tabs:OpenMultiple", object.toString());
+        } catch (JSONException e) {
+            Log.e(LOGTAG, "Unable to create JSON for opening multiple URLs");
+        }
+    }
+
     private void showTabQueuePromptIfApplicable(final Intent intent) {
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
                 // We only want to show the prompt if the browser has been opened from an external url
                 if (TabQueueHelper.TAB_QUEUE_ENABLED && mInitialized
                                                      && Intent.ACTION_VIEW.equals(intent.getAction())
                                                      && !intent.getBooleanExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, false)
--- a/mobile/android/base/java/org/mozilla/gecko/TelemetryContract.java
+++ b/mobile/android/base/java/org/mozilla/gecko/TelemetryContract.java
@@ -177,16 +177,19 @@ public interface TelemetryContract {
 
         // Action triggered from a pageaction in the URLBar.
         // Note: Only used in JavaScript for now, but here for completeness.
         PAGEACTION("pageaction"),
 
         // Action triggered from one of a series of views, such as ViewPager.
         PANEL("panel"),
 
+        // Action triggered by a background service / automatic system making a decision.
+        SERVICE("service"),
+
         // Action triggered from a settings screen.
         SETTINGS("settings"),
 
         // Actions triggered from the share overlay.
         SHARE_OVERLAY("shareoverlay"),
 
         // Action triggered from a suggestion provided to the user.
         SUGGESTION("suggestion"),
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
@@ -512,17 +512,33 @@ public class BrowserContract {
         public static final String URL = "url";
         public static final String KEY = "key";
         public static final String VALUE = "value";
         public static final String SYNC_STATUS = "sync_status";
 
         public enum Key {
             // We use a parameter, rather than name(), as defensive coding: we can't let the
             // enum name change because we've already stored values into the DB.
-            SCREENSHOT ("screenshot");
+            SCREENSHOT ("screenshot"),
+
+            /**
+             * This key maps URLs to its feeds.
+             *
+             * Key:   feed
+             * Value: URL of feed
+             */
+            FEED("feed"),
+
+            /**
+             * This key maps URLs of feeds to an object describing the feed.
+             *
+             * Key:   feed_subscription
+             * Value: JSON object describing feed
+             */
+            FEED_SUBSCRIPTION("feed_subscription");
 
             private final String dbValue;
 
             Key(final String dbValue) { this.dbValue = dbValue; }
             public String getDbValue() { return dbValue; }
         }
 
         public enum SyncStatus {
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
@@ -105,19 +105,21 @@ public interface BrowserDB {
     public abstract void clearHistory(ContentResolver cr, boolean clearSearchHistory);
 
 
     public abstract String getUrlForKeyword(ContentResolver cr, String keyword);
 
     public abstract boolean isBookmark(ContentResolver cr, String uri);
     public abstract boolean addBookmark(ContentResolver cr, String title, String uri);
     public abstract Cursor getBookmarkForUrl(ContentResolver cr, String url);
+    public abstract Cursor getBookmarksForPartialUrl(ContentResolver cr, String partialUrl);
     public abstract void removeBookmarksWithURL(ContentResolver cr, String uri);
     public abstract void registerBookmarkObserver(ContentResolver cr, ContentObserver observer);
     public abstract void updateBookmark(ContentResolver cr, int id, String uri, String title, String keyword);
+    public abstract boolean hasBookmarkWithGuid(ContentResolver cr, String guid);
 
     /**
      * Can return <code>null</code>.
      */
     public abstract Cursor getBookmarksInFolder(ContentResolver cr, long folderId);
 
     /**
      * Get the favicon from the database, if any, associated with the given favicon URL. (That is,
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
@@ -465,16 +465,21 @@ public class BrowserProvider extends Sha
                 // fall through
             case THUMBNAILS: {
                 trace("Deleting thumbnails: " + uri);
                 beginWrite(db);
                 deleted = deleteThumbnails(uri, selection, selectionArgs);
                 break;
             }
 
+            case URL_ANNOTATIONS:
+                trace("Delete on URL_ANNOTATIONS: " + uri);
+                deleteUrlAnnotation(uri, selection, selectionArgs);
+                break;
+
             default: {
                 Table table = findTableFor(match);
                 if (table == null) {
                     throw new UnsupportedOperationException("Unknown delete URI " + uri);
                 }
                 trace("Deleting TABLE: " + uri);
                 beginWrite(db);
                 deleted = table.delete(db, uri, match, selection, selectionArgs);
@@ -647,16 +652,20 @@ public class BrowserProvider extends Sha
                                                       new String[] { url });
                 } else {
                     updated = updateExistingThumbnail(uri, values, Thumbnails.URL + " = ?",
                                                       new String[] { url });
                 }
                 break;
             }
 
+            case URL_ANNOTATIONS:
+                updateUrlAnnotation(uri, values, selection, selectionArgs);
+                break;
+
             default: {
                 Table table = findTableFor(match);
                 if (table == null) {
                     throw new UnsupportedOperationException("Unknown update URI " + uri);
                 }
                 trace("Update TABLE: " + uri);
 
                 beginWrite(db);
@@ -1272,17 +1281,17 @@ public class BrowserProvider extends Sha
             values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis());
         }
 
         trace("Querying bookmarks to update on URI: " + uri);
         final SQLiteDatabase db = getWritableDatabase(uri);
 
         // Compute matching IDs.
         final Cursor cursor = db.query(TABLE_BOOKMARKS, bookmarksProjection,
-                                       selection, selectionArgs, null, null, null);
+                selection, selectionArgs, null, null, null);
 
         // Now that we're done reading, open a transaction.
         final String inClause;
         try {
             inClause = DBUtils.computeSQLInClauseFromLongs(cursor, Bookmarks._ID);
         } finally {
             cursor.close();
         }
@@ -1515,16 +1524,30 @@ public class BrowserProvider extends Sha
         final String url = values.getAsString(UrlAnnotations.URL);
         trace("Inserting url annotations for URL: " + url);
 
         final SQLiteDatabase db = getWritableDatabase(uri);
         beginWrite(db);
         return db.insertOrThrow(TABLE_URL_ANNOTATIONS, null, values);
     }
 
+    private void deleteUrlAnnotation(final Uri uri, final String selection, final String[] selectionArgs) {
+        trace("Deleting url annotation for URI: " + uri);
+
+        final SQLiteDatabase db = getWritableDatabase(uri);
+        db.delete(TABLE_URL_ANNOTATIONS, selection, selectionArgs);
+    }
+
+    private void updateUrlAnnotation(final Uri uri, final ContentValues values, final String selection, final String[] selectionArgs) {
+        trace("Updating url annotation for URI: " + uri);
+
+        final SQLiteDatabase db = getWritableDatabase(uri);
+        db.update(TABLE_URL_ANNOTATIONS, values, selection, selectionArgs);
+    }
+
     private int updateOrInsertThumbnail(Uri uri, ContentValues values, String selection,
             String[] selectionArgs) {
         return updateThumbnail(uri, values, selection, selectionArgs,
                 true /* insert if needed */);
     }
 
     private int updateExistingThumbnail(Uri uri, ContentValues values, String selection,
             String[] selectionArgs) {
--- a/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
@@ -1096,16 +1096,33 @@ public class LocalBrowserDB implements B
         values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis());
 
         cr.update(mBookmarksUriWithProfile,
                   values,
                   Bookmarks._ID + " = ?",
                   new String[] { String.valueOf(id) });
     }
 
+    @Override
+    public boolean hasBookmarkWithGuid(ContentResolver cr, String guid) {
+        Cursor c = cr.query(bookmarksUriWithLimit(1),
+                new String[] { Bookmarks.GUID },
+                Bookmarks.GUID + " = ?",
+                new String[] { guid },
+                null);
+
+        try {
+            return c != null && c.getCount() > 0;
+        } finally {
+            if (c != null) {
+                c.close();
+            }
+        }
+    }
+
     /**
      * Get the favicon from the database, if any, associated with the given favicon URL. (That is,
      * the URL of the actual favicon image, not the URL of the page with which the favicon is associated.)
      * @param cr The ContentResolver to use.
      * @param faviconURL The URL of the favicon to fetch from the database.
      * @return The decoded Bitmap from the database, if any. null if none is stored.
      */
     @Override
@@ -1577,16 +1594,32 @@ public class LocalBrowserDB implements B
             c.close();
             c = null;
         }
 
         return c;
     }
 
     @Override
+    public Cursor getBookmarksForPartialUrl(ContentResolver cr, String partialUrl) {
+        Cursor c = cr.query(mBookmarksUriWithProfile,
+                new String[] { Bookmarks.GUID, Bookmarks._ID, Bookmarks.URL },
+                Bookmarks.URL + " LIKE '%" + partialUrl + "%'", // TODO: Escaping!
+                null,
+                null);
+
+        if (c != null && c.getCount() == 0) {
+            c.close();
+            c = null;
+        }
+
+        return c;
+    }
+
+    @Override
     public void setSuggestedSites(SuggestedSites suggestedSites) {
         mSuggestedSites = suggestedSites;
     }
 
     @Override
     public SuggestedSites getSuggestedSites() {
         return mSuggestedSites;
     }
--- a/mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java
@@ -5,39 +5,187 @@
 package org.mozilla.gecko.db;
 
 import android.content.ContentResolver;
 import android.content.ContentValues;
 import android.database.Cursor;
 import android.net.Uri;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
+import android.util.Log;
+
+import org.json.JSONException;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.db.BrowserContract.UrlAnnotations.Key;
+import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
 
 public class LocalUrlAnnotations implements UrlAnnotations {
+    private static final String LOGTAG = "LocalUrlAnnotations";
+
     private Uri urlAnnotationsTableWithProfile;
 
     public LocalUrlAnnotations(final String profile) {
         urlAnnotationsTableWithProfile = DBUtils.appendProfile(profile, BrowserContract.UrlAnnotations.CONTENT_URI);
     }
 
+    /**
+     * Get all feed subscriptions.
+     */
+    @Override
+    public Cursor getFeedSubscriptions(ContentResolver cr) {
+        return queryByKey(cr,
+                Key.FEED_SUBSCRIPTION,
+                new String[] { BrowserContract.UrlAnnotations.URL, BrowserContract.UrlAnnotations.VALUE },
+                null);
+    }
+
+    /**
+     * Insert mapping from website URL to URL of the feed.
+     */
+    @Override
+    public void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl) {
+        insertAnnotation(cr, originUrl, Key.FEED, feedUrl);
+    }
+
+    /**
+     * Returns true if there's a mapping from the given website URL to a feed URL. False otherwise.
+     */
+    @Override
+    public boolean hasFeedUrlForWebsite(ContentResolver cr, String websiteUrl) {
+        return hasResultsForSelection(cr,
+                BrowserContract.UrlAnnotations.URL + " = ? AND " + BrowserContract.UrlAnnotations.KEY + " = ?",
+                new String[]{websiteUrl, Key.FEED.getDbValue()});
+    }
+
+    /**
+     * Returns true if there's a website URL with this feed URL. False otherwise.
+     */
+    @Override
+    public boolean hasWebsiteForFeedUrl(ContentResolver cr, String feedUrl) {
+        return hasResultsForSelection(cr,
+                BrowserContract.UrlAnnotations.VALUE + " = ? AND " + BrowserContract.UrlAnnotations.KEY + " = ?",
+                new String[]{feedUrl, Key.FEED.getDbValue()});
+    }
+
+    /**
+     * Delete the feed URL mapping for this website URL.
+     */
+    @Override
+    public void deleteFeedUrl(ContentResolver cr, String websiteUrl) {
+        deleteAnnotation(cr, websiteUrl, Key.FEED);
+    }
+
+    /**
+     * Get website URLs that are mapped to the given feed URL.
+     */
+    @Override
+    public Cursor getWebsitesWithFeedUrl(ContentResolver cr) {
+        return cr.query(urlAnnotationsTableWithProfile,
+                new String[] { BrowserContract.UrlAnnotations.URL },
+                BrowserContract.UrlAnnotations.KEY + " = ?",
+                new String[] { Key.FEED.getDbValue() },
+                null);
+    }
+
+    /**
+     * Returns true if there's a subscription for this feed URL. False otherwise.
+     */
+    @Override
+    public boolean hasFeedSubscription(ContentResolver cr, String feedUrl) {
+        return hasResultsForSelection(cr,
+                BrowserContract.UrlAnnotations.URL + " = ? AND " + BrowserContract.UrlAnnotations.KEY + " = ?",
+                new String[]{feedUrl, Key.FEED_SUBSCRIPTION.getDbValue()});
+    }
+
+    /**
+     * Insert the given feed subscription (Mapping from feed URL to the subscription object).
+     */
+    @Override
+    public void insertFeedSubscription(ContentResolver cr, FeedSubscription subscription) {
+        try {
+            insertAnnotation(cr, subscription.getFeedUrl(), Key.FEED_SUBSCRIPTION, subscription.toJSON().toString());
+        } catch (JSONException e) {
+            Log.w(LOGTAG, "Could not serialize subscription");
+        }
+    }
+
+    /**
+     * Update the feed subscription with new values.
+     */
+    @Override
+    public void updateFeedSubscription(ContentResolver cr, FeedSubscription subscription) {
+        try {
+            updateAnnotation(cr, subscription.getFeedUrl(), Key.FEED_SUBSCRIPTION, subscription.toJSON().toString());
+        } catch (JSONException e) {
+            Log.w(LOGTAG, "Could not serialize subscription");
+        }
+    }
+
+    /**
+     * Delete the subscription for the feed URL.
+     */
+    @Override
+    public void deleteFeedSubscription(ContentResolver cr, FeedSubscription subscription) {
+        deleteAnnotation(cr, subscription.getFeedUrl(), Key.FEED_SUBSCRIPTION);
+    }
+
+    private int deleteAnnotation(final ContentResolver cr, final String url, final Key key) {
+        return cr.delete(urlAnnotationsTableWithProfile,
+                BrowserContract.UrlAnnotations.KEY + " = ? AND " + BrowserContract.UrlAnnotations.URL + " = ?",
+                new String[] { key.getDbValue(), url  });
+    }
+
+    private int updateAnnotation(final ContentResolver cr, final String url, final Key key, final String value) {
+        ContentValues values = new ContentValues();
+        values.put(BrowserContract.UrlAnnotations.VALUE, value);
+        values.put(BrowserContract.UrlAnnotations.DATE_MODIFIED, System.currentTimeMillis());
+
+        return cr.update(urlAnnotationsTableWithProfile,
+                values,
+                BrowserContract.UrlAnnotations.KEY + " = ? AND " + BrowserContract.UrlAnnotations.URL + " = ?",
+                new String[]{key.getDbValue(), url});
+    }
+
+    private void insertAnnotation(final ContentResolver cr, final String url, final Key key, final String value) {
+        insertAnnotation(cr, url, key.getDbValue(), value);
+    }
+
     @RobocopTarget
     @Override
     public void insertAnnotation(final ContentResolver cr, final String url, final String key, final String value) {
         final long creationTime = System.currentTimeMillis();
         final ContentValues values = new ContentValues(5);
         values.put(BrowserContract.UrlAnnotations.URL, url);
         values.put(BrowserContract.UrlAnnotations.KEY, key);
         values.put(BrowserContract.UrlAnnotations.VALUE, value);
         values.put(BrowserContract.UrlAnnotations.DATE_CREATED, creationTime);
         values.put(BrowserContract.UrlAnnotations.DATE_MODIFIED, creationTime);
         cr.insert(urlAnnotationsTableWithProfile, values);
     }
 
+    /**
+     * @return true if the table contains rows for the given selection.
+     */
+    private boolean hasResultsForSelection(ContentResolver cr, String selection, String[] selectionArgs) {
+        Cursor cursor = cr.query(urlAnnotationsTableWithProfile,
+                new String[] { BrowserContract.UrlAnnotations._ID },
+                selection,
+                selectionArgs,
+                null);
+        if (cursor == null) {
+            return false;
+        }
+
+        try {
+            return cursor.getCount() > 0;
+        } finally {
+            cursor.close();
+        }
+    }
+
     private Cursor queryByKey(final ContentResolver cr, @NonNull final Key key, @Nullable final String[] projections,
                 @Nullable final String sortOrder) {
         return cr.query(urlAnnotationsTableWithProfile,
                 projections,
                 BrowserContract.UrlAnnotations.KEY + " = ?", new String[] { key.getDbValue() },
                 sortOrder);
     }
 
--- a/mobile/android/base/java/org/mozilla/gecko/db/StubBrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/StubBrowserDB.java
@@ -12,16 +12,17 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
 import org.json.JSONObject;
 import org.mozilla.gecko.annotation.RobocopTarget;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.favicons.decoders.LoadFaviconResult;
 import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
 
 import android.content.ContentProviderOperation;
 import android.content.ContentResolver;
 import android.content.ContentValues;
 import android.content.Context;
 import android.database.ContentObserver;
 import android.database.Cursor;
 import android.graphics.drawable.BitmapDrawable;
@@ -154,16 +155,46 @@ class StubUrlAnnotations implements UrlA
     @Override
     public void insertAnnotation(ContentResolver cr, String url, String key, String value) {}
 
     @Override
     public Cursor getScreenshots(ContentResolver cr) { return null; }
 
     @Override
     public void insertScreenshot(ContentResolver cr, String pageUrl, final String screenshotLocation) {}
+
+    @Override
+    public Cursor getFeedSubscriptions(ContentResolver cr) { return null; }
+
+    @Override
+    public Cursor getWebsitesWithFeedUrl(ContentResolver cr) { return null; }
+
+    @Override
+    public void deleteFeedUrl(ContentResolver cr, String websiteUrl) {}
+
+    @Override
+    public boolean hasWebsiteForFeedUrl(ContentResolver cr, String feedUrl) { return false; }
+
+    @Override
+    public void deleteFeedSubscription(ContentResolver cr, FeedSubscription subscription) {}
+
+    @Override
+    public void updateFeedSubscription(ContentResolver cr, FeedSubscription subscription) {}
+
+    @Override
+    public boolean hasFeedSubscription(ContentResolver cr, String feedUrl) { return false; }
+
+    @Override
+    public void insertFeedSubscription(ContentResolver cr, FeedSubscription subscription) {}
+
+    @Override
+    public boolean hasFeedUrlForWebsite(ContentResolver cr, String websiteUrl) { return false; }
+
+    @Override
+    public void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl) {}
 }
 
 /*
  * This base implementation just stubs all methods. For the
  * real implementations, see LocalBrowserDB.java.
  */
 public class StubBrowserDB implements BrowserDB {
     private final StubSearches searches = new StubSearches();
@@ -369,16 +400,26 @@ public class StubBrowserDB implements Br
     public void unpinSite(ContentResolver cr, int position) {
     }
 
     @RobocopTarget
     public Cursor getBookmarkForUrl(ContentResolver cr, String url) {
         return null;
     }
 
+    @Override
+    public Cursor getBookmarksForPartialUrl(ContentResolver cr, String partialUrl) {
+        return null;
+    }
+
+    @Override
+    public boolean hasBookmarkWithGuid(ContentResolver cr, String guid) {
+        return false;
+    }
+
     public void setSuggestedSites(SuggestedSites suggestedSites) {
         this.suggestedSites = suggestedSites;
     }
 
     public SuggestedSites getSuggestedSites() {
         return suggestedSites;
     }
 
--- a/mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java
@@ -2,15 +2,27 @@
  * 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.db;
 
 import android.content.ContentResolver;
 import android.database.Cursor;
 import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
 
 public interface UrlAnnotations {
     @RobocopTarget void insertAnnotation(ContentResolver cr, String url, String key, String value);
 
     Cursor getScreenshots(ContentResolver cr);
     void insertScreenshot(ContentResolver cr, String pageUrl, String screenshotPath);
+
+    Cursor getFeedSubscriptions(ContentResolver cr);
+    Cursor getWebsitesWithFeedUrl(ContentResolver cr);
+    void deleteFeedUrl(ContentResolver cr, String websiteUrl);
+    boolean hasWebsiteForFeedUrl(ContentResolver cr, String feedUrl);
+    void deleteFeedSubscription(ContentResolver cr, FeedSubscription subscription);
+    void updateFeedSubscription(ContentResolver cr, FeedSubscription subscription);
+    boolean hasFeedSubscription(ContentResolver cr, String feedUrl);
+    void insertFeedSubscription(ContentResolver cr, FeedSubscription subscription);
+    boolean hasFeedUrlForWebsite(ContentResolver cr, String websiteUrl);
+    void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl);
 }
--- a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java
@@ -1,17 +1,17 @@
 /* -*- 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.content.Context;
-import android.util.AtomicFile;
+import android.support.v4.util.AtomicFile;
 import android.util.Log;
 
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 
 import java.io.File;
 import java.io.FileNotFoundException;
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedAlarmReceiver.java
@@ -0,0 +1,31 @@
+/* -*- 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.feeds;
+
+import android.content.Context;
+import android.content.Intent;
+import android.support.v4.content.WakefulBroadcastReceiver;
+import android.util.Log;
+
+/**
+ * Broadcast receiver that will receive broadcasts from the AlarmManager and start the FeedService
+ * with the given action.
+ */
+public class FeedAlarmReceiver extends WakefulBroadcastReceiver {
+    private static final String LOGTAG = "FeedCheckAction";
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        final String action = intent.getAction();
+
+        Log.d(LOGTAG, "Received alarm with action: " + action);
+
+        final Intent serviceIntent = new Intent(context, FeedService.class);
+        serviceIntent.setAction(action);
+
+        startWakefulService(context, serviceIntent);
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/feeds/FeedService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedService.java
@@ -1,68 +1,148 @@
 /* -*- 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.feeds;
 
 import android.app.IntentService;
+import android.content.Context;
 import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.support.annotation.Nullable;
+import android.support.v4.net.ConnectivityManagerCompat;
 import android.util.Log;
 
 import com.keepsafe.switchboard.SwitchBoard;
 
 import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.feeds.action.CheckAction;
-import org.mozilla.gecko.feeds.action.SubscribeAction;
-import org.mozilla.gecko.feeds.subscriptions.SubscriptionStorage;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.feeds.action.FeedAction;
+import org.mozilla.gecko.feeds.action.CheckForUpdatesAction;
+import org.mozilla.gecko.feeds.action.EnrollSubscriptionsAction;
+import org.mozilla.gecko.feeds.action.SetupAlarmsAction;
+import org.mozilla.gecko.feeds.action.SubscribeToFeedAction;
+import org.mozilla.gecko.feeds.action.WithdrawSubscriptionsAction;
+import org.mozilla.gecko.preferences.GeckoPreferences;
 import org.mozilla.gecko.util.Experiments;
 
 /**
  * Background service for subscribing to and checking website feeds to notify the user about updates.
  */
 public class FeedService extends IntentService {
     private static final String LOGTAG = "GeckoFeedService";
 
+    public static final String ACTION_SETUP = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.SETUP";
     public static final String ACTION_SUBSCRIBE = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.SUBSCRIBE";
     public static final String ACTION_CHECK = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.CHECK";
+    public static final String ACTION_ENROLL = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.ENROLL";
+    public static final String ACTION_WITHDRAW = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.WITHDRAW";
 
-    private SubscriptionStorage storage;
+    public static void setup(Context context) {
+        Intent intent = new Intent(context, FeedService.class);
+        intent.setAction(ACTION_SETUP);
+        context.startService(intent);
+    }
+
+    public static void subscribe(Context context, String feedUrl) {
+        Intent intent = new Intent(context, FeedService.class);
+        intent.setAction(ACTION_SUBSCRIBE);
+        intent.putExtra(SubscribeToFeedAction.EXTRA_FEED_URL, feedUrl);
+        context.startService(intent);
+    }
 
     public FeedService() {
         super(LOGTAG);
     }
 
+    private BrowserDB browserDB;
+
     @Override
     public void onCreate() {
         super.onCreate();
 
-        storage = new SubscriptionStorage(getApplicationContext());
+        browserDB = GeckoProfile.get(this).getDB();
     }
 
     @Override
     protected void onHandleIntent(Intent intent) {
-        if (intent == null) {
-            return;
+        try {
+            if (intent == null) {
+                return;
+            }
+
+            Log.d(LOGTAG, "Service started with action: " + intent.getAction());
+
+            if (!SwitchBoard.isInExperiment(this, Experiments.CONTENT_NOTIFICATIONS)) {
+                Log.d(LOGTAG, "Not in content notifications experiment. Skipping.");
+                return;
+            }
+
+            FeedAction action = createActionForIntent(intent);
+            if (action == null) {
+                Log.d(LOGTAG, "No action to process");
+                return;
+            }
+
+            if (action.requiresPreferenceEnabled() && !isPreferenceEnabled()) {
+                Log.d(LOGTAG, "Preference is disabled. Skipping.");
+                return;
+            }
+
+            if (action.requiresNetwork() && !isConnectedToUnmeteredNetwork()) {
+                // For now just skip if we are not connected or the network is metered. We do not want
+                // to use precious mobile traffic.
+                Log.d(LOGTAG, "Not connected to a network or network is metered. Skipping.");
+                return;
+            }
+
+            action.perform(browserDB, intent);
+        } finally {
+            FeedAlarmReceiver.completeWakefulIntent(intent);
         }
 
-        if (!SwitchBoard.isInExperiment(this, Experiments.CONTENT_NOTIFICATIONS)) {
-            Log.d(LOGTAG, "Not in content notifications experiment. Skipping.");
-            return;
-        }
+        Log.d(LOGTAG, "Done.");
+    }
+
+    @Nullable
+    private FeedAction createActionForIntent(Intent intent) {
+        final Context context = getApplicationContext();
 
         switch (intent.getAction()) {
+            case ACTION_SETUP:
+                return new SetupAlarmsAction(context);
+
             case ACTION_SUBSCRIBE:
-                new SubscribeAction(storage).perform(intent);
-                break;
+                return new SubscribeToFeedAction(context);
 
             case ACTION_CHECK:
-                new CheckAction(this, storage).perform();
-                break;
+                return new CheckForUpdatesAction(context);
+
+            case ACTION_ENROLL:
+                return new EnrollSubscriptionsAction(context);
+
+            case ACTION_WITHDRAW:
+                return new WithdrawSubscriptionsAction(context);
 
             default:
-                Log.e(LOGTAG, "Unknown action: " + intent.getAction());
+                throw new AssertionError("Unknown action: " + intent.getAction());
+        }
+    }
+
+    private boolean isConnectedToUnmeteredNetwork() {
+        ConnectivityManager manager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
+        NetworkInfo networkInfo = manager.getActiveNetworkInfo();
+        if (networkInfo == null || !networkInfo.isConnected()) {
+            return false;
         }
 
-        storage.persistChanges();
+        return !ConnectivityManagerCompat.isActiveNetworkMetered(manager);
+    }
+
+    private boolean isPreferenceEnabled() {
+        return GeckoSharedPrefs.forApp(this).getBoolean(GeckoPreferences.PREFS_NOTIFICATIONS_CONTENT, true);
     }
 }
rename from mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckAction.java
rename to mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckForUpdatesAction.java
--- a/mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckAction.java
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckForUpdatesAction.java
@@ -3,95 +3,218 @@
  * 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.feeds.action;
 
 import android.app.Notification;
 import android.app.PendingIntent;
 import android.content.ComponentName;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
+import android.database.Cursor;
 import android.net.Uri;
 import android.support.v4.app.NotificationCompat;
 import android.support.v4.app.NotificationManagerCompat;
 import android.support.v4.content.ContextCompat;
-import android.util.Log;
+import android.text.format.DateFormat;
 
+import org.json.JSONException;
+import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.GeckoApp;
 import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
 import org.mozilla.gecko.feeds.FeedFetcher;
 import org.mozilla.gecko.feeds.parser.Feed;
 import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
-import org.mozilla.gecko.feeds.subscriptions.SubscriptionStorage;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.StringUtils;
 
+import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 
 /**
- * CheckAction: Check if feeds we subscribed to have new content available.
+ * CheckForUpdatesAction: Check if feeds we subscribed to have new content available.
  */
-public class CheckAction {
+public class CheckForUpdatesAction extends FeedAction {
+    /**
+     * This extra will be added to Intents fired by the notification.
+     */
+    public static final String EXTRA_CONTENT_NOTIFICATION = "content-notification";
+
     private static final String LOGTAG = "FeedCheckAction";
 
     private Context context;
-    private SubscriptionStorage storage;
+
+    public CheckForUpdatesAction(Context context) {
+        this.context = context;
+    }
+
+    @Override
+    public void perform(BrowserDB browserDB, Intent intent) {
+        final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations();
+        final ContentResolver resolver = context.getContentResolver();
+        final List<Feed> updatedFeeds = new ArrayList<>();
+
+        log("Checking feeds for updates..");
+
+        Cursor cursor = urlAnnotations.getFeedSubscriptions(resolver);
+        if (cursor == null) {
+            return;
+        }
 
-    public CheckAction(Context context, SubscriptionStorage storage) {
-        this.context = context;
-        this.storage = storage;
+        try {
+            while (cursor.moveToNext()) {
+                FeedSubscription subscription = FeedSubscription.fromCursor(cursor);
+
+                FeedFetcher.FeedResponse response = checkFeedForUpdates(subscription);
+                if (response != null) {
+                    updatedFeeds.add(response.feed);
+
+                    urlAnnotations.updateFeedSubscription(resolver, subscription);
+                }
+            }
+        } catch (JSONException e) {
+            log("Could not deserialize subscription", e);
+        } finally {
+            cursor.close();
+        }
+
+        showNotification(updatedFeeds);
     }
 
-    public void perform() {
-        final List<FeedSubscription> subscriptions = storage.getSubscriptions();
+    private FeedFetcher.FeedResponse checkFeedForUpdates(FeedSubscription subscription) {
+        log("Checking feed: " + subscription.getFeedTitle());
 
-        Log.d(LOGTAG, "Checking feeds for updates (" + subscriptions.size() + " feeds) ..");
-
-        for (FeedSubscription subscription : subscriptions) {
-            Log.i(LOGTAG, "Checking feed: " + subscription.getFeedTitle());
+        FeedFetcher.FeedResponse response = fetchFeed(subscription);
+        if (response == null) {
+            return null;
+        }
 
-            FeedFetcher.FeedResponse response = fetchFeed(subscription);
-            if (response == null) {
-                continue;
-            }
+        if (subscription.hasBeenUpdated(response)) {
+            log("* Feed has changed. New item: " + response.feed.getLastItem().getTitle());
+
+            subscription.update(response);
 
-            if (subscription.isNewer(response)) {
-                Log.d(LOGTAG, "* Feed has changed. New item: " + response.feed.getLastItem().getTitle());
+            return response;
 
-                storage.updateSubscription(subscription, response);
+        }
 
-                notify(response.feed);
-            }
-        }
+        return null;
     }
 
-    private void notify(Feed feed) {
+    private void showNotification(List<Feed> updatedFeeds) {
+        final int feedCount = updatedFeeds.size();
+        if (feedCount == 0) {
+            return;
+        }
+
+        if (feedCount == 1) {
+            showNotificationForSingleUpdate(updatedFeeds.get(0));
+        } else {
+            showNotificationForMultipleUpdates(updatedFeeds);
+        }
+
+        Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.NOTIFICATION, "content_update");
+    }
+
+    private void showNotificationForSingleUpdate(Feed feed) {
+        final String date = DateFormat.getMediumDateFormat(context).format(new Date(feed.getLastItem().getTimestamp()));
+
         NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle()
                 .bigText(feed.getLastItem().getTitle())
                 .setBigContentTitle(feed.getTitle())
-                .setSummaryText(feed.getLastItem().getURL());
+                .setSummaryText(context.getString(R.string.content_notification_updated_on, date));
 
         Intent intent = new Intent(Intent.ACTION_VIEW);
         intent.setComponent(new ComponentName(context, BrowserApp.class));
         intent.setData(Uri.parse(feed.getLastItem().getURL()));
+        intent.putExtra(EXTRA_CONTENT_NOTIFICATION, true);
 
         PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0);
 
         Notification notification = new NotificationCompat.Builder(context)
                 .setSmallIcon(R.drawable.ic_status_logo)
                 .setContentTitle(feed.getTitle())
                 .setContentText(feed.getLastItem().getTitle())
                 .setStyle(style)
-                .setColor(ContextCompat.getColor(context, R.color.link_blue))
+                .setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange))
                 .setContentIntent(pendingIntent)
                 .setAutoCancel(true)
+                .addAction(createNotificationSettingsAction())
                 .build();
 
         NotificationManagerCompat.from(context).notify(R.id.websiteContentNotification, notification);
     }
 
+    private void showNotificationForMultipleUpdates(List<Feed> feeds) {
+        final ArrayList<String> urls = new ArrayList<>();
+
+        final NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
+        for (Feed feed : feeds) {
+            final String url = feed.getLastItem().getURL();
+
+            inboxStyle.addLine(StringUtils.stripScheme(url, StringUtils.UrlFlags.STRIP_HTTPS));
+            urls.add(url);
+        }
+        inboxStyle.setSummaryText(context.getString(R.string.content_notification_summary));
+
+        Intent intent = new Intent(context, BrowserApp.class);
+        intent.setAction(BrowserApp.ACTION_VIEW_MULTIPLE);
+        intent.putStringArrayListExtra("urls", urls);
+	    intent.putExtra(EXTRA_CONTENT_NOTIFICATION, true);
+
+        PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0);
+
+        Notification notification = new NotificationCompat.Builder(context)
+                .setSmallIcon(R.drawable.ic_status_logo)
+                .setContentTitle(context.getString(R.string.content_notification_title_plural, feeds.size()))
+                .setContentText(context.getString(R.string.content_notification_summary))
+                .setStyle(inboxStyle)
+                .setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange))
+                .setContentIntent(pendingIntent)
+                .setAutoCancel(true)
+                .setNumber(feeds.size())
+                .addAction(createNotificationSettingsAction())
+                .build();
+
+        NotificationManagerCompat.from(context).notify(R.id.websiteContentNotification, notification);
+    }
+
+    private NotificationCompat.Action createNotificationSettingsAction() {
+        final Intent intent = new Intent(GeckoApp.ACTION_LAUNCH_SETTINGS);
+        intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+        intent.putExtra(EXTRA_CONTENT_NOTIFICATION, true);
+
+        GeckoPreferences.setResourceToOpen(intent, "preferences_notifications");
+
+        PendingIntent settingsIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+        return new NotificationCompat.Action(
+                R.drawable.firefox_settings_alert,
+                context.getString(R.string.content_notification_action_settings),
+                settingsIntent);
+    }
+
     private FeedFetcher.FeedResponse fetchFeed(FeedSubscription subscription) {
         return FeedFetcher.fetchAndParseFeedIfModified(
                 subscription.getFeedUrl(),
                 subscription.getETag(),
                 subscription.getLastModified()
         );
     }
+
+    @Override
+    public boolean requiresNetwork() {
+        return true;
+    }
+
+    @Override
+    public boolean requiresPreferenceEnabled() {
+        return true;
+    }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/EnrollSubscriptionsAction.java
@@ -0,0 +1,99 @@
+/* -*- 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.feeds.action;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.text.TextUtils;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.feeds.FeedService;
+import org.mozilla.gecko.feeds.knownsites.KnownSiteBlogger;
+import org.mozilla.gecko.feeds.knownsites.KnownSite;
+import org.mozilla.gecko.feeds.knownsites.KnownSiteMedium;
+import org.mozilla.gecko.feeds.knownsites.KnownSiteWordpress;
+
+/**
+ * EnrollSubscriptionsAction: Search for bookmarks of known sites we can subscribe to.
+ */
+public class EnrollSubscriptionsAction extends FeedAction {
+    private static final String LOGTAG = "FeedEnrollAction";
+
+    private static final KnownSite[] knownSites = {
+        new KnownSiteMedium(),
+        new KnownSiteBlogger(),
+        new KnownSiteWordpress(),
+    };
+
+    private Context context;
+
+    public EnrollSubscriptionsAction(Context context) {
+        this.context = context;
+    }
+
+    @Override
+    public void perform(BrowserDB db, Intent intent) {
+        log("Searching for bookmarks to enroll in updates");
+
+        final ContentResolver contentResolver = context.getContentResolver();
+
+        for (KnownSite knownSite : knownSites) {
+            searchFor(db, contentResolver, knownSite);
+        }
+    }
+
+    @Override
+    public boolean requiresNetwork() {
+        return false;
+    }
+
+    @Override
+    public boolean requiresPreferenceEnabled() {
+        return true;
+    }
+
+    private void searchFor(BrowserDB db, ContentResolver contentResolver, KnownSite knownSite) {
+        final UrlAnnotations urlAnnotations = db.getUrlAnnotations();
+
+        final Cursor cursor = db.getBookmarksForPartialUrl(contentResolver, knownSite.getURLSearchString());
+        if (cursor == null) {
+            log("Nothing found (" + knownSite.getClass().getSimpleName() + ")");
+            return;
+        }
+
+        try {
+            log("Found " + cursor.getCount() + " websites");
+
+            while (cursor.moveToNext()) {
+
+                final String url = cursor.getString(cursor.getColumnIndex(BrowserContract.Bookmarks.URL));
+
+                log(" URL: " + url);
+
+                String feedUrl = knownSite.getFeedFromURL(url);
+                if (TextUtils.isEmpty(feedUrl)) {
+                    log("Could not determine feed for URL: " + url);
+                    return;
+                }
+
+                if (!urlAnnotations.hasFeedUrlForWebsite(contentResolver, url)) {
+                    urlAnnotations.insertFeedUrl(contentResolver, url, feedUrl);
+                }
+
+                if (!urlAnnotations.hasFeedSubscription(contentResolver, feedUrl)) {
+                    FeedService.subscribe(context, feedUrl);
+                }
+            }
+        } finally {
+            cursor.close();
+        }
+    }
+
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/FeedAction.java
@@ -0,0 +1,58 @@
+/* -*- 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.feeds.action;
+
+import android.content.Intent;
+import android.util.Log;
+
+import org.mozilla.gecko.db.BrowserDB;
+
+/**
+ * Interface for actions run by FeedService.
+ */
+public abstract class FeedAction {
+    public static final boolean DEBUG_LOG = false;
+
+    /**
+     * Perform this action.
+     *
+     * @param browserDB database instance to perform the action.
+     * @param intent used to start the service.
+     */
+    public abstract void perform(BrowserDB browserDB, Intent intent);
+
+    /**
+     * Does this action require an active network connection?
+     */
+    public abstract boolean requiresNetwork();
+
+    /**
+     * Should this action only run if the preference is enabled?
+     */
+    public abstract boolean requiresPreferenceEnabled();
+
+    /**
+     * This method will swallow all log messages to avoid logging potential personal information.
+     *
+     * For debugging purposes set {@code DEBUG_LOG} to true.
+     */
+    public void log(String message) {
+        if (DEBUG_LOG) {
+            Log.d("Gecko" + getClass().getSimpleName(), message);
+        }
+    }
+
+    /**
+     * This method will swallow all log messages to avoid logging potential personal information.
+     *
+     * For debugging purposes set {@code DEBUG_LOG} to true.
+     */
+    public void log(String message, Throwable throwable) {
+        if (DEBUG_LOG) {
+            Log.d("Gecko" + getClass().getSimpleName(), message, throwable);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/SetupAlarmsAction.java
@@ -0,0 +1,102 @@
+/* -*- 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.feeds.action;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.SystemClock;
+
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.feeds.FeedAlarmReceiver;
+import org.mozilla.gecko.feeds.FeedService;
+
+/**
+ * SetupAlarmsAction: Set up alarms to run various actions every now and then.
+ */
+public class SetupAlarmsAction extends FeedAction {
+    private static final String LOGTAG = "FeedSetupAction";
+
+    private Context context;
+
+    public SetupAlarmsAction(Context context) {
+        this.context = context;
+    }
+
+    @Override
+    public void perform(BrowserDB browserDB, Intent intent) {
+        final AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+
+        cancelPreviousAlarms(alarmManager);
+        scheduleAlarms(alarmManager);
+    }
+
+    @Override
+    public boolean requiresNetwork() {
+        return false;
+    }
+
+    @Override
+    public boolean requiresPreferenceEnabled() {
+        return false;
+    }
+
+    private void cancelPreviousAlarms(AlarmManager alarmManager) {
+        final PendingIntent withdrawIntent = getWithdrawPendingIntent();
+        alarmManager.cancel(withdrawIntent);
+
+        final PendingIntent enrollIntent = getEnrollPendingIntent();
+        alarmManager.cancel(enrollIntent);
+
+        final PendingIntent checkIntent = getCheckPendingIntent();
+        alarmManager.cancel(checkIntent);
+
+        log("Cancelled previous alarms");
+    }
+
+    private void scheduleAlarms(AlarmManager alarmManager) {
+        alarmManager.setInexactRepeating(
+                AlarmManager.ELAPSED_REALTIME,
+                SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_FIFTEEN_MINUTES,
+                AlarmManager.INTERVAL_DAY,
+                getWithdrawPendingIntent());
+
+        alarmManager.setInexactRepeating(
+                AlarmManager.ELAPSED_REALTIME,
+                SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HALF_HOUR,
+                AlarmManager.INTERVAL_DAY,
+                getEnrollPendingIntent()
+        );
+
+        alarmManager.setInexactRepeating(
+                AlarmManager.ELAPSED_REALTIME,
+                SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HOUR,
+                AlarmManager.INTERVAL_HALF_DAY,
+                getCheckPendingIntent()
+        );
+
+        log("Scheduled alarms");
+    }
+
+    private PendingIntent getWithdrawPendingIntent() {
+        Intent intent = new Intent(context, FeedAlarmReceiver.class);
+        intent.setAction(FeedService.ACTION_WITHDRAW);
+        return PendingIntent.getBroadcast(context, 0, intent, 0);
+    }
+
+    private PendingIntent getEnrollPendingIntent() {
+        Intent intent = new Intent(context, FeedAlarmReceiver.class);
+        intent.setAction(FeedService.ACTION_ENROLL);
+        return PendingIntent.getBroadcast(context, 0, intent, 0);
+    }
+
+    private PendingIntent getCheckPendingIntent() {
+        Intent intent = new Intent(context, FeedAlarmReceiver.class);
+        intent.setAction(FeedService.ACTION_CHECK);
+        return PendingIntent.getBroadcast(context, 0, intent, 0);
+    }
+}
rename from mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeAction.java
rename to mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeToFeedAction.java
--- a/mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeAction.java
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeToFeedAction.java
@@ -1,63 +1,76 @@
 /* -*- 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.feeds.action;
 
+import android.content.Context;
 import android.content.Intent;
 import android.os.Bundle;
-import android.util.Log;
 
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
 import org.mozilla.gecko.feeds.FeedFetcher;
 import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
-import org.mozilla.gecko.feeds.subscriptions.SubscriptionStorage;
-
-import java.util.UUID;
 
 /**
- * SubscribeAction: Try to fetch a feed and create a subscription if successful.
+ * SubscribeToFeedAction: Try to fetch a feed and create a subscription if successful.
  */
-public class SubscribeAction {
+public class SubscribeToFeedAction extends FeedAction {
     private static final String LOGTAG = "FeedSubscribeAction";
 
-    public static final String EXTRA_GUID = "guid";
     public static final String EXTRA_FEED_URL = "feed_url";
 
-    private SubscriptionStorage storage;
+    private Context context;
 
-    public SubscribeAction(SubscriptionStorage storage) {
-        this.storage = storage;
+    public SubscribeToFeedAction(Context context) {
+        this.context = context;
     }
 
-    public void perform(Intent intent) {
-        Log.d(LOGTAG, "Subscribing to feed..");
+    @Override
+    public void perform(BrowserDB browserDB, Intent intent) {
+        final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations();
 
         final Bundle extras = intent.getExtras();
+        final String feedUrl = extras.getString(EXTRA_FEED_URL);
 
-        // TODO: Using a random UUID as fallback just so that I can subscribe for things that are not bookmarks (testing)
-        final String guid = extras.getString(EXTRA_GUID, UUID.randomUUID().toString());
-        final String feedUrl = intent.getStringExtra(EXTRA_FEED_URL);
-
-        if (storage.hasSubscriptionForBookmark(guid)) {
-            Log.d(LOGTAG, "Already subscribed to " + feedUrl + ". Skipping.");
+        if (urlAnnotations.hasFeedSubscription(context.getContentResolver(), feedUrl)) {
+            log("Already subscribed to " + feedUrl + ". Skipping.");
             return;
         }
 
-        subscribe(guid, feedUrl);
+        log("Subscribing to feed: " + feedUrl);
+
+        subscribe(urlAnnotations, feedUrl);
+    }
+
+    @Override
+    public boolean requiresNetwork() {
+        return true;
     }
 
-    private void subscribe(String guid, String feedUrl) {
+    @Override
+    public boolean requiresPreferenceEnabled() {
+        return true;
+    }
+
+    private void subscribe(UrlAnnotations urlAnnotations, String feedUrl) {
         FeedFetcher.FeedResponse response = FeedFetcher.fetchAndParseFeed(feedUrl);
         if (response == null) {
-            Log.w(LOGTAG, String.format("Could not fetch feed (%s). Not subscribing for now.", feedUrl));
+            log(String.format("Could not fetch feed (%s). Not subscribing for now.", feedUrl));
             return;
         }
 
-        Log.d(LOGTAG, "Subscribing to feed: " + response.feed.getTitle());
-        Log.d(LOGTAG, "               GUID: " + guid);
-        Log.d(LOGTAG, "          Last item: " + response.feed.getLastItem().getTitle());
+        log("Subscribing to feed: " + response.feed.getTitle());
+        log("          Last item: " + response.feed.getLastItem().getTitle());
 
-        storage.addSubscription(FeedSubscription.create(guid, feedUrl, response));
+        final FeedSubscription subscription = FeedSubscription.create(feedUrl, response);
+
+        urlAnnotations.insertFeedSubscription(context.getContentResolver(), subscription);
+
+        Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SERVICE, "content_update");
     }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/WithdrawSubscriptionsAction.java
@@ -0,0 +1,102 @@
+/* -*- 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.feeds.action;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+
+import org.json.JSONException;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
+
+/**
+ * WithdrawSubscriptionsAction: Look for feeds to unsubscribe from.
+ */
+public class WithdrawSubscriptionsAction extends FeedAction {
+    private static final String LOGTAG = "FeedWithdrawAction";
+
+    private Context context;
+
+    public WithdrawSubscriptionsAction(Context context) {
+        this.context = context;
+    }
+
+    @Override
+    public void perform(BrowserDB browserDB, Intent intent) {
+        log("Searching for subscriptions to remove..");
+
+        final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations();
+        final ContentResolver resolver = context.getContentResolver();
+
+        removeFeedsOfUnknownUrls(browserDB, urlAnnotations, resolver);
+        removeSubscriptionsOfRemovedFeeds(urlAnnotations, resolver);
+    }
+
+    /**
+     * Search for website URLs with a feed assigned. Remove entry if website URL is not known anymore:
+     * For now this means the website is not bookmarked.
+     */
+    private void removeFeedsOfUnknownUrls(BrowserDB browserDB, UrlAnnotations urlAnnotations, ContentResolver resolver) {
+        Cursor cursor = urlAnnotations.getWebsitesWithFeedUrl(resolver);
+        if (cursor == null) {
+            return;
+        }
+
+        try {
+            while (cursor.moveToNext()) {
+                final String url = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.URL));
+
+                if (!browserDB.isBookmark(resolver, url)) {
+                    log("Removing feed for unknown URL: " + url);
+
+                    urlAnnotations.deleteFeedUrl(resolver, url);
+                }
+            }
+        } finally {
+            cursor.close();
+        }
+    }
+
+    /**
+     * Remove subscriptions of feed URLs that are not assigned to a website URL (anymore).
+     */
+    private void removeSubscriptionsOfRemovedFeeds(UrlAnnotations urlAnnotations, ContentResolver resolver) {
+        Cursor cursor = urlAnnotations.getFeedSubscriptions(resolver);
+        if (cursor == null) {
+            return;
+        }
+
+        try {
+            while (cursor.moveToNext()) {
+                final FeedSubscription subscription = FeedSubscription.fromCursor(cursor);
+
+                if (!urlAnnotations.hasWebsiteForFeedUrl(resolver, subscription.getFeedUrl())) {
+                    log("Removing subscription for feed: " + subscription.getFeedUrl());
+
+                    urlAnnotations.deleteFeedSubscription(resolver, subscription);
+                }
+            }
+        } catch (JSONException e) {
+            log("Could not deserialize subscription", e);
+        } finally {
+            cursor.close();
+        }
+    }
+
+    @Override
+    public boolean requiresNetwork() {
+        return false;
+    }
+
+    @Override
+    public boolean requiresPreferenceEnabled() {
+        return true;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSite.java
@@ -0,0 +1,38 @@
+/* -*- 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.feeds.knownsites;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+/**
+ * A site we know and for which we can guess the feed URL from an arbitrary URL.
+ */
+public interface KnownSite {
+    /**
+     * Get a search string to find URLs of this site in our database. This search string is usually
+     * a partial domain / URL.
+     *
+     * For example we could return "medium.com" to find all URLs that contain this string. This could
+     * obviously find URLs that are not actually medium.com sites. This is acceptable as long as
+     * getFeedFromURL() can handle these inputs and either returns a feed for valid URLs or null for
+     * other matches that are not related to this site.
+     */
+    @NonNull String getURLSearchString();
+
+    /**
+     * Get the Feed URL for this URL. For a known site we can "guess" the feed URL from an URL
+     * pointing to any page. The input URL will be a result from the database found with the value
+     * returned by getURLSearchString().
+     *
+     * Example:
+     * - Input:  https://medium.com/@antlam/ux-thoughts-for-2016-1fc1d6e515e8
+     * - Output: https://medium.com/feed/@antlam
+     *
+     * @return the url representing a feed, or null if a feed could not be determined.
+     */
+    @Nullable String getFeedFromURL(String url);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteBlogger.java
@@ -0,0 +1,29 @@
+/* -*- 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.feeds.knownsites;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Blogger.com
+ */
+public class KnownSiteBlogger implements KnownSite {
+    @Override
+    public String getURLSearchString() {
+        return ".blogspot.com";
+    }
+
+    @Override
+    public String getFeedFromURL(String url) {
+        Pattern pattern = Pattern.compile("https?://(www\\.)?(.*?)\\.blogspot\\.com(/.*)?");
+        Matcher matcher = pattern.matcher(url);
+        if (matcher.matches()) {
+            return String.format("https://%s.blogspot.com/feeds/posts/default", matcher.group(2));
+        }
+        return null;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteMedium.java
@@ -0,0 +1,29 @@
+/* -*- 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.feeds.knownsites;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Medium.com
+ */
+public class KnownSiteMedium implements KnownSite {
+    @Override
+    public String getURLSearchString() {
+        return "://medium.com/";
+    }
+
+    @Override
+    public String getFeedFromURL(String url) {
+        Pattern pattern = Pattern.compile("https?://medium.com/([^/]+)(/.*)?");
+        Matcher matcher = pattern.matcher(url);
+        if (matcher.matches()) {
+            return String.format("https://medium.com/feed/%s", matcher.group(1));
+        }
+        return null;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteWordpress.java
@@ -0,0 +1,26 @@
+package org.mozilla.gecko.feeds.knownsites;
+
+import android.support.annotation.NonNull;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Wordpress.com
+ */
+public class KnownSiteWordpress implements KnownSite {
+    @Override
+    public String getURLSearchString() {
+        return ".wordpress.com";
+    }
+
+    @Override
+    public String getFeedFromURL(String url) {
+        Pattern pattern = Pattern.compile("https?://(.*?).wordpress.com(/.*)?");
+        Matcher matcher = pattern.matcher(url);
+        if (matcher.matches()) {
+            return "https://" + matcher.group(1) + ".wordpress.com/feed/";
+        }
+        return null;
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Feed.java
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Feed.java
@@ -47,44 +47,16 @@ public class Feed {
      */
     /* package-private */ boolean isSufficientlyComplete() {
         return !TextUtils.isEmpty(title) &&
                 lastItem != null &&
                 !TextUtils.isEmpty(lastItem.getURL()) &&
                 !TextUtils.isEmpty(lastItem.getTitle());
     }
 
-    /**
-     * Guesstimate if the given feed is a newer representation of this feed.
-     */
-    public boolean hasBeenUpdated(Feed newFeed) {
-        final Item otherItem = newFeed.getLastItem();
-
-        if (lastItem.getTimestamp() > otherItem.getTimestamp()) {
-            // The timestamp is from a newer date so we expect that this item is a new item. But this
-            // could also mean that the timestamp of an already existing item has been updated. We
-            // accept that and assume that the content will have changed too in this case.
-            return true;
-        }
-
-        if (lastItem.getTimestamp() == otherItem.getTimestamp() && lastItem.getTimestamp() != 0) {
-            // We have a timestamp that is not zero and this item has still the timestamp: It's very
-            // likely that we are looking at the same item. We assume this is not new content.
-            return false;
-        }
-
-        if (!lastItem.getURL().equals(otherItem.getURL())) {
-            // The URL changed: It is very likely that this is a new item. At least it has been updated
-            // in a way that we just treat it as new content here.
-            return true;
-        }
-
-        return false;
-    }
-
     public String getTitle() {
         return title;
     }
 
     public String getWebsiteURL() {
         return websiteURL;
     }
 
--- a/mobile/android/base/java/org/mozilla/gecko/feeds/parser/SimpleFeedParser.java
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/SimpleFeedParser.java
@@ -51,24 +51,26 @@ public class SimpleFeedParser {
     private static final String TAG_ITEM = "item";
     private static final String TAG_LINK = "link";
     private static final String TAG_ENTRY = "entry";
     private static final String TAG_PUBDATE = "pubDate";
     private static final String TAG_UPDATED = "updated";
     private static final String TAG_DATE = "date";
     private static final String TAG_SOURCE = "source";
     private static final String TAG_IMAGE = "image";
+    private static final String TAG_CONTENT = "content";
 
     private class ParserState {
         public Feed feed;
         public Item currentItem;
         public boolean isRSS;
         public boolean isATOM;
         public boolean inSource;
         public boolean inImage;
+        public boolean inContent;
     }
 
     public Feed parse(InputStream in) throws ParserException, IOException {
         final ParserState state = new ParserState();
 
         try {
             final XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
             factory.setNamespaceAware(true);
@@ -152,16 +154,20 @@ public class SimpleFeedParser {
 
             case TAG_SOURCE:
                 state.inSource = true;
                 break;
 
             case TAG_IMAGE:
                 state.inImage = true;
                 break;
+
+            case TAG_CONTENT:
+                state.inContent = true;
+                break;
         }
     }
 
     private void handleEndTag(XmlPullParser parser, ParserState state) {
         switch (parser.getName()) {
             case TAG_ITEM:
             case TAG_ENTRY:
                 handleItemOrEntryREndTag(state);
@@ -169,22 +175,26 @@ public class SimpleFeedParser {
 
             case TAG_SOURCE:
                 state.inSource = false;
                 break;
 
             case TAG_IMAGE:
                 state.inImage = false;
                 break;
+
+            case TAG_CONTENT:
+                state.inContent = false;
+                break;
         }
     }
 
     private void handleTitleStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
-        if (state.inSource || state.inImage) {
-            // We do not care about titles in <source> or <image> tags.
+        if (state.inSource || state.inImage || state.inContent) {
+            // We do not care about titles in <source>, <image> or <media> tags.
             return;
         }
 
         String title = getTextUntilEndTag(parser, TAG_TITLE);
 
         title = title.replaceAll("[\r\n]", " ");
         title = title.replaceAll("  +", " ");
 
--- a/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java
@@ -1,161 +1,130 @@
 /* -*- 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.feeds.subscriptions;
 
+import android.database.Cursor;
 import android.text.TextUtils;
 
 import org.json.JSONException;
 import org.json.JSONObject;
+import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.feeds.FeedFetcher;
 import org.mozilla.gecko.feeds.parser.Item;
 
 /**
  * An object describing a subscription and containing some meta data about the last time we fetched
  * the feed.
  */
 public class FeedSubscription {
-    private static final String JSON_KEY_FEED_URL = "feed_url";
     private static final String JSON_KEY_FEED_TITLE = "feed_title";
-    private static final String JSON_KEY_WEBSITE_URL = "website_url";
     private static final String JSON_KEY_LAST_ITEM_TITLE = "last_item_title";
     private static final String JSON_KEY_LAST_ITEM_URL = "last_item_url";
     private static final String JSON_KEY_LAST_ITEM_TIMESTAMP = "last_item_timestamp";
     private static final String JSON_KEY_ETAG = "etag";
     private static final String JSON_KEY_LAST_MODIFIED = "last_modified";
-    private static final String JSON_KEY_BOOKMARK_GUID = "bookmark_guid";
 
-    private String bookmarkGuid; // Currently a subscription is linked to a bookmark
     private String feedUrl;
     private String feedTitle;
-    private String websiteUrl;
     private String lastItemTitle;
     private String lastItemUrl;
     private long lastItemTimestamp;
     private String etag;
     private String lastModified;
 
-    public static FeedSubscription create(String bookmarkGuid, String url, FeedFetcher.FeedResponse response) {
+    public static FeedSubscription create(String feedUrl, FeedFetcher.FeedResponse response) {
         FeedSubscription subscription = new FeedSubscription();
-        subscription.bookmarkGuid = bookmarkGuid;
-        subscription.feedUrl = url;
+        subscription.feedUrl = feedUrl;
 
         subscription.update(response);
 
         return subscription;
     }
 
-    public static FeedSubscription fromJSON(JSONObject object) throws JSONException {
-        FeedSubscription subscription = new FeedSubscription();
+    public static FeedSubscription fromCursor(Cursor cursor) throws JSONException {
+        final FeedSubscription subscription = new FeedSubscription();
+        subscription.feedUrl = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.URL));
 
-        subscription.feedUrl = object.getString(JSON_KEY_FEED_URL);
-        subscription.feedTitle = object.getString(JSON_KEY_FEED_TITLE);
-        subscription.websiteUrl = object.getString(JSON_KEY_WEBSITE_URL);
-        subscription.lastItemTitle = object.getString(JSON_KEY_LAST_ITEM_TITLE);
-        subscription.lastItemUrl = object.getString(JSON_KEY_LAST_ITEM_URL);
-        subscription.lastItemTimestamp = object.getLong(JSON_KEY_LAST_ITEM_TIMESTAMP);
-        subscription.etag = object.getString(JSON_KEY_ETAG);
-        subscription.lastModified = object.getString(JSON_KEY_LAST_MODIFIED);
-        subscription.bookmarkGuid = object.getString(JSON_KEY_BOOKMARK_GUID);
+        final String value = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.VALUE));
+        subscription.fromJSON(new JSONObject(value));
 
         return subscription;
     }
 
-    /* package-private */ void update(FeedFetcher.FeedResponse response) {
-        final String feedUrl = response.feed.getFeedURL();
-        if (!TextUtils.isEmpty(feedUrl)) {
-            // Prefer to use the URL we get from the feed for further requests
-            this.feedUrl = feedUrl;
-        }
+    private void fromJSON(JSONObject object) throws JSONException {
+        feedTitle = object.getString(JSON_KEY_FEED_TITLE);
+        lastItemTitle = object.getString(JSON_KEY_LAST_ITEM_TITLE);
+        lastItemUrl = object.getString(JSON_KEY_LAST_ITEM_URL);
+        lastItemTimestamp = object.getLong(JSON_KEY_LAST_ITEM_TIMESTAMP);
+        etag = object.optString(JSON_KEY_ETAG);
+        lastModified = object.optString(JSON_KEY_LAST_MODIFIED);
+    }
 
+    public void update(FeedFetcher.FeedResponse response) {
         feedTitle = response.feed.getTitle();
-        websiteUrl = response.feed.getWebsiteURL();
         lastItemTitle = response.feed.getLastItem().getTitle();
         lastItemUrl = response.feed.getLastItem().getURL();
         lastItemTimestamp = response.feed.getLastItem().getTimestamp();
         etag = response.etag;
         lastModified = response.lastModified;
     }
 
-
     /**
      * Guesstimate if this response is a newer representation of the feed.
      */
-    public boolean isNewer(FeedFetcher.FeedResponse response) {
-        final Item otherItem = response.feed.getLastItem();
+    public boolean hasBeenUpdated(FeedFetcher.FeedResponse response) {
+        final Item responseItem = response.feed.getLastItem();
 
-        if (lastItemTimestamp > otherItem.getTimestamp()) {
-            return true; // How to detect if this same item and it only has been updated?
+        if (responseItem.getTimestamp() > lastItemTimestamp) {
+            // The timestamp is from a newer date so we expect that this item is a new item. But this
+            // could also mean that the timestamp of an already existing item has been updated. We
+            // accept that and assume that the content will have changed too in this case.
+            return true;
         }
 
-        if (lastItemTimestamp == otherItem.getTimestamp() &&
-                lastItemTimestamp != 0) {
+        if (responseItem.getTimestamp() == lastItemTimestamp && responseItem.getTimestamp() != 0) {
+            // We have a timestamp that is not zero and this item has still the timestamp: It's very
+            // likely that we are looking at the same item. We assume this is not new content.
             return false;
         }
 
-        if (lastItemUrl == null || !lastItemUrl.equals(otherItem.getURL())) {
-            // URL changed: Probably a different item
+        if (!responseItem.getURL().equals(lastItemUrl)) {
+            // The URL changed: It is very likely that this is a new item. At least it has been updated
+            // in a way that we just treat it as new content here.
             return true;
         }
 
         return false;
     }
 
     public String getFeedUrl() {
         return feedUrl;
     }
 
     public String getFeedTitle() {
         return feedTitle;
     }
 
-    public String getWebsiteUrl() {
-        return websiteUrl;
-    }
-
-    public String getLastItemTitle() {
-        return lastItemTitle;
-    }
-
-    public String getLastItemUrl() {
-        return lastItemUrl;
-    }
-
-    public long getLastItemTimestamp() {
-        return lastItemTimestamp;
-    }
-
     public String getETag() {
         return etag;
     }
 
     public String getLastModified() {
         return lastModified;
     }
 
-    public String getBookmarkGUID() {
-        return bookmarkGuid;
-    }
-
-    public boolean isForTheSameBookmarkAs(FeedSubscription other) {
-        return TextUtils.equals(bookmarkGuid, other.bookmarkGuid);
-    }
-
     public JSONObject toJSON() throws JSONException {
         JSONObject object = new JSONObject();
 
-        object.put(JSON_KEY_FEED_URL, feedUrl);
         object.put(JSON_KEY_FEED_TITLE, feedTitle);
-        object.put(JSON_KEY_WEBSITE_URL, websiteUrl);
         object.put(JSON_KEY_LAST_ITEM_TITLE, lastItemTitle);
         object.put(JSON_KEY_LAST_ITEM_URL, lastItemUrl);
         object.put(JSON_KEY_LAST_ITEM_TIMESTAMP, lastItemTimestamp);
         object.put(JSON_KEY_ETAG, etag);
         object.put(JSON_KEY_LAST_MODIFIED, lastModified);
-        object.put(JSON_KEY_BOOKMARK_GUID, bookmarkGuid);
 
         return object;
     }
 }
deleted file mode 100644
--- a/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/SubscriptionStorage.java
+++ /dev/null
@@ -1,220 +0,0 @@
-/* -*- 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.feeds.subscriptions;
-
-import android.content.Context;
-import android.text.TextUtils;
-import android.util.AtomicFile;
-import android.util.Log;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-import org.mozilla.gecko.feeds.FeedFetcher;
-
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-
-/**
- * Storage for feed subscriptions. This is just using a plain JSON file on disk.
- *
- * TODO: Store this data in the url metadata tablet instead (See bug 1250707)
- */
-public class SubscriptionStorage {
-    private static final String LOGTAG = "FeedStorage";
-    private static final String FILE_NAME = "feed_subscriptions";
-
-    private static final String JSON_KEY_SUBSCRIPTIONS = "subscriptions";
-
-    private final AtomicFile file; // Guarded by 'file'
-
-    private List<FeedSubscription> subscriptions;
-    private boolean hasLoadedSubscriptions;
-    private boolean hasChanged;
-
-    public SubscriptionStorage(Context context) {
-        this(new AtomicFile(new File(context.getApplicationInfo().dataDir, FILE_NAME)));
-
-        startLoadFromDisk();
-    }
-
-    // For injecting mocked AtomicFile objects during test
-    protected SubscriptionStorage(AtomicFile file) {
-        this.subscriptions = new ArrayList<>();
-        this.file = file;
-    }
-
-    public synchronized void addSubscription(FeedSubscription subscription) {
-        awaitLoadingSubscriptionsLocked();
-
-        subscriptions.add(subscription);
-        hasChanged = true;
-    }
-
-    public synchronized void removeSubscription(FeedSubscription subscription) {
-        awaitLoadingSubscriptionsLocked();
-
-        Iterator<FeedSubscription> iterator = subscriptions.iterator();
-        while (iterator.hasNext()) {
-            if (subscription.isForTheSameBookmarkAs(iterator.next())) {
-                iterator.remove();
-                hasChanged = true;
-                return;
-            }
-        }
-    }
-
-    public synchronized List<FeedSubscription> getSubscriptions() {
-        awaitLoadingSubscriptionsLocked();
-
-        return new ArrayList<>(subscriptions);
-    }
-
-    public synchronized void updateSubscription(FeedSubscription subscription, FeedFetcher.FeedResponse response) {
-        awaitLoadingSubscriptionsLocked();
-
-        subscription.update(response);
-
-        for (int i = 0; i < subscriptions.size(); i++) {
-            if (subscriptions.get(i).isForTheSameBookmarkAs(subscription)) {
-                subscriptions.set(i, subscription);
-                hasChanged = true;
-                return;
-            }
-        }
-    }
-
-    public synchronized boolean hasSubscriptionForBookmark(String guid) {
-        awaitLoadingSubscriptionsLocked();
-
-        for (int i = 0; i < subscriptions.size(); i++) {
-            if (TextUtils.equals(guid, subscriptions.get(i).getBookmarkGUID())) {
-                return true;
-            }
-        }
-
-        return false;
-    }
-
-    private void awaitLoadingSubscriptionsLocked() {
-        while (!hasLoadedSubscriptions) {
-            try {
-                Log.v(LOGTAG, "Waiting for subscriptions to be loaded");
-
-                wait();
-            } catch (InterruptedException e) {
-                // Ignore
-            }
-        }
-    }
-
-    public void persistChanges() {
-        new Thread(LOGTAG + "-Persist") {
-            public void run() {
-                writeToDisk();
-            }
-        }.start();
-    }
-
-    private void startLoadFromDisk() {
-        new Thread(LOGTAG + "-Load") {
-            public void run() {
-                loadFromDisk();
-            }
-        }.start();
-    }
-
-    protected synchronized void loadFromDisk() {
-        Log.d(LOGTAG, "Loading from disk");
-
-        if (hasLoadedSubscriptions) {
-            return;
-        }
-
-        List<FeedSubscription> subscriptions = new ArrayList<>();
-
-        try {
-            JSONObject data;
-
-            synchronized (file) {
-                data = new JSONObject(new String(file.readFully(), "UTF-8"));
-            }
-
-            JSONArray array = data.getJSONArray(JSON_KEY_SUBSCRIPTIONS);
-            for (int i = 0; i < array.length(); i++) {
-                subscriptions.add(FeedSubscription.fromJSON(array.getJSONObject(i)));
-            }
-        } catch (FileNotFoundException e) {
-            Log.d(LOGTAG, "No subscriptions yet.");
-        } catch (JSONException e) {
-            Log.w(LOGTAG, "Unable to parse subscriptions JSON. Using empty list.", e);
-        } catch (UnsupportedEncodingException e) {
-            AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8");
-            error.initCause(e);
-            throw error;
-        } catch (IOException e) {
-            Log.d(LOGTAG, "Can't read subscriptions due to IOException", e);
-        }
-
-        onSubscriptionsLoaded(subscriptions);
-
-        notifyAll();
-
-        Log.d(LOGTAG, "Loaded " + subscriptions.size() + " elements");
-    }
-
-    protected void onSubscriptionsLoaded(List<FeedSubscription> subscriptions) {
-        this.subscriptions = subscriptions;
-        this.hasLoadedSubscriptions = true;
-    }
-
-    protected synchronized void writeToDisk() {
-        if (!hasChanged) {
-            Log.v(LOGTAG, "Not persisting: Subscriptions have not changed");
-            return;
-        }
-
-        Log.d(LOGTAG, "Writing to disk");
-
-        FileOutputStream outputStream = null;
-
-        synchronized (file) {
-            try {
-                outputStream = file.startWrite();
-
-                JSONArray array = new JSONArray();
-                for (FeedSubscription subscription : this.subscriptions) {
-                    array.put(subscription.toJSON());
-                }
-
-                JSONObject catalog = new JSONObject();
-                catalog.put(JSON_KEY_SUBSCRIPTIONS, array);
-
-                outputStream.write(catalog.toString().getBytes("UTF-8"));
-
-                file.finishWrite(outputStream);
-
-                hasChanged = false;
-            } catch (UnsupportedEncodingException e) {
-                AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8");
-                error.initCause(e);
-                throw error;
-            } catch (IOException | JSONException e) {
-                Log.e(LOGTAG, "IOException during writing catalog", e);
-
-                if (outputStream != null) {
-                    file.failWrite(outputStream);
-                }
-            }
-        }
-    }
-}
--- a/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferenceFragment.java
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferenceFragment.java
@@ -113,16 +113,21 @@ public class GeckoPreferenceFragment ext
             return getString(R.string.pref_category_privacy_short);
         }
 
         // We can launch this category from the the magnifying glass in the quick search bar.
         if (res == R.xml.preferences_search) {
             return getString(R.string.pref_category_search);
         }
 
+        // Launched as action from content notifications.
+        if (res == R.xml.preferences_notifications) {
+            return getString(R.string.pref_category_notifications);
+        }
+
         return null;
     }
 
     /**
      * Return the header id for this preference fragment. This allows
      * us to select the correct header when launching a preference
      * screen directly.
      *
@@ -140,16 +145,21 @@ public class GeckoPreferenceFragment ext
             return R.id.pref_header_privacy;
         }
 
         // We can launch this category from the the magnifying glass in the quick search bar.
         if (res == R.xml.preferences_search) {
             return R.id.pref_header_search;
         }
 
+        // Launched as action from content notifications.
+        if (res == R.xml.preferences_notifications) {
+            return R.id.pref_header_notifications;
+        }
+
         return -1;
     }
 
     private void updateTitle() {
         final String newTitle = getTitle();
         if (newTitle == null) {
             Log.d(LOGTAG, "No new title to show.");
             return;
--- a/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java
@@ -26,23 +26,26 @@ import org.mozilla.gecko.PrefsHelper;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Restrictions;
 import org.mozilla.gecko.SnackbarHelper;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
 import org.mozilla.gecko.TelemetryContract.Method;
 import org.mozilla.gecko.background.common.GlobalConstants;
 import org.mozilla.gecko.db.BrowserContract.SuggestedSites;
+import org.mozilla.gecko.feeds.FeedService;
+import org.mozilla.gecko.feeds.action.CheckForUpdatesAction;
 import org.mozilla.gecko.permissions.Permissions;
 import org.mozilla.gecko.restrictions.Restrictable;
 import org.mozilla.gecko.tabqueue.TabQueueHelper;
 import org.mozilla.gecko.tabqueue.TabQueuePrompt;
 import org.mozilla.gecko.updater.UpdateService;
 import org.mozilla.gecko.updater.UpdateServiceHelper;
 import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.Experiments;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.InputOptionsUtils;
 import org.mozilla.gecko.util.NativeEventListener;
 import org.mozilla.gecko.util.NativeJSObject;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.annotation.TargetApi;
@@ -65,33 +68,34 @@ import android.os.Bundle;
 import android.preference.CheckBoxPreference;
 import android.preference.EditTextPreference;
 import android.preference.ListPreference;
 import android.preference.Preference;
 import android.preference.Preference.OnPreferenceChangeListener;
 import android.preference.Preference.OnPreferenceClickListener;
 import android.preference.PreferenceActivity;
 import android.preference.PreferenceGroup;
-import android.preference.PreferenceScreen;
 import android.preference.TwoStatePreference;
 import android.support.design.widget.Snackbar;
 import android.support.design.widget.TextInputLayout;
 import android.text.Editable;
 import android.text.InputType;
 import android.text.TextUtils;
 import android.text.TextWatcher;
 import android.util.Log;
 import android.view.MenuItem;
 import android.view.View;
 import android.widget.AdapterView;
 import android.widget.EditText;
 import android.widget.LinearLayout;
 import android.widget.ListAdapter;
 import android.widget.ListView;
 
+import com.keepsafe.switchboard.SwitchBoard;
+
 import org.json.JSONObject;
 
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -147,16 +151,17 @@ OnSharedPreferenceChangeListener
     private static final String PREFS_TRACKING_PROTECTION_LEARN_MORE = NON_PREF_PREFIX + "trackingprotection.learn_more";
     private static final String PREFS_CLEAR_PRIVATE_DATA = NON_PREF_PREFIX + "privacy.clear";
     private static final String PREFS_CLEAR_PRIVATE_DATA_EXIT = NON_PREF_PREFIX + "history.clear_on_exit";
     private static final String PREFS_SCREEN_ADVANCED = NON_PREF_PREFIX + "advanced_screen";
     public static final String PREFS_HOMEPAGE = NON_PREF_PREFIX + "homepage";
     public static final String PREFS_HISTORY_SAVED_SEARCH = NON_PREF_PREFIX + "search.search_history.enabled";
     private static final String PREFS_FAQ_LINK = NON_PREF_PREFIX + "faq.link";
     private static final String PREFS_FEEDBACK_LINK = NON_PREF_PREFIX + "feedback.link";
+    public static final String PREFS_NOTIFICATIONS_CONTENT = NON_PREF_PREFIX + "notifications.content";
 
     private static final String ACTION_STUMBLER_UPLOAD_PREF = AppConstants.ANDROID_PACKAGE_NAME + ".STUMBLER_PREF";
 
 
     // This isn't a Gecko pref, even if it looks like one.
     private static final String PREFS_BROWSER_LOCALE = "locale";
 
     public static final String PREFS_RESTORE_SESSION = NON_PREF_PREFIX + "restoreSession3";
@@ -375,16 +380,21 @@ OnSharedPreferenceChangeListener
         // capture EXTRA_SHOW_FRAGMENT_TITLE from the intent and store the title ID.
 
         // If launched from notification, explicitly cancel the notification.
         if (intentExtras != null && intentExtras.containsKey(DataReportingNotification.ALERT_NAME_DATAREPORTING_NOTIFICATION)) {
             Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, Method.NOTIFICATION, "settings-data-choices");
             NotificationManager notificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE);
             notificationManager.cancel(DataReportingNotification.ALERT_NAME_DATAREPORTING_NOTIFICATION.hashCode());
         }
+
+        // Launched from "Notifications settings" action button in a notification.
+        if (intentExtras != null && intentExtras.containsKey(CheckForUpdatesAction.EXTRA_CONTENT_NOTIFICATION)) {
+            Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, Method.BUTTON, "notification-settings");
+        }
     }
 
     /**
      * Initializes the action bar configuration in code.
      *
      * Declaring these attributes in XML does not work on some devices for an unknown reason
      * (e.g. the back button stops working or the logo disappears; see bug 1152314) so we
      * duplicate those attributes in code here. Note: the order of these calls matters.
@@ -856,16 +866,22 @@ OnSharedPreferenceChangeListener
                     final String url = getResources().getString(R.string.feedback_link, AppConstants.MOZ_APP_VERSION, AppConstants.MOZ_UPDATE_CHANNEL);
                     ((LinkPreference) pref).setUrl(url);
                 } else if (PREFS_DYNAMIC_TOOLBAR.equals(key)) {
                     if (DynamicToolbar.isForceDisabled()) {
                         preferences.removePreference(pref);
                         i--;
                         continue;
                     }
+                } else if (PREFS_NOTIFICATIONS_CONTENT.equals(key)) {
+                    if (!SwitchBoard.isInExperiment(this, Experiments.CONTENT_NOTIFICATIONS)) {
+                        preferences.removePreference(pref);
+                        i--;
+                        continue;
+                    }
                 }
 
                 // Some Preference UI elements are not actually preferences,
                 // but they require a key to work correctly. For example,
                 // "Clear private data" requires a key for its state to be
                 // saved when the orientation changes. It uses the
                 // "android.not_a_preference.privacy.clear" key - which doesn't
                 // exist in Gecko - to satisfy this requirement.
@@ -1203,16 +1219,18 @@ OnSharedPreferenceChangeListener
                 return true;
             }
         } else if (PREFS_TAB_QUEUE.equals(prefName)) {
             if ((Boolean) newValue && !TabQueueHelper.canDrawOverlays(this)) {
                 Intent promptIntent = new Intent(this, TabQueuePrompt.class);
                 startActivityForResult(promptIntent, REQUEST_CODE_TAB_QUEUE);
                 return false;
             }
+        } else if (PREFS_NOTIFICATIONS_CONTENT.equals(prefName)) {
+            FeedService.setup(this);
         } else if (handlers.containsKey(prefName)) {
             PrefHandler handler = handlers.get(prefName);
             handler.onChange(this, preference, newValue);
         }
 
         // Send Gecko-side pref changes to Gecko
         if (isGeckoPref(prefName)) {
             PrefsHelper.setPref(prefName, newValue, true /* flush */);
--- a/mobile/android/base/java/org/mozilla/gecko/push/PushState.java
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushState.java
@@ -3,17 +3,17 @@
  * 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.push;
 
 import android.content.Context;
 import android.support.annotation.NonNull;
 import android.support.annotation.WorkerThread;
-import android.util.AtomicFile;
+import android.support.v4.util.AtomicFile;
 import android.util.Log;
 
 import org.json.JSONException;
 import org.json.JSONObject;
 
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
--- a/mobile/android/base/locales/en-US/android_strings.dtd
+++ b/mobile/android/base/locales/en-US/android_strings.dtd
@@ -159,16 +159,19 @@
 <!-- Localization note (pref_search_hint) : "TIP" as in "hint", "clue" etc. Displayed as an
      advisory message on the customise search providers settings page explaining how to add new
      search providers.
      The &formatI; in the string will be replaced by a small image of the icon described, and can be moved to wherever
      it is applicable. -->
 <!ENTITY pref_search_hint "TIP: Add any website to your list of search providers by long-pressing on its search field and then tapping the &formatI; icon.">
 <!ENTITY pref_category_advanced "Advanced">
 <!ENTITY pref_category_advanced_summary2 "Restore tabs, plugins, developer tools">
+<!ENTITY pref_category_notifications "Notifications">
+<!ENTITY pref_content_notifications "Website updates">
+<!ENTITY pref_content_notifications_summary "Allow notifications for supported sites">
 <!ENTITY pref_developer_remotedebugging_usb "Remote debugging via USB">
 <!ENTITY pref_developer_remotedebugging_wifi "Remote debugging via Wi-Fi">
 <!ENTITY pref_developer_remotedebugging_wifi_disabled_summary "Wi-Fi debugging requires your device to have a QR code reader app installed.">
 <!ENTITY pref_remember_signons2 "Remember logins">
 <!ENTITY pref_manage_logins "Manage logins">
 
 <!ENTITY pref_category_home "Home">
 <!ENTITY pref_category_home_summary "Customize your homepage">
@@ -195,16 +198,17 @@
      text field. -->
 <!ENTITY home_homepage_hint_user_address "Enter address or search term">
 
 <!-- Localization note: These are shown in the left sidebar on tablets -->
 <!ENTITY pref_header_general "General">
 <!ENTITY pref_header_search "Search">
 <!ENTITY pref_header_privacy_short "Privacy">
 <!ENTITY pref_header_accessibility "Accessibility">
+<!ENTITY pref_header_notifications "Notifications">
 <!ENTITY pref_header_advanced "Advanced">
 <!ENTITY pref_header_help "Help">
 <!ENTITY pref_header_vendor "&vendorShortName;">
 
 <!ENTITY pref_cookies_menu "Cookies">
 <!ENTITY pref_cookies_accept_all "Enabled">
 <!ENTITY pref_cookies_not_accept_foreign "Enabled, excluding 3rd party">
 <!ENTITY pref_cookies_disabled "Disabled">
@@ -252,16 +256,24 @@
 <!-- Localization note (tab_queue_notification_text_singular2) : This is the
      text of a notification; we expect only one tab queued. -->
 <!ENTITY tab_queue_notification_text_singular2 "1 tab waiting">
 
 <!-- Localization note (tab_queue_notification_settings): This notification text is shown if a tab
      has been queued but we are missing the system permission to show an overlay. -->
 <!ENTITY tab_queue_notification_settings "To \&quot;Open multiple links\&quot;, please enable the \'Draw over other apps\' permission for &brandShortName;">
 
+<!ENTITY content_notification_summary "&brandShortName;">
+<!ENTITY content_notification_title_plural "&formatD; websites updated">
+<!ENTITY content_notification_action_settings "Notifications Setting">
+<!-- Localization note (content_notification_updated_on): &formatS; will be replaced with a medium sized version of the
+     date, depending on locale. For en_US this is for example: Feb 24, 2016. For more details see the Android developer
+     documentation for DateFormat.getMediumDateFormat(). -->
+<!ENTITY content_notification_updated_on "Updated on &formatS;">
+
 <!ENTITY pref_char_encoding "Character encoding">
 <!ENTITY pref_char_encoding_on "Show menu">
 <!ENTITY pref_char_encoding_off "Don\'t show menu">
 <!ENTITY pref_clear_private_data2 "Clear private data">
 <!-- Localization note (pref_clear_private_data_now_tablet): This action to clear private data is only shown on tablets.
      The action is shown below a header saying "Clear private data"; See pref_clear_private_data -->
 <!ENTITY pref_clear_private_data_now_tablet "Clear now">
 <!ENTITY pref_clear_on_exit_title3 "Clear private data on exit">
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -197,16 +197,17 @@ gbjar.sources += ['java/org/mozilla/geck
     'animation/AnimatorProxy.java',
     'animation/HeightChangeAnimation.java',
     'animation/PropertyAnimator.java',
     'animation/Rotate3DAnimation.java',
     'animation/ViewHelper.java',
     'ANRReporter.java',
     'AppNotificationClient.java',
     'BaseGeckoInterface.java',
+    'BootReceiver.java',
     'BrowserApp.java',
     'BrowserLocaleManager.java',
     'ContactService.java',
     'ContextGetter.java',
     'CrashHandler.java',
     'CustomEditText.java',
     'DataReportingNotification.java',
     'db/AbstractPerProfileDatabaseProvider.java',
@@ -267,25 +268,33 @@ gbjar.sources += ['java/org/mozilla/geck
     'favicons/decoders/FaviconDecoder.java',
     'favicons/decoders/ICODecoder.java',
     'favicons/decoders/IconDirectoryEntry.java',
     'favicons/decoders/LoadFaviconResult.java',
     'favicons/Favicons.java',
     'favicons/LoadFaviconTask.java',
     'favicons/OnFaviconLoadedListener.java',
     'favicons/RemoteFavicon.java',
-    'feeds/action/CheckAction.java',
-    'feeds/action/SubscribeAction.java',
+    'feeds/action/CheckForUpdatesAction.java',
+    'feeds/action/EnrollSubscriptionsAction.java',
+    'feeds/action/FeedAction.java',
+    'feeds/action/SetupAlarmsAction.java',
+    'feeds/action/SubscribeToFeedAction.java',
+    'feeds/action/WithdrawSubscriptionsAction.java',
+    'feeds/FeedAlarmReceiver.java',
     'feeds/FeedFetcher.java',
     'feeds/FeedService.java',
+    'feeds/knownsites/KnownSite.java',
+    'feeds/knownsites/KnownSiteBlogger.java',
+    'feeds/knownsites/KnownSiteMedium.java',
+    'feeds/knownsites/KnownSiteWordpress.java',
     'feeds/parser/Feed.java',
     'feeds/parser/Item.java',
     'feeds/parser/SimpleFeedParser.java',
     'feeds/subscriptions/FeedSubscription.java',
-    'feeds/subscriptions/SubscriptionStorage.java',
     'FilePicker.java',
     'FilePickerResultHandler.java',
     'FindInPageBar.java',
     'firstrun/DataPanel.java',
     'firstrun/FirstrunAnimationContainer.java',
     'firstrun/FirstrunPager.java',
     'firstrun/FirstrunPagerConfig.java',
     'firstrun/FirstrunPanel.java',
index c4f178a281ff167ff8e1534f956bba9c9c32c063..9cb9d6fbb4c3417cbb9e9d0dc2dacb9de42c5a5f
GIT binary patch
literal 570
zc%17D@N?(olHy`uVBq!ia0vp^PCy*a!3HEZ_)k6nq}Y<Y-CY>|gW!U_%O?XxI14-?
ziy0WWg+Z8+Vb&Z8pdfpRr>`sf17<NnN%hlf#9A2`7>|3pIEGZ*dV9;(i`h}4{o`{}
z*I#e9h!wHvD>UuxH9P3mS=Ah|Lj8kwSy<*Jb`|jp+AM1(D<v(Q+A7)2xoA=Ak9X%-
z{_khbnbuxzpJvw9TyXNTJij5U+5rg(!5yr>u05L96``hBXJO!+H7D@GR^GVNJR2R(
z^{@q>n^_>P7x8<)q8*!I)xyJ`98c#I-<y8yJKL4aTW06~=PTJoKW)u^e{#lg0mEaW
z?p%WNHQ48wU--!@DBCV)+G-$WvuULO!}>ScM{=|dv7Y7<T&wLer%SZ8W15Ik)EU(y
zCWReJ(*-6UQIsp@H1v~6n&aIO_())}jnfg^V^g>{+>TLOG`~XWS7gfuvF|Kxoy!tm
zY`p#UyIkO79Z!zs_rG!nURYbB#pKMjE3$P)ZFxqx4S(+X_p<BO-?;XO?eUgHt=Cp=
z-SN?S&i5^BVlOT*hk2hdNSMxRyg$79yX$nx;8!M%j+uh5Ol*~>7zxf-d)%eW#6E92
zv$af>yN2nJ>IplZ*7aQcarrXit$E*DPB}BQ$eGIBP}=Z)wSs6J?|PLZ(>WvAuBG43
q_CDe}HEG)vzi<CJ1aSk;r@a4|D?VD-PGthdGJ~h9pUXO@geCyZ+TPg!
index 1fd38b656a325ddd7ddf960e15fe986d978990e7..075a30029b8dfb7ee7ed196a8f62b118a0f85b49
GIT binary patch
literal 692
zc%17D@N?(olHy`uVBq!ia0vp^PCy*a!3HEZ_)k6nq}Y<Y-CY>|gW!U_%O?XxI14-?
ziy0WWg+Z8+Vb&Z8pdfpRr>`sf17<NnNv^(PuMZ3iOpcx|jv*Dd-rjQb4hfWK|M*;X
zP7Dj%#iSM&j}O8-q&PCC>t;`95!#uNwq9W8*4))AJ#KZ>GN&JDWoEr-p7D6m8rLS3
zg}FTMZ$ERpqV;NC^}QGWe=~eOWc++i^?8E{6W1HQv}N0OOl4-|IgU9O*wq@o&v`iO
z?!&wFqK~F?+)wRIbW}>|Pi$05=}kPSl+vBZsGQQ7=%{=}?l6=43ez^${(xwn-|R|P
zgcb)@wv-+dc43(Dpk?2BV>!E)?~+eDdRYYPj#wle{@rsdPB6cc<BW>kgY;9DhR#2f
z^B6vx?)`t}ck;S+?uX891@Ep`S!zUlR8-y=c0@XlG1&C}<YlLJF5Kp3e)&AdT?55E
z$5j)of;(Oo)isDdj8R?Q^(=g&>8&SM`vey`YG=2BxluE>yJ?@gCUJU?>9IvE7AIsL
zDR_1KJ)`(7xg}%6^q(8<bZ=5BGg{v<S3;3%TcMw^t)ZXh4L-p`Rx(L@&Ilc<NR;J!
z9J6cInl#RfPaId=UatE|>c$$&!)cc0S4?H*h(6e{jN|xIn?tP|^mbctJ5022YTTR<
zTjt!Rk(`sPqi&}#eQosF$x4wN47*PYCukl#rO~kT(`Vr?zF#}u8@7Mud7Q)g{JeP2
z?Q4k>-m#iq<$7nZ;H1umuhZqXx=y}1MR3x(j`{m8ERT7n{k`DUjr=nuGLJ=l!*Vyy
zo4&Q`ztxvH+%;)@&$-UIN^L1Pmt3>xf$}l-9>wPpH_pDfUM+|nOtk;V>p5@xFSa95
RQ-SG=!PC{xWt~$(699CiDd+$I
index b99f460e303a2ce3688e628171d5aaefcd5b3780..ccbc1767da1d0851169a248e7e01388532242bf8
GIT binary patch
literal 581
zc%17D@N?(olHy`uVBq!ia0vp^PCy*a!3HEZ_)k6nq}Y<Y-CY>|gW!U_%O?XxI14-?
ziy0WWg+Z8+Vb&Z8pdfpRr>`sf17<Nneyx?Q_un%xFkbR>aSW-r_4d|AFXcdy_K)v<
zShRK7bnZlbkgQ?kT(bS*F+cNn`y6%7NV_y;9?|ms)$oJK&iR0qrpRyIFL@6)Y)DeJ
zEPuE6!~d@{6YcoQpPf6B5h<~m{fhx#gZu%#4a_00Wv<t4+I#N3dDPAylCp7@&wAq}
zey3zCZsmOO>5M?KymItKrxu5!E!s@`H65l1Je;PRw}Y8a(?v8@=-(7kMK2Dun5kk1
zZCxZT>!$j4h$)?U!QjmG)Lc-1&XZp~7xFp|u(Gb6bFFRfq~!;Tm8wL}sUJDc=A}3*
zc8QZobY?5Vt0UJx&hzfLenfFzG3Q1vnWU-S9jT85PTM%K$W2Zwu-Jdy<&3cZ5u0v-
zYtK6mC|xwsS#v(vZuUC9>z+;)bFb^WoZa$uC5O(6nQ5Yj`YM>&bk84NV43eG{BdL0
zo#dV<&xD>e=l9-!xM#U(K;hw@9r^|G7SB)Q{B?h=A<UR?%zE}5CjN^`IYqA|6;HYR
zF_*j-zpF<uJ*LXNL+Vld1jE>jeYNX~%NrV>&$>{>krDTGe}U73N70T<+*UEc#$R_?
ztMGozblUf;-sNu2)9?eEZd#wQohRYyf)l8e*E9Y-pPgMf$Lazw!WleW{an^LB{Ts5
Dzy$Ca
index 88e676b0a5b3ca88a0f4155b5e34abb21923a518..a637c37469cdabaa1609b38f4746b50d1a2ed4d6
GIT binary patch
literal 707
zc%17D@N?(olHy`uVBq!ia0vp^5kOqR!3HE5e}An6Qfx`y?k)`fL2$v|<&%LToCO|{
z#S9GG!XV7ZFl&wkP>{XE)7O>#0kfE(giP|Da}O98m;yXq978H@y}iBLPb5&L;p2M-
zeh1eLhYz_MToBZ9N<7RRa7y)yNI+uaHnuX&O2+LsST^?_;d`tc;lRF7$<2CN^1&lV
z1i1D8&$QfqfBnMdhqpHuTNe8%dDuP5KBNE4x>NhXu3wd$JQ2@}y;7poM8$u6&ua)a
z+PmfG%tqzZb2F3l_dc22nEX|8)9h_KGhQ*MN#yF!c#>?Fa`HgoGS4rc1YfiD{k6aU
zgl|2sO!ddxE2>rc!ymA-m(H7f-0%ZS=j1=rmb3Nk`QZEM)PaNB3x3Y1WOm<`eE-14
z^BcJ@itqWz_b1`%fsKnRRG;s#lw>~46FX1Z;@YOh<g=0%LC-pYwA2m3KPR>~<y|f6
zY+TPRb@)1)*@wmJ?}j=q?^rG5{vp*-`NyUs2b!Ch_#f?)2`smp>C&!$z~}Vidm9dJ
zUQwExsBEEXH1j)a&%MhbFLI@PE@(ecTr56Y;nSl%2ic9D>-JU4?G-Zq7g*V~_Zr)}
zhc^x}hkJYc);)83eR5^C?POc$vc}tDNB6Gn>zv3jXWLGN&1`qJE4;Ivxpmhyp6h0@
zk+D8yQ34gq&*`#lJ;%P>cbAG4?>WnoOKNwk{-+3+yYqW{@MryLc;7g8*JCp=2?hC0
zE{|Ad@a<oadTrLN$}2at7jHQ#=`7Zq|L_-2WKL0Zc(U@3=@)Cx*p$DS9PuOGKSOzc
z@9Xvc(i^;zTJ7hz?p~=j{a)`;w<Gmjb3LyXElW*USM}QZ_vg3yj?tN_o|9Avp(SQN
a7&lLu&DwWXT^yL)7(8A5T-G@yGywqj5Ifia
index a320624923b4e1bec33282d3d45fd0671e2f951e..95b85bc23252ef3ad903343d22cc07f20d87d3c2
GIT binary patch
literal 890
zc%17D@N?(olHy`uVBq!ia0vp^5kOqR!3HE5e}An6Qfx`y?k)`fL2$v|<&%LToCO|{
z#S9GG!XV7ZFl&wkP>{XE)7O>#0kfE(xaE2UwhRUaW_C{($B>F!Z|~T9geFQH|M<Rm
zUEx~C<2q4FJyJ^T-xOMwl$EX!b6hWUB`D&stkfb?j{hvWnjTIUCIvYwne2KIr1DT`
zQJ&hAN%yudbdL7^a__U<^84w<A8nqWF1}xVwc2zJNAewZx6k_WH#zui&*btwex$j{
zviG&xCd<CpN}DYEXV;`?JKkBM9PRl?P5P#ni22DAVVicGbloO&%vWpERFgcTiEED(
zPVUH2?T@ZV`OW`GOv+MQ;oV!NM@&7>uIs&LYcJHhoFJzCB7=*?s491c`BH9~c?Gk2
z${nH)>c{N6^kCB6C&8J09R{A(qG>`M+!x$hXBym{6FIAx&3uRR0^_;!PrgiP-@VjA
z>5y^X=Ho^Is=C!7mif$Ue&2f#`%YCte8H^q&&rz*t1Q2f&bipBi-~<p!NIm4Z;G4w
zUwE6nTmN-cfy4ag%8S~+eBHq#v&@36#FFFA)bp=$n#*Hy^1nnrW;HvvlT)VfT1BID
z$qop^xNjGP(Y%<KPtIH->f_?8kJ<9AZawhrm)`R*quKlq>p2<Lb2e;zYb>pluG!>$
zh<2Bj$-TB?|2mn+$^|wb_q;YcB-!s@#PFH#Te;o4X60pE`jgK~R~}o@nA`FFfYYH$
z@lTQ;t6Uu1Y#*v`ulnQdl4d%s@g~dXbI&&a<l}LeYPDf%z@4=<YF>gj*do*y&p(jI
zWFPWo(na~-e+ACoeGu<{R7jUcOm0W3<LZr9_9XA?%;^XZcriK7cK*W3?=dG9m&|$1
zBFC)jyX%SFF<t4jRX4v!-ErrbS2R)HCwYszUGa9yBc{?R8)t+!v2Jt}Gu+iUwPSkZ
z!54a3c?+fgR3BtI#!}1j>Og1xm)!jcHVck%8#_(>KK-1!?ei6z+<131{(V~A$-MeN
zv7b?5_zmxe`!4<2@|R_<$qugvTm|3ypO+*^|7b9DKbF0E<E&eIfBcwkv{@!?GGE`5
r`ndnc*xYY!h});EL^KGpvuAv`;NqTFA6DiAa|wf|tDnm{r-UW|EW3dw
index 1fae29a023e7c7aabb2a9c4ea0abb3122ee6e977..05532b2393be2fd4062e6173aa414d467516e46d
GIT binary patch
literal 725
zc%17D@N?(olHy`uVBq!ia0vp^5kOqR!3HE5e}An6Qfx`y?k)`fL2$v|<&%LToCO|{
z#S9GG!XV7ZFl&wkP>{XE)7O>#0kfE(AkX@fsb?7&n36nQ978H@y}iBBD>_i7;p2Ok
z?M^RMcD=mduCc3g@|Fhy@e0Yp-MgLVmSm(~d~rj3h4X@=OJjq7ambbg&ylf`@^6pf
z64DT<dS|Qt{kLx3$%l{U7x$a*Y~(rW@nDMh0ksW2DVz~1n_M1vc75luw(<SA>vLqQ
zZCdkzY_>g2V#~Ax=IAgTHxAqRyy1Y9@v@5tK6Z#cvq(7;)_Hkm>4Plm9~?HxZKYD3
z)lU5fey%y5o6_{Sf#2oV7MJDUPUZ&QiDEvUY}W97i;r&OV?ph!tZS1Wh&;2%In26t
z;(?Dnp$Xm7yzCEtm${JS$sGRQXN{!A+}tR=$OjUfEOAm6Q&0ZA!^*sTbLS+sIJ5jy
z2PUtt<eBqftN7X1EaEa20^F}YtUjXqXxV}T=4@sku80-J`rK>D_O(11&g%Oiw6js4
zi>qehyTcP-O%dbd`QiERyK>&k&o(MnUsYy55a@8(Ct|R7zVjjF)LXv~%AVVNUi$Jn
z!^{U4o@-AGJXYxbQ1^ZpFV6v+-#6LTCFY)FZa1%CZ<Bl`8<wqYZNQLxkFi|x=-$=W
zj`*<f?T!(8$;2;tq*mb)@9vm$J_!x4k~U3ue6aV5_%=5t<`!8$P9CW}&kZ=N=Un?e
z&38_p-wVT^OH-F`%Vqrbs_%A0!iukgP1Z_xt+uxw+t#ttw(?BXBjr023}5>m%`It?
z_x=38u1DJ_Y2II}b30vG>bB$_DEm5jb^TuM^60xUzpgwvk|z7Z|HXb=FYCy!rUk)&
r_cH0d_^3SPtLthN&q)MP$n5+2k9v2_56e#hCO`&HS3j3^P6<r_G<rYG
--- a/mobile/android/base/resources/xml-v11/preference_headers.xml
+++ b/mobile/android/base/resources/xml-v11/preference_headers.xml
@@ -33,16 +33,23 @@
     <header android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment"
             android:title="@string/pref_header_accessibility"
             android:id="@+id/pref_header_accessibility">
         <extra android:name="resource"
                android:value="preferences_accessibility"/>
     </header>
 
     <header android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment"
+        android:title="@string/pref_header_notifications"
+        android:id="@+id/pref_header_notifications">
+        <extra android:name="resource"
+            android:value="preferences_notifications"/>
+    </header>
+
+    <header android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment"
             android:title="@string/pref_header_advanced"
             android:id="@+id/pref_header_advanced">
         <extra android:name="resource"
                android:value="preferences_advanced"/>
     </header>
 
     <header android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment"
         android:title="@string/pref_clear_private_data_now"
--- a/mobile/android/base/resources/xml-v11/preferences.xml
+++ b/mobile/android/base/resources/xml-v11/preferences.xml
@@ -42,16 +42,22 @@
 
     <PreferenceScreen android:title="@string/pref_category_accessibility"
                       android:summary="@string/pref_category_accessibility_summary"
                       android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment" >
         <extra android:name="resource"
                android:value="preferences_accessibility" />
     </PreferenceScreen>
 
+    <PreferenceScreen android:title="@string/pref_category_notifications"
+        android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment">
+        <extra android:name="resource"
+            android:value="preferences_notifications"/>
+    </PreferenceScreen>
+
     <PreferenceScreen android:title="@string/pref_category_advanced"
                       android:summary="@string/pref_category_advanced_summary"
                       android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment"
                       android:key="android.not_a_preference.advanced_screen" >
         <extra android:name="resource"
                android:value="preferences_advanced"/>
     </PreferenceScreen>
 
--- a/mobile/android/base/resources/xml/preference_headers.xml
+++ b/mobile/android/base/resources/xml/preference_headers.xml
@@ -6,16 +6,19 @@
 <!-- This file is a stub to allow IDs to be used in code
      even for a version-limited build. -->
 
 <preference-headers xmlns:android="http://schemas.android.com/apk/res/android">
     <header android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment"
             android:id="@+id/pref_header_search">
     </header>
     <header android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment"
+            android:id="@+id/pref_header_notifications">
+    </header>
+    <header android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment"
             android:id="@+id/pref_header_advanced">
     </header>
     <header android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment"
             android:id="@+id/pref_header_accessibility">
     </header>
     <header android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment"
             android:id="@+id/pref_header_clear_private_data">
     </header>
--- a/mobile/android/base/resources/xml/preferences.xml
+++ b/mobile/android/base/resources/xml/preferences.xml
@@ -58,16 +58,26 @@
                 android:targetPackage="@string/android_package_name"
                 android:targetClass="org.mozilla.gecko.preferences.GeckoPreferences" >
             <extra
                     android:name="resource"
                     android:value="preferences_accessibility" />
         </intent>
     </PreferenceScreen>
 
+    <PreferenceScreen android:title="@string/pref_category_notifications">
+        <intent android:action="android.intent.action.VIEW"
+            android:targetPackage="@string/android_package_name"
+            android:targetClass="org.mozilla.gecko.preferences.GeckoPreferences" >
+            <extra
+                android:name="resource"
+                android:value="preferences_notifications" />
+        </intent>
+    </PreferenceScreen>
+
     <PreferenceScreen android:title="@string/pref_category_advanced"
                       android:summary="@string/pref_category_advanced_summary"
                       android:key="android.not_a_preference.advanced.enabled" >
         <intent android:action="android.intent.action.VIEW"
                 android:targetPackage="@string/android_package_name"
                 android:targetClass="org.mozilla.gecko.preferences.GeckoPreferences" >
             <extra
                     android:name="resource"
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/xml/preferences_notifications.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
+    <CheckBoxPreference android:key="android.not_a_preference.notifications.content"
+        android:title="@string/pref_content_notifications"
+        android:summary="@string/pref_content_notifications_summary"
+        android:defaultValue="true" />
+</PreferenceScreen>
\ No newline at end of file
--- a/mobile/android/base/strings.xml.in
+++ b/mobile/android/base/strings.xml.in
@@ -159,16 +159,20 @@
   <string name="locale_system_default">&locale_system_default;</string>
 
   <string name="pref_category_advanced">&pref_category_advanced;</string>
   <string name="pref_category_advanced_summary">&pref_category_advanced_summary2;</string>
   <string name="pref_developer_remotedebugging_usb">&pref_developer_remotedebugging_usb;</string>
   <string name="pref_developer_remotedebugging_wifi">&pref_developer_remotedebugging_wifi;</string>
   <string name="pref_developer_remotedebugging_wifi_disabled_summary">&pref_developer_remotedebugging_wifi_disabled_summary;</string>
 
+  <string name="pref_category_notifications">&pref_category_notifications;</string>
+  <string name="pref_content_notifications">&pref_content_notifications;</string>
+  <string name="pref_content_notifications_summary">&pref_content_notifications_summary;</string>
+
   <string name="pref_category_home">&pref_category_home;</string>
   <string name="pref_category_home_summary">&pref_category_home_summary;</string>
   <string name="pref_category_home_panels">&pref_category_home_panels;</string>
   <string name="pref_home_updates_wifi">&pref_home_updates_wifi;</string>
   <string name="pref_category_home_add_ons">&pref_category_home_add_ons;</string>
   <string name="pref_home_updates">&pref_home_updates2;</string>
   <string name="pref_home_updates_enabled">&pref_home_updates_enabled;</string>
   <string name="pref_category_home_homepage">&pref_category_home_homepage;</string>
@@ -176,16 +180,17 @@
   <string name="home_homepage_radio_default">&home_homepage_radio_default;</string>
   <string name="home_homepage_radio_user_address">&home_homepage_radio_user_address;</string>
   <string name="home_homepage_hint_user_address">&home_homepage_hint_user_address;</string>
 
   <string name="pref_header_general">&pref_header_general;</string>
   <string name="pref_header_search">&pref_header_search;</string>
   <string name="pref_header_accessibility">&pref_header_accessibility;</string>
   <string name="pref_header_privacy_short">&pref_header_privacy_short;</string>
+  <string name="pref_header_notifications">&pref_header_notifications;</string>
   <string name="pref_header_advanced">&pref_header_advanced;</string>
   <string name="pref_header_vendor">&pref_header_vendor;</string>
 
   <string name="pref_learn_more">&pref_learn_more;</string>
 
   <string name="pref_remember_signons">&pref_remember_signons2;</string>
 
   <string name="pref_manage_logins">&pref_manage_logins;</string>
@@ -280,16 +285,21 @@
   <string name="tab_queue_prompt_settings_button">&tab_queue_prompt_settings_button;</string>
   <string name="tab_queue_toast_message">&tab_queue_toast_message3;</string>
   <string name="tab_queue_toast_action">&tab_queue_toast_action;</string>
   <string name="tab_queue_notification_text_singular">&tab_queue_notification_text_singular2;</string>
   <string name="tab_queue_notification_text_plural">&tab_queue_notification_text_plural2;</string>
   <string name="tab_queue_notification_title">&tab_queue_notification_title;</string>
   <string name="tab_queue_notification_settings">&tab_queue_notification_settings;</string>
 
+  <string name="content_notification_summary">&content_notification_summary;</string>
+  <string name="content_notification_title_plural">&content_notification_title_plural;</string>
+  <string name="content_notification_action_settings">&content_notification_action_settings;</string>
+  <string name="content_notification_updated_on">&content_notification_updated_on;</string>
+
   <string name="pref_about_firefox">&pref_about_firefox;</string>
   <string name="pref_vendor_faqs">&pref_vendor_faqs;</string>
   <string name="pref_vendor_feedback">&pref_vendor_feedback;</string>
 
   <string name="pref_dialog_set_default">&pref_dialog_set_default;</string>
   <string name="pref_default">&pref_dialog_default;</string>
   <string name="pref_dialog_remove">&pref_dialog_remove;</string>
 
--- a/mobile/android/components/HelperAppDialog.js
+++ b/mobile/android/components/HelperAppDialog.js
@@ -3,29 +3,35 @@
  * 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/. */
 
 /*globals ContentAreaUtils */
 
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 const APK_MIME_TYPE = "application/vnd.android.package-archive";
+
 const OMA_DOWNLOAD_DESCRIPTOR_MIME_TYPE = "application/vnd.oma.dd+xml";
+const OMA_DRM_MESSAGE_MIME = "application/vnd.oma.drm.message";
+const OMA_DRM_CONTENT_MIME = "application/vnd.oma.drm.content";
+const OMA_DRM_RIGHTS_MIME = "application/vnd.oma.drm.rights+wbxml";
+
 const PREF_BD_USEDOWNLOADDIR = "browser.download.useDownloadDir";
 const URI_GENERIC_ICON_DOWNLOAD = "drawable://alert_download";
 
 Cu.import("resource://gre/modules/Downloads.jsm");
 Cu.import("resource://gre/modules/FileUtils.jsm");
 Cu.import("resource://gre/modules/HelperApps.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/NetUtil.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "RuntimePermissions", "resource://gre/modules/RuntimePermissions.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Messaging", "resource://gre/modules/Messaging.jsm");
 
 // -----------------------------------------------------------------------
 // HelperApp Launcher Dialog
 // -----------------------------------------------------------------------
 
 XPCOMUtils.defineLazyModuleGetter(this, "Snackbars", "resource://gre/modules/Snackbars.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "ContentAreaUtils", function() {
@@ -96,22 +102,51 @@ HelperAppLauncherDialog.prototype = {
    */
   _shouldAddSaveToDiskIntent: function(launcher) {
       let mimeType = this._getMimeTypeFromLauncher(launcher);
 
       // We can't handle OMA downloads. So don't even try. (Bug 1219078)
       return mimeType != OMA_DOWNLOAD_DESCRIPTOR_MIME_TYPE;
   },
 
+  /**
+   * Returns true if `launcher`represents a download that should not be handled by Firefox
+   * or a third-party app and instead be forwarded to Android's download manager.
+   */
+  _shouldForwardToAndroidDownloadManager: function(aLauncher) {
+    let forwardDownload = Services.prefs.getBoolPref('browser.download.forward_oma_android_download_manager');
+    if (!forwardDownload) {
+      return false;
+    }
+
+    let mimeType = aLauncher.MIMEInfo.MIMEType;
+    if (!mimeType) {
+      mimeType = ContentAreaUtils.getMIMETypeForURI(aLauncher.source) || "";
+    }
+
+    return [
+      OMA_DOWNLOAD_DESCRIPTOR_MIME_TYPE,
+      OMA_DRM_MESSAGE_MIME,
+      OMA_DRM_CONTENT_MIME,
+      OMA_DRM_RIGHTS_MIME
+    ].indexOf(mimeType) != -1;
+  },
+
   show: function hald_show(aLauncher, aContext, aReason) {
     if (!this._canDownload(aLauncher.source)) {
       this._refuseDownload(aLauncher);
       return;
     }
 
+    if (this._shouldForwardToAndroidDownloadManager(aLauncher)) {
+      this._downloadWithAndroidDownloadManager(aLauncher);
+      aLauncher.cancel(Cr.NS_BINDING_ABORTED);
+      return;
+    }
+
     let bundle = Services.strings.createBundle("chrome://browser/locale/browser.properties");
 
     let defaultHandler = new Object();
     let apps = HelperApps.getAppsForUri(aLauncher.source, {
       mimeType: aLauncher.MIMEInfo.MIMEType,
     });
 
     if (this._shouldAddSaveToDiskIntent(aLauncher)) {
@@ -197,16 +232,30 @@ HelperAppLauncherDialog.prototype = {
     }
 
     Services.console.logStringMessage("Refusing download of non-downloadable file.");
     let bundle = Services.strings.createBundle("chrome://browser/locale/handling.properties");
     let failedText = bundle.GetStringFromName("download.blocked");
     win.toast.show(failedText, "long");
   },
 
+  _downloadWithAndroidDownloadManager(aLauncher) {
+    let mimeType = aLauncher.MIMEInfo.MIMEType;
+    if (!mimeType) {
+      mimeType = ContentAreaUtils.getMIMETypeForURI(aLauncher.source) || "";
+    }
+
+    Messaging.sendRequest({
+      'type': 'Download:AndroidDownloadManager',
+      'uri': aLauncher.source.spec,
+      'mimeType': mimeType,
+      'filename': aLauncher.suggestedFileName
+    });
+  },
+
   _getPrefName: function getPrefName(mimetype) {
     return "browser.download.preferred." + mimetype.replace("\\", ".");
   },
 
   _getMimeTypeFromLauncher: function (launcher) {
     let mime = launcher.MIMEInfo.MIMEType;
     if (!mime)
       mime = ContentAreaUtils.getMIMETypeForURI(launcher.source) || "";
--- a/mobile/android/docs/uitelemetry.rst
+++ b/mobile/android/docs/uitelemetry.rst
@@ -219,16 +219,19 @@ Methods
   Action triggered from the main menu.
 
 ``notification``
   Action triggered from a system notification.
 
 ``pageaction``
   Action triggered from a pageaction, displayed in the URL bar.
 
+``service``
+  Action triggered from an automatic system making a decision.
+
 ``settings``
   Action triggered from a content page.
 
 ``shareoverlay``
   Action triggered from a content page.
 
 ``suggestion``
   Action triggered from a suggested result, like those from search engines or default tiles.
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/resources/feed_atom_blogger.xml
@@ -0,0 +1,13 @@
+<?xml version='1.0' encoding='UTF-8'?><?xml-stylesheet href="http://www.blogger.com/styles/atom.css" type="text/css"?><feed xmlns='http://www.w3.org/2005/Atom' xmlns:openSearch='http://a9.com/-/spec/opensearchrss/1.0/' xmlns:blogger='http://schemas.google.com/blogger/2008' xmlns:georss='http://www.georss.org/georss' xmlns:gd="http://schemas.google.com/g/2005" xmlns:thr='http://purl.org/syndication/thread/1.0'><id>tag:blogger.com,1999:blog-18929277</id><updated>2016-02-18T09:07:17.583-08:00</updated><category term="jetpack"/><title type='text'>mykzilla</title><subtitle type='html'></subtitle><link rel='http://schemas.google.com/g/2005#feed' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/posts/default'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/'/><link rel='hub' href='http://pubsubhubbub.appspot.com/'/><link rel='next' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default?start-index=26&amp;max-results=25'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><generator version='7.00' uri='http://www.blogger.com'>Blogger</generator><openSearch:totalResults>114</openSearch:totalResults><openSearch:startIndex>1</openSearch:startIndex><openSearch:itemsPerPage>25</openSearch:itemsPerPage><entry><id>tag:blogger.com,1999:blog-18929277.post-3538029308224239292</id><published>2016-01-11T08:57:00.001-08:00</published><updated>2016-01-11T08:57:31.366-08:00</updated><title type='text'>URL Has Been Changed</title><content type='html'>&lt;dl&gt;&lt;dd&gt;The URL you have reached, &lt;a href=&quot;http://mykzilla.blogspot.com/&quot;&gt;http://mykzilla.blogspot.com/&lt;/a&gt;, has been changed.  The new URL is &lt;a href=&quot;https://mykzilla.org/&quot;&gt;https://mykzilla.org/&lt;/a&gt;. Please make a note of it.&lt;/dd&gt;&lt;/dl&gt;</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/3538029308224239292/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=3538029308224239292' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/3538029308224239292'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/3538029308224239292'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2016/01/url-has-been-changed.html' title='URL Has Been Changed'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/08518329693863067865</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='http://img2.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-7658939959003799797</id><published>2015-06-23T16:05:00.000-07:00</published><updated>2015-06-23T16:07:06.667-07:00</updated><title type='text'>Introducing PluotSorbet</title><content type='html'>&lt;a href=&quot;https://github.com/mozilla/pluotsorbet&quot;&gt;PluotSorbet&lt;/a&gt; is a &lt;a href=&quot;https://en.wikipedia.org/wiki/Java_Platform,_Micro_Edition&quot;&gt;J2ME&lt;/a&gt;-compatible virtual machine written in JavaScript. Its goal is to enable users you run J2ME apps (i.e. &lt;a href=&quot;https://en.wikipedia.org/wiki/MIDlet&quot;&gt;MIDlets&lt;/a&gt;) in web apps without a native plugin. It does this by interpreting Java bytecode and compiling it to JavaScript code. It also provides a virtual filesystem (via &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API&quot;&gt;IndexedDB&lt;/a&gt;), network sockets (through the &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/TCPSocket&quot;&gt;TCPSocket API&lt;/a&gt;), and other common J2ME APIs, like &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Contacts_API&quot;&gt;Contacts&lt;/a&gt;.&lt;br /&gt;&lt;br /&gt;The project reuses as much existing code as possible, to minimize its surface area and maximize its compatibility with other J2ME implementations. It incorporates the &lt;a href=&quot;https://java.net/projects/phoneme&quot;&gt;PhoneME&lt;/a&gt; reference implementation, numerous tests from &lt;a href=&quot;https://www.sourceware.org/mauve/&quot;&gt;Mauve&lt;/a&gt;, and a variety of JavaScript libraries (including &lt;a href=&quot;http://www-cs-students.stanford.edu/%7Etjw/jsbn/&quot;&gt;jsbn&lt;/a&gt;, &lt;a href=&quot;https://github.com/digitalbazaar/forge&quot;&gt;Forge&lt;/a&gt;, and &lt;a href=&quot;https://github.com/eligrey/FileSaver.js&quot;&gt;FileSaver.js&lt;/a&gt;). The virtual machine is originally based on &lt;a href=&quot;https://github.com/YaroslavGaponov/node-jvm&quot;&gt;node-jvm&lt;/a&gt;.&lt;br /&gt;&lt;br /&gt;PluotSorbet  makes it possible to bring J2ME apps to Firefox OS. J2ME may be a moribund platform, but it still has &lt;a href=&quot;http://netmarketshare.com/operating-system-market-share.aspx?qprid=9&amp;amp;qpcustom=Java+ME&amp;amp;qpcustomb=1&quot;&gt;non-negligible market share&lt;/a&gt;,  not to mention a number of useful apps. So it retains residual  value, which PluotSorbet can extend to Firefox OS devices.&lt;br /&gt;&lt;br /&gt;PluotSorbet is also still under development, with a variety of issues to address. To learn more about PluotSorbet, check out its &lt;a href=&quot;https://github.com/mozilla/pluotsorbet/blob/master/README.md&quot;&gt;README&lt;/a&gt;, clone its &lt;a href=&quot;https://github.com/mozilla/pluotsorbet&quot;&gt;Git repository&lt;/a&gt;, peruse its &lt;a href=&quot;https://github.com/mozilla/pluotsorbet/issues&quot;&gt;issue tracker&lt;/a&gt;, and say hello to its developers in &lt;a href=&quot;irc://irc.mozilla.org/pluotsorbet&quot;&gt;irc.mozilla.org#pluotsorbet&lt;/a&gt;!&lt;br /&gt;&lt;br /&gt;</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/7658939959003799797/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=7658939959003799797' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7658939959003799797'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7658939959003799797'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2015/06/introducing-pluotsorbet.html' title='Introducing PluotSorbet'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/08518329693863067865</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='http://img2.blogblog.com/img/b16-rounded.gif'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-2146007198307190404</id><published>2014-03-28T16:58:00.002-07:00</published><updated>2014-03-28T16:58:42.947-07:00</updated><title type='text'>simplify asynchronous method declarations with Task.async()</title><content type='html'>In Mozilla code, &lt;span style=&quot;font-family: &amp;quot;Courier New&amp;quot;,Courier,monospace;&quot;&gt;Task.spawn()&lt;/span&gt; is becoming a common way to implement asynchronous operations, especially methods like the &lt;span style=&quot;font-family: &amp;quot;Courier New&amp;quot;,Courier,monospace;&quot;&gt;greet&lt;/span&gt; method in this &lt;span style=&quot;font-family: &amp;quot;Courier New&amp;quot;,Courier,monospace;&quot;&gt;greeter&lt;/span&gt; object:&lt;br /&gt;&lt;br /&gt;&lt;div style=&quot;background: #202020; border-width: .1em .1em .1em .8em; border: solid gray; overflow: auto; padding: .2em .6em; width: auto;&quot;&gt;&lt;pre style=&quot;line-height: 125%; margin: 0;&quot;&gt;&lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;let&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;greeter&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;=&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;{&lt;/span&gt;&lt;br /&gt;  &lt;span style=&quot;color: #d0d0d0;&quot;&gt;message:&lt;/span&gt; &lt;span style=&quot;color: #ed9d13;&quot;&gt;&quot;Hello, NAME!&quot;&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;,&lt;/span&gt;&lt;br /&gt;  &lt;span style=&quot;color: #d0d0d0;&quot;&gt;greet:&lt;/span&gt; &lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;(name)&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;{&lt;/span&gt;&lt;br /&gt;    &lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;return&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;Task.spawn((&lt;/span&gt;&lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;*()&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;{&lt;/span&gt;&lt;br /&gt;      &lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;return&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;yield&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;sendGreeting(&lt;/span&gt;&lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;.message.replace(&lt;/span&gt;&lt;span style=&quot;color: #ed9d13;&quot;&gt;/NAME/&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;,&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;name));&lt;/span&gt;&lt;br /&gt;    &lt;span style=&quot;color: #d0d0d0;&quot;&gt;}).bind(&lt;/span&gt;&lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;);&lt;/span&gt;&lt;br /&gt;  &lt;span style=&quot;color: #d0d0d0;&quot;&gt;})&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;};&lt;/span&gt;&lt;br /&gt;&lt;/pre&gt;&lt;/div&gt;&lt;br /&gt;&lt;span style=&quot;font-family: &amp;quot;Courier New&amp;quot;,Courier,monospace;&quot;&gt;Task.spawn()&lt;/span&gt; makes the operation logic simple, but the wrapper function and &lt;span style=&quot;font-family: &amp;quot;Courier New&amp;quot;,Courier,monospace;&quot;&gt;bind()&lt;/span&gt; call required to start the task on method invocation and bind its &lt;span style=&quot;font-family: &amp;quot;Courier New&amp;quot;,Courier,monospace;&quot;&gt;this&lt;/span&gt; reference make the overall implementation complex.&lt;br /&gt;&lt;br /&gt;Enter &lt;span style=&quot;font-family: &amp;quot;Courier New&amp;quot;,Courier,monospace;&quot;&gt;Task.async()&lt;/span&gt;.&lt;br /&gt;&lt;br /&gt;Like &lt;span style=&quot;font-family: &amp;quot;Courier New&amp;quot;,Courier,monospace;&quot;&gt;Task.spawn()&lt;/span&gt;, it creates a task, but it doesn&#39;t immediately start it. Instead, it returns an &quot;async function&quot; whose invocation starts the task, and the async function binds the task to its own &lt;span style=&quot;font-family: &amp;quot;Courier New&amp;quot;,Courier,monospace;&quot;&gt;this&lt;/span&gt; reference at invocation time. That makes it simpler to declare the method:&lt;br /&gt;&lt;br /&gt;&lt;!-- HTML generated using hilite.me --&gt; &lt;div style=&quot;background: #202020; border-width: .1em .1em .1em .8em; border: solid gray; overflow: auto; padding: .2em .6em; width: auto;&quot;&gt;&lt;pre style=&quot;line-height: 125%; margin: 0;&quot;&gt;&lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;let&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;greeter&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;=&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;{&lt;/span&gt;&lt;br /&gt;  &lt;span style=&quot;color: #d0d0d0;&quot;&gt;message:&lt;/span&gt; &lt;span style=&quot;color: #ed9d13;&quot;&gt;&quot;Hello, NAME!&quot;&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;,&lt;/span&gt;&lt;br /&gt;  &lt;span style=&quot;color: #d0d0d0;&quot;&gt;greet:&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;Task.async(&lt;/span&gt;&lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;function&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;*(name)&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;{&lt;/span&gt;&lt;br /&gt;    &lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;return&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;yield&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;sendGreeting(&lt;/span&gt;&lt;span style=&quot;color: #6ab825; font-weight: bold;&quot;&gt;this&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;.message.replace(&lt;/span&gt;&lt;span style=&quot;color: #ed9d13;&quot;&gt;/NAME/&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;,&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;name));&lt;/span&gt;&lt;br /&gt;  &lt;span style=&quot;color: #d0d0d0;&quot;&gt;})&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;};&lt;/span&gt;&lt;br /&gt;&lt;/pre&gt;&lt;/div&gt;&lt;br /&gt;With identical semantics:&lt;br /&gt;&lt;br /&gt;&lt;div style=&quot;background: #202020; border-width: .1em .1em .1em .8em; border: solid gray; overflow: auto; padding: .2em .6em; width: auto;&quot;&gt;&lt;pre style=&quot;line-height: 125%; margin: 0;&quot;&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;greeter.greet(&lt;/span&gt;&lt;span style=&quot;color: #ed9d13;&quot;&gt;&quot;Mitchell&quot;&lt;/span&gt;&lt;span style=&quot;color: #d0d0d0;&quot;&gt;).then((reply)&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;{&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;...&lt;/span&gt; &lt;span style=&quot;color: #d0d0d0;&quot;&gt;});&lt;/span&gt; &lt;span style=&quot;color: #999999; font-style: italic;&quot;&gt;// behaves the same&lt;/span&gt;&lt;br /&gt;&lt;/pre&gt;&lt;/div&gt;&lt;br /&gt;(And it avoids a couple anti-patterns in the process.)&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;font-family: &amp;quot;Courier New&amp;quot;,Courier,monospace;&quot;&gt;Task.async()&lt;/span&gt; is inspired by ECMAScript&#39;s &lt;a href=&quot;http://wiki.ecmascript.org/doku.php?id=strawman:async_functions&quot;&gt;Async Functions strawman proposal&lt;/a&gt; and &lt;a href=&quot;http://msdn.microsoft.com/en-us/library/hh191443.aspx&quot;&gt;C#&#39;s Async modifier&lt;/a&gt; and was implemented in &lt;a href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=966182&quot;&gt;bug 966182&lt;/a&gt;. It isn&#39;t limited to use in method declarations, although it&#39;s particularly helpful for them.&lt;br /&gt;&lt;br /&gt;Use it to implement your next asynchronous operation!&lt;br /&gt;&lt;br /&gt;</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/2146007198307190404/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=2146007198307190404' title='2 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/2146007198307190404'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/2146007198307190404'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2014/03/simplify-asynchronous-method.html' title='simplify asynchronous method declarations with Task.async()'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/08518329693863067865</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='http://img2.blogblog.com/img/b16-rounded.gif'/></author><thr:total>2</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-7603765594353319606</id><published>2014-03-27T13:14:00.000-07:00</published><updated>2014-03-27T13:14:20.326-07:00</updated><title type='text'>qualifications for leadership</title><content type='html'>I&#39;ve been surprised by the negative reaction to Brendan&#39;s promotion by  some of my fellow supporters of marriage equality. Perhaps I take it too  much for granted that Mozillians recognize the diversity of their community in every possible  respect, including politically and religiously, and that the &lt;span style=&quot;font-style: italic;&quot;&gt;only&lt;/span&gt; thing we share in common is our  commitment to Mozilla&#39;s mission and the principles for participation.&lt;br /&gt;&lt;br /&gt;Those principles are reflected in our &lt;a href=&quot;http://www.mozilla.org/en-US/about/governance/policies/participation/&quot;&gt;Community  Participation Agreement&lt;/a&gt;, to which Brendan has always shown fealty  (since long before it was formalized, in my 15-year experience with  him), and which could not possibly be clearer about the welcoming nature  of Mozilla to all constructive contributors.&lt;br /&gt;  &lt;br /&gt;I know that marriage equality has been a long, difficult, and painful  battle, the kind that rubs nerves raw and makes it challenging to show  any charity to its opponents. But they aren&#39;t all bigots, and I take Brendan at his &lt;a href=&quot;https://brendaneich.com/2014/03/inclusiveness-at-mozilla/&quot;&gt;word and deed&lt;/a&gt; that he&#39;s as committed as I am to the community&#39;s inclusive ideals (and the organization&#39;s employment policies).&lt;br /&gt;  &lt;br /&gt;As Andrew Sullivan eloquently states in his recent blog post on &lt;a href=&quot;http://dish.andrewsullivan.com/2014/03/24/religious-belief-and-bigotry/&quot;&gt;Religious  Belief and Bigotry&lt;/a&gt;:&lt;br /&gt;&lt;br /&gt;&lt;div style=&quot;margin-left: 40px;&quot;&gt;&quot;Twenty years ago, I was confidently  told by my leftist gay friends that Americans were all anti-gay bigots  and would never, ever back marriage rights so I should stop trying to  reason them out of their opposition. My friends were wrong. Americans  are not all bigots. Not even close. They can be persuaded rather than  attacked. And if we behave magnanimously and give maximal space for  those who sincerely oppose us, then eventual persuasion will be more  likely. And our victory more moral and more enduring.&quot;   &lt;/div&gt;&lt;br /&gt;I&#39;m chastened to admit that I substantially shared his friends&#39; opinion  twenty years ago. But I&#39;m happy to realize I was wrong. And perhaps Brendan will one day do the same. Either way, he  qualifies to be a leader at any level in the Mozilla community (and organization), as do the many other Mozilla  leaders whose beliefs undoubtedly differ sharply from my own.&lt;br /&gt;&lt;br /&gt;</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/7603765594353319606/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=7603765594353319606' title='21 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7603765594353319606'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7603765594353319606'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2014/03/qualifications-for-leadership.html' title='qualifications for leadership'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/08518329693863067865</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='http://img2.blogblog.com/img/b16-rounded.gif'/></author><thr:total>21</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-4907835732389141704</id><published>2013-10-15T17:05:00.003-07:00</published><updated>2013-10-15T17:05:53.226-07:00</updated><title type='text'>from Webapp SDK to r2d2b2g, Firefox OS Simulator, and the App Manager</title><content type='html'>A little over a year ago, on August 31, 2012, I brainstormed the outline of a &quot;Webapp SDK&quot;:&lt;br /&gt;&lt;br /&gt;&lt;div class=&quot;separator&quot; style=&quot;clear: both; text-align: center;&quot;&gt;&lt;a href=&quot;http://2.bp.blogspot.com/-qFEN48-NFBI/Ul2-QToFUkI/AAAAAAAAAEY/Pah7FtanWfo/s1600/2012-08-31+10.57.00.jpg&quot; imageanchor=&quot;1&quot; style=&quot;margin-left: 1em; margin-right: 1em;&quot;&gt;&lt;img border=&quot;0&quot; height=&quot;300&quot; src=&quot;http://2.bp.blogspot.com/-qFEN48-NFBI/Ul2-QToFUkI/AAAAAAAAAEY/Pah7FtanWfo/s400/2012-08-31+10.57.00.jpg&quot; width=&quot;400&quot; /&gt;&lt;/a&gt;&lt;/div&gt;&lt;br /&gt;That outline was the genesis for the &lt;a href=&quot;https://hacks.mozilla.org/2012/10/r2d2b2g-an-experimental-prototype-firefox-os-test-environment/&quot;&gt;r2d2b2g experiment&lt;/a&gt;, which built the &lt;a href=&quot;http://www.blueskyonmars.com/2012/11/08/r2d2b2g-is-becoming-the-firefox-os-simulator/&quot;&gt;Firefox OS Simulator&lt;/a&gt;, whose initial version hit the web on September 14, 2012 and which has gone through numerous iterations since then as we evaluated various features to enhance app development.&lt;br /&gt;&lt;br /&gt;And that experiment spurred Mozilla&#39;s &lt;a href=&quot;https://wiki.mozilla.org/DevTools&quot;&gt;Developer Tools group&lt;/a&gt;, particularly its nascent &lt;a href=&quot;https://wiki.mozilla.org/DevTools/AppTools&quot;&gt;App Tools team&lt;/a&gt;, to build Firefox&#39;s new App Manager, which landed last month and was &lt;a href=&quot;https://hacks.mozilla.org/2013/10/introducing-the-firefox-os-app-manager/&quot;&gt;introduced today on Hacks&lt;/a&gt;!&lt;br /&gt;&lt;br /&gt;Despite the twisty passage from experiment to product, that initial outline bears a surprising resemblance to the App Manager feature set. The Manager checks off three of the four features on the outline&#39;s primary list—&quot;start Gaia in B2G,&quot; &quot;package app,&quot; and &quot;test app in Gaia/B2G&quot;—plus a few on its secondary list, like &quot;debug from Firefox&quot; and &quot;test on mobile device,&quot; with &quot;edit manifest in GUI&quot; well underway over in &lt;a href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=912912&quot;&gt;bug 912912&lt;/a&gt;. And the Simulator continues to provide B2G/Gaia via an easy-to-install addon that integrates with the Manager.&lt;br /&gt;&lt;br /&gt;Like any good product of a successful experiment, however, the Manager&#39;s reach has exceeded its progenitor&#39;s grasp! So it also gives you access to pre-installed apps, lets you take screenshots of device/Simulator screens, and will doubtless continue to sprout handy features to make app development great.&lt;br /&gt;&lt;br /&gt;So kudos to the folks who built it, and long live the App Manager!&lt;br /&gt;&lt;br /&gt;</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/4907835732389141704/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=4907835732389141704' title='3 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/4907835732389141704'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/4907835732389141704'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2013/10/from-webapp-sdk-to-r2d2b2g-firefox-os.html' title='from Webapp SDK to r2d2b2g, Firefox OS Simulator, and the App Manager'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="http://2.bp.blogspot.com/-qFEN48-NFBI/Ul2-QToFUkI/AAAAAAAAAEY/Pah7FtanWfo/s72-c/2012-08-31+10.57.00.jpg" height="72" width="72"/><thr:total>3</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-4775473125409851813</id><published>2013-08-23T10:55:00.001-07:00</published><updated>2013-08-23T10:55:06.641-07:00</updated><title type='text'>fixing this morning&#39;s mach OS X psutil bustage</title><content type='html'>If mach is broken in your mozilla-central clone on Mac OS X this      morning:&lt;br&gt;      &lt;blockquote&gt;&lt;tt&gt;08-23 10:20 &amp;gt; ./mach build&lt;/tt&gt;&lt;br&gt;        &lt;tt&gt;Error running mach:&lt;/tt&gt;&lt;br&gt;        &lt;br&gt;        &lt;tt&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; [&#39;build&#39;]&lt;/tt&gt;&lt;br&gt;        &lt;br&gt;        &lt;tt&gt;The error occurred in code that was called by the mach          command. This is either&lt;/tt&gt;&lt;br&gt;        &lt;tt&gt;a bug in the called code itself or in the way that mach is          calling it.&lt;/tt&gt;&lt;br&gt;        &lt;br&gt;        &lt;tt&gt;You should consider filing a bug for this issue.&lt;/tt&gt;&lt;br&gt;        &lt;br&gt;        &lt;tt&gt;If filing a bug, please include the full output of mach,          including this error&lt;/tt&gt;&lt;br&gt;        &lt;tt&gt;message.&lt;/tt&gt;&lt;br&gt;        &lt;br&gt;        &lt;tt&gt;The details of the failure are as follows:&lt;/tt&gt;&lt;br&gt;        &lt;br&gt;        &lt;tt&gt;AttributeError: &#39;module&#39; object has no attribute          &#39;TCPS_ESTABLISHED&#39;&lt;/tt&gt;&lt;br&gt;        &lt;br&gt;        &lt;tt&gt;&amp;nbsp; File          &quot;/Users/myk/Mozilla/central/python/mozbuild/mozbuild/mach_commands.py&quot;,          line 293, in build&lt;/tt&gt;&lt;br&gt;        &lt;tt&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; from mozbuild.controller.building import BuildMonitor&lt;/tt&gt;&lt;br&gt;        &lt;tt&gt;&amp;nbsp; File          &quot;/Users/myk/Mozilla/central/python/mozbuild/mozbuild/controller/building.py&quot;,          line 22, in &amp;lt;module&amp;gt;&lt;/tt&gt;&lt;br&gt;        &lt;tt&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; import psutil&lt;/tt&gt;&lt;br&gt;        &lt;tt&gt;&amp;nbsp; File          &quot;/Users/myk/Mozilla/central/python/psutil/psutil/__init__.py&quot;,          line 95, in &amp;lt;module&amp;gt;&lt;/tt&gt;&lt;br&gt;        &lt;tt&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; import psutil._psosx as _psplatform&lt;/tt&gt;&lt;br&gt;        &lt;tt&gt;&amp;nbsp; File          &quot;/Users/myk/Mozilla/central/python/psutil/psutil/_psosx.py&quot;,          line 48, in &amp;lt;module&amp;gt;&lt;/tt&gt;&lt;br&gt;        &lt;tt&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; _TCP_STATES_TABLE = {_psutil_osx.TCPS_ESTABLISHED :          CONN_ESTABLISHED,&lt;/tt&gt;&lt;br&gt;      &lt;/blockquote&gt;      &lt;br&gt;      Then you&#39;ve been bit by the fix for &lt;a        href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=908296&quot;&gt;bug        908296&lt;/a&gt;. To resolve the bustage, run this command in your Hg      clone:&lt;br&gt;      &lt;meta http-equiv=&quot;content-type&quot; content=&quot;text/html;        charset=ISO-8859-1&quot;&gt;      &lt;blockquote&gt;hg status -in python/psutil | xargs rm&lt;br&gt;      &lt;/blockquote&gt;      &lt;br&gt;      Or, if you&#39;ve cloned the &lt;a        href=&quot;https://github.com/mozilla/mozilla-central&quot;&gt;Git mirror&lt;/a&gt;,      run this instead:&lt;br&gt;      &lt;meta http-equiv=&quot;content-type&quot; content=&quot;text/html;        charset=ISO-8859-1&quot;&gt;      &lt;blockquote&gt;git clean -xf python/psutil&lt;br&gt;      &lt;/blockquote&gt;      &lt;br&gt;    </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/4775473125409851813/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=4775473125409851813' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/4775473125409851813'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/4775473125409851813'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2013/08/fixing-this-mornings-mach-os-x-psutil.html' title='fixing this morning&#39;s mach OS X psutil bustage'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-7658176042227879683</id><published>2013-06-07T16:04:00.000-07:00</published><updated>2013-06-07T16:04:04.952-07:00</updated><title type='text'>64-bit Linux ADB for Simulator</title><content type='html'>Thanks to the efforts of new Mozilla intern &lt;a href=&quot;https://github.com/bkase&quot;&gt;Brandon Kase&lt;/a&gt;, the &lt;a href=&quot;https://ftp.mozilla.org/pub/mozilla.org/labs/r2d2b2g/r2d2b2g-linux.xpi&quot;&gt;latest        preview build of Firefox OS Simulator for Linux&lt;/a&gt; includes a     64-bit version of ADB, so you can push an app to an FxOS device from     64-bit Linux installations without any extra packages!&lt;br /&gt;     &lt;br /&gt;     Building it was tricky, because the Android SDK build scripts don&#39;t     support that target. Brandon first tried simply specifying the     target, which worked on an older version of ADB (1.0.24). But it     failed on the latest version (1.0.31), which links with a bundled     copy of libcrypto that includes 32-bit assembly.&lt;br /&gt;     &lt;br /&gt;     Ubuntu 13.04 (Raring) ships a 64-bit &lt;a href=&quot;http://packages.ubuntu.com/raring/android-tools-adb&quot;&gt;android-tools-adb       package&lt;/a&gt;, though, so we knew it could be done. And its &lt;a href=&quot;http://packages.ubuntu.com/source/raring/android-tools&quot;&gt;source       package&lt;/a&gt;&#39;s build system is much simpler than the SDK&#39;s. We just     needed a binary that works on distributions with older versions of     glibc than Raring&#39;s 2.17. And one that doesn&#39;t depend on a specific     version of libcrypto, which varies around the Linux world; whereas     Raring&#39;s ADB executable appears to need the specific version that     comes with that distribution.&lt;br /&gt;     &lt;br /&gt;     So Brandon modified the source package&#39;s Makefile to link libcrypto     statically (note the absolute path to libcrypto.a, which may vary):&lt;br /&gt;&lt;blockquote class=&quot;tr_bq&quot;&gt;     &lt;tt&gt;--- debian/makefiles/adb.mk&amp;nbsp;&amp;nbsp;&amp;nbsp; 2013-03-26 14:15:41.000000000       -0700&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;+++ adb-static-crypto.mk&amp;nbsp;&amp;nbsp;&amp;nbsp; 2013-06-06 16:51:52.794521267       -0700&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;@@ -40,15 +40,16 @@&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;CPPFLAGS+= -I.&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;CPPFLAGS+= -I../include&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;CPPFLAGS+= -I../../../external/zlib&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;+CPPFLAGS+= -I/usr/include/openssl&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;-LIBS+= -lc -lpthread -lz -lcrypto&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;+LIBS+= -lc -lpthread -lz -ldl&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;OBJS= $(SRCS:.c=.o)&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;all: adb&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;adb: $(OBJS)&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;-&amp;nbsp;&amp;nbsp;&amp;nbsp; $(CC) -o $@ $(LDFLAGS) $(OBJS) $(LIBS)&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;+&amp;nbsp;&amp;nbsp;&amp;nbsp; $(CC) -o $@ $(LDFLAGS) $(OBJS)       /usr/lib/x86_64-linux-gnu/libcrypto.a $(LIBS)&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;clean:&lt;/tt&gt;&lt;br /&gt;&lt;tt&gt;    &lt;/tt&gt;&lt;tt&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; rm -rf $(OBJS) adb&lt;/tt&gt;&lt;/blockquote&gt;&lt;br /&gt;     Then I copied the source package to my CentOS 16 build machine     (which has glibc 2.12) and built it there. After which the resultant     executable worked on all the distributions we tested: Ubuntu 13.04,     Ubuntu 10.04, CentOS 16, and Arch Linux (kernel 3.9.3-1-ARCH).&lt;br /&gt;     &lt;br /&gt;     Presumably it will work on others too. But if it still doesn&#39;t work     for you, &lt;a href=&quot;https://github.com/mozilla/r2d2b2g/issues&quot;&gt;let us       know&lt;/a&gt;!&lt;br /&gt;     &lt;br /&gt;     And if you just want the ADB executable, sans Simulator, &lt;a href=&quot;https://ftp.mozilla.org/pub/mozilla.org/labs/r2d2b2g/adb-1.0.31-linux64.zip&quot;&gt;here        it is&lt;/a&gt;.&lt;br /&gt;&lt;br /&gt;</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/7658176042227879683/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=7658176042227879683' title='2 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7658176042227879683'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7658176042227879683'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2013/06/64-bit-linux-adb-for-simulator.html' title='64-bit Linux ADB for Simulator'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>2</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-2950140535922521480</id><published>2012-10-02T12:08:00.001-07:00</published><updated>2012-10-02T12:13:24.657-07:00</updated><title type='text'>r2d2b2g implementation details</title><content type='html'>Over at Mozilla Hacks, I just &lt;a href=&quot;https://hacks.mozilla.org/2012/10/r2d2b2g-an-experimental-prototype-firefox-os-test-environment/&quot;&gt;blogged            about r2d2b2g&lt;/a&gt; (ratta-datta-batta-ga), an experimental      prototype test environment for Firefox OS that makes it drop-dead      simple to test your app in &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Mozilla/Boot_to_Gecko/Using_the_B2G_desktop_client&quot;&gt;B2G            Desktop&lt;/a&gt;.&lt;br /&gt;&lt;br /&gt;r2d2b2g is an addon, but it bundles &lt;a href=&quot;https://ftp.mozilla.org/pub/mozilla.org/b2g/nightly/latest-mozilla-central/&quot;&gt;B2G            Desktop nightly builds&lt;/a&gt;, which are native executables, and thus      the addon is platform-specific, with packages available for &lt;a href=&quot;https://ftp.mozilla.org/pub/mozilla.org/labs/r2d2b2g/r2d2b2g-mac.xpi&quot;&gt;Mac&lt;/a&gt;,      &lt;a href=&quot;https://ftp.mozilla.org/pub/mozilla.org/labs/r2d2b2g/r2d2b2g-linux.xpi&quot;&gt;Linux            32-bit&lt;/a&gt;, and &lt;a href=&quot;https://ftp.mozilla.org/pub/mozilla.org/labs/r2d2b2g/r2d2b2g-windows.xpi&quot;&gt;Windows&lt;/a&gt;      (caveat: B2G Desktop for Windows currently crashes on startup due to      bug            &lt;strike&gt;&lt;a href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=794662&quot;&gt;794662&lt;/a&gt;&lt;/strike&gt;      &lt;a href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=795484&quot;&gt;795484&lt;/a&gt;).&lt;br /&gt;&lt;br /&gt;The packages are large, 50-60MB each, partly because of the      executables, but mostly because they also bundle &lt;a href=&quot;https://wiki.mozilla.org/Gaia&quot;&gt;Gaia&lt;/a&gt; profiles, including      all default apps. (It&#39;s probably worth bundling a few of these, for      demonstration purposes, but we could make the packages much smaller      by removing the rest.)&lt;br /&gt;&lt;br /&gt;r2d2b2g uses the &lt;a href=&quot;https://addons.mozilla.org/en-US/developers/builder&quot;&gt;Add-on        SDK&lt;/a&gt; as its addon framework and relies on several third-party      addon modules (&lt;a href=&quot;https://github.com/ochameau/jetpack-subprocess&quot;&gt;subprocess&lt;/a&gt;,      &lt;a href=&quot;https://github.com/voldsoftware/menuitems-jplib&quot;&gt;menuitems&lt;/a&gt;)      along with some Python utilities (&lt;a href=&quot;https://github.com/mozilla/mozdownload&quot;&gt;mozdownload&lt;/a&gt;, &lt;a href=&quot;https://github.com/mozilla/mozbase&quot;&gt;mozbase&lt;/a&gt;) to download      and unpack B2G Desktop builds. Plus Gaia, although recent work to      bundle Gaia profiles with B2G Desktop builds may break that      dependency.&lt;br /&gt;&lt;br /&gt;I&#39;ve demoed the project to a variety of folks over the last couple      weeks, and I&#39;ve received a bunch of positive feedback about it. B2G      Desktop combines approachability with phoneliness and is the best      existing test environment for Firefox OS. But its configuration is a      challenge, and it provides no obvious affordances for installing and      testing your own app. r2d2b2g shows that these problems are      tractable (even if it doesn&#39;t yet solve them all) and demonstrates a      promising product path.&lt;br /&gt;&lt;br /&gt;After seeing r2d2b2g, Kevin Dangoor drafted a &lt;a href=&quot;https://docs.google.com/document/d/1OptOCWO4b_b1aa4Gtwr82_q-mW5ybteoWNpPAJUIFNk/edit&quot;&gt;PRD            for a Firefox OS Simulator&lt;/a&gt; that I&#39;ll use to guide further      development. Interested in participating? Clone the code from its &lt;a href=&quot;https://github.com/mozilla/r2d2b2g&quot;&gt;GitHub repository&lt;/a&gt;      and contribute your improvements!&lt;br /&gt;&lt;br /&gt;</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/2950140535922521480/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=2950140535922521480' title='5 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/2950140535922521480'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/2950140535922521480'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2012/10/r2d2b2g-implementation-details.html' title='r2d2b2g implementation details'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>5</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-704490864096897779</id><published>2012-03-07T18:04:00.001-08:00</published><updated>2012-03-07T18:04:16.546-08:00</updated><title type='text'>Next/Previous Tab on Mac Consistent At Last</title><content type='html'>After blogging about the &lt;a href=&quot;http://mykzilla.blogspot.com/2011/09/nextprevious-tab-keyboard-shortcuts-on.html&quot;&gt;inconsistency       of keyboard shortcuts for Next/Previous Tab on Mac&lt;/a&gt; last year,     I found out that Firefox, Thunderbird, and Komodo also support     Command + Option + LeftArrow|RightArrow, and Adium has a General     &amp;gt; &quot;Switch tabs with&quot; pref that I can set to the same chord.&lt;br&gt;     &lt;br&gt;     (Later, I switched IM clients from Adium to InstantBird, which also     supports that combination.)&lt;br&gt;     &lt;br&gt;     That left Terminal, which I couldn&#39;t figure out how to configure to     support the same shortcut. Until now.&lt;br&gt;     &lt;br&gt;     I&#39;m not sure if it&#39;s because I have since upgraded to Mac OS X 10.7     (Lion). I could&#39;ve sworn I tried something like this back when I     wrote that previous blog post, and it didn&#39;t work.&lt;br&gt;     &lt;ol&gt;       &lt;li&gt;Go to System Preferences &amp;gt; Keyboard &amp;gt; Keyboard Shortcuts         &amp;gt; Application Shortcuts.&lt;/li&gt;       &lt;li&gt;Press the + (plus) button.&lt;/li&gt;       &lt;li&gt;Select &quot;Other...&quot; from the Application menu and select         Utilities &amp;gt; Terminal from the file picker dialog.&lt;/li&gt;       &lt;li&gt;Enter &quot;Select Next Tab&quot; (without the quotes) into the Menu         Title field.&lt;/li&gt;       &lt;li&gt;Focus the Keyboard Shortcut field and press Command + Option +         RightArrow to set the keyboard shortcut, which will appear as         &lt;meta http-equiv=&quot;content-type&quot; content=&quot;text/html;           charset=ISO-8859-1&quot;&gt;         &amp;#8997;&amp;#8984;&amp;#8594;.&lt;/li&gt;       &lt;li&gt;Press the Add button.&lt;/li&gt;     &lt;/ol&gt;     &lt;p&gt;Repeat steps 4-6 with &quot;Select Previous Tab&quot; and Command + Option       + LeftArrow, which will appear as       &lt;meta http-equiv=&quot;content-type&quot; content=&quot;text/html;         charset=ISO-8859-1&quot;&gt;       &amp;#8997;&amp;#8984;&amp;#8592;.&lt;br&gt;     &lt;/p&gt;     &lt;p&gt;Those shortcuts should now work in Terminal.&lt;br&gt;     &lt;/p&gt;     &lt;p&gt;With this change, all five of my current primary productivity       applications on Mac (Firefox, Thunderbird, Instantbird, Komodo,       and Terminal) support a consistent pair of keyboard shortcuts for       Next/Previous Tab, which are two of the most common commands I       issue in all of those apps.&lt;br&gt;     &lt;/p&gt;     &lt;p&gt;Woot!&lt;br&gt;       &lt;br&gt;     &lt;/p&gt;   </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/704490864096897779/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=704490864096897779' title='2 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/704490864096897779'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/704490864096897779'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2012/03/nextprevious-tab-on-mac-consistent-at.html' title='Next/Previous Tab on Mac Consistent At Last'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>2</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-4717272844287397408</id><published>2012-03-07T13:46:00.001-08:00</published><updated>2012-03-07T13:46:45.454-08:00</updated><title type='text'>generating a fingerprint for an SSH key</title><content type='html'>After recently &lt;a href=&quot;https://github.com/blog/1068-public-key-security-vulnerability-and-mitigation&quot;&gt;discovering       a security vulnerability&lt;/a&gt; that allows an attacker to add an SSH     key to a GitHub user account, GitHub is requiring all users to audit     their SSH keys. Its &lt;a href=&quot;https://github.com/settings/ssh/audit&quot;&gt;audit       page&lt;/a&gt; lists one&#39;s keys by type and fingerprint, but it doesn&#39;t     say how it generated the fingerprint or how to generate one for your     local copy of a key to compare it with. Nor does it let you see the     whole key.&lt;br&gt;     &lt;br&gt;     And since I don&#39;t generate such fingerprints very often, I didn&#39;t     know how to do it. So I tried &lt;tt&gt;cksum&lt;/tt&gt;, &lt;tt&gt;md5&lt;/tt&gt;, and &lt;tt&gt;shasum&lt;/tt&gt;     on my Mac, but none of their checksums matched. Turns out the tool     to use is &lt;tt&gt;ssh-keygen&lt;/tt&gt;:&lt;br&gt;     &lt;br&gt;     &lt;tt&gt;       &amp;nbsp;&amp;nbsp;&amp;nbsp; ssh-keygen -l -f path/to/keyfile&lt;/tt&gt;&lt;br&gt;     &lt;br&gt;   </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/4717272844287397408/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=4717272844287397408' title='2 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/4717272844287397408'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/4717272844287397408'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2012/03/generating-fingerprint-for-ssh-key.html' title='generating a fingerprint for an SSH key'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>2</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-3538011216013400890</id><published>2011-10-17T10:38:00.001-07:00</published><updated>2011-10-17T10:38:52.345-07:00</updated><title type='text'>Mozilla Status Board Text is Markdown</title><content type='html'>It isn&#39;t documented anywhere that I can find, but Benjamin     Smedberg&#39;s handy &lt;a       href=&quot;http://benjamin.smedbergs.us/weekly-updates.fcgi/&quot;&gt;Mozilla       Status Board&lt;/a&gt; tool parses status text as &lt;a       href=&quot;http://daringfireball.net/projects/markdown/&quot;&gt;Markdown&lt;/a&gt;,     which is how I added a &lt;b&gt;Didn&#39;t&lt;/b&gt; header to the &lt;b&gt;Done&lt;/b&gt;     section of my &lt;a       href=&quot;http://benjamin.smedbergs.us/weekly-updates.fcgi/user/mykmelez&quot;&gt;status       update&lt;/a&gt; with all the things I planned to do last week but     didn&#39;t make happen. (The &lt;b&gt;Done&lt;/b&gt;, &lt;b&gt;Next&lt;/b&gt;, and &lt;b&gt;Coordination&lt;/b&gt;     headers are all &lt;b&gt;H4&lt;/b&gt;s, so I prepended four hash marks to &lt;tt&gt;####       &lt;b&gt;Didn&#39;t&lt;/b&gt;&lt;/tt&gt; to make it the same size).&lt;br&gt;     &lt;br&gt;     (Note that &lt;tt&gt;[&lt;a         href=&quot;http://daringfireball.net/projects/markdown/basics&quot;&gt;Markdown-style         links&lt;/a&gt;](&lt;a class=&quot;moz-txt-link-freetext&quot; href=&quot;http://daringfireball.net/projects/markdown/basics&quot;&gt;http://daringfireball.net/projects/markdown/basics&lt;/a&gt;)&lt;/tt&gt;     don&#39;t work and cause the entire section in which they appear to     remain unparsed. However angle-bracketed URLs, as recommended by &lt;tt&gt;&lt;a href=&quot;http://labs.apache.org/webarch/uri/rfc/rfc3986.html#delimiting&quot;&gt;RFC         3986&lt;/a&gt;       &lt;a class=&quot;moz-txt-link-rfc2396E&quot; href=&quot;http://labs.apache.org/webarch/uri/rfc/rfc3986.html#delimiting&quot;&gt;&amp;lt;http://labs.apache.org/webarch/uri/rfc/rfc3986.html#delimiting&amp;gt;&lt;/a&gt;&lt;/tt&gt;,     work when added to the ends of lines. And &quot;&lt;tt&gt;bug ###&lt;/tt&gt;&quot;     references are auto-linkified.)&lt;br&gt;     &lt;br&gt;   </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/3538011216013400890/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=3538011216013400890' title='1 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/3538011216013400890'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/3538011216013400890'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2011/10/mozilla-status-board-text-is-markdown.html' title='Mozilla Status Board Text is Markdown'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>1</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-3002229182794927051</id><published>2011-09-16T08:32:00.001-07:00</published><updated>2011-09-16T08:32:13.528-07:00</updated><title type='text'>to all the bugs I&#39;ve filed before</title><content type='html'>The &lt;a href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=20142&quot;&gt;first       bug I filed&lt;/a&gt; was marked as &lt;i&gt;duplicate&lt;/i&gt;; the &lt;a       href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=20187&quot;&gt;second&lt;/a&gt;     was &lt;i&gt;worksforme&lt;/i&gt; (although Chris Petersen could reproduce it     before he couldn&#39;t anymore); and the &lt;a       href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=24840&quot;&gt;third&lt;/a&gt;     was &lt;i&gt;invalid&lt;/i&gt; (it was the spec, not the code, that was     errant). The &lt;a       href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=25082&quot;&gt;fourth&lt;/a&gt;     is the first that was &lt;i&gt;fixed&lt;/i&gt;.&lt;br&gt;     &lt;br&gt;   </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/3002229182794927051/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=3002229182794927051' title='2 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/3002229182794927051'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/3002229182794927051'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2011/09/to-all-bugs-ive-filed-before.html' title='to all the bugs I&#39;ve filed before'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>2</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-2481752447493541518</id><published>2011-09-09T17:01:00.000-07:00</published><updated>2011-09-09T17:00:36.580-07:00</updated><title type='text'>&quot;Next/Previous Tab&quot; Keyboard Shortcuts on Windows</title><content type='html'>On my Windows laptop, I use the following four programs with tabbed     interfaces on a regular basis:&lt;br&gt;     &lt;ul&gt;       &lt;li&gt;Firefox&lt;/li&gt;       &lt;li&gt;Thunderbird&lt;/li&gt;       &lt;li&gt;Instantbird&lt;/li&gt;       &lt;li&gt;Komodo IDE&lt;/li&gt;     &lt;/ul&gt;     (I&#39;d love to have tabs in my Windows terminal app of choice, &lt;a       href=&quot;http://code.google.com/p/mintty/&quot;&gt;Mintty&lt;/a&gt;, but its     developer &lt;a       href=&quot;http://code.google.com/p/mintty/issues/detail?id=8&quot;&gt;thinks       tabs should be implemented at the window manager level&lt;/a&gt;.)&lt;br&gt;     &lt;br&gt;     Unlike &lt;a href=&quot;http://mykzilla.blogspot.com/2011/09/nextprevious-tab-keyboard-shortcuts-on.html&quot;&gt;on       my Mac&lt;/a&gt;, all those programs implement the same keyboard     shortcut for switching to the previous/next tab, and it&#39;s a simple     one with just a two-key chord: Control + PageUp / PageDown.&lt;br&gt;     &lt;br&gt;     Ha!&lt;br&gt;     &lt;br&gt;   </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/2481752447493541518/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=2481752447493541518' title='8 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/2481752447493541518'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/2481752447493541518'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2011/09/nextprevious-tab-keyboard-shortcuts-on_09.html' title='&quot;Next/Previous Tab&quot; Keyboard Shortcuts on Windows'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>8</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-1639696569383368856</id><published>2011-09-08T17:03:00.001-07:00</published><updated>2011-09-08T17:03:31.723-07:00</updated><title type='text'>&quot;Next/Previous Tab&quot; Keyboard Shortcuts on Mac</title><content type='html'>On my Mac, I use the following five programs with tabbed interfaces     on a regular basis:&lt;br&gt;     &lt;ul&gt;       &lt;li&gt;Firefox&lt;/li&gt;       &lt;li&gt;Thunderbird&lt;/li&gt;       &lt;li&gt;Adium&lt;/li&gt;       &lt;li&gt;Terminal&lt;/li&gt;       &lt;li&gt;Komodo IDE&lt;/li&gt;     &lt;/ul&gt;     &lt;br&gt;     And those programs implement the following five different keyboard     shortcuts for switching to the previous/next tab:&lt;br&gt;     &lt;ul&gt;       &lt;li&gt;Control + PageUp / PageDown (Firefox, Thunderbird)&lt;br&gt;       &lt;/li&gt;       &lt;li&gt;Command + LeftArrow / RightArrow (Adium)&lt;/li&gt;       &lt;li&gt;Command + PageUp / PageDown (Komodo IDE)&lt;/li&gt;       &lt;li&gt;Command + Shift + [ / ] (Terminal)&lt;/li&gt;       &lt;li&gt;Command + Shift + LeftArrow / RightArrow (Terminal)&lt;/li&gt;     &lt;/ul&gt;     Hrm.&lt;br&gt;     &lt;br&gt;   </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/1639696569383368856/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=1639696569383368856' title='6 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/1639696569383368856'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/1639696569383368856'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2011/09/nextprevious-tab-keyboard-shortcuts-on.html' title='&quot;Next/Previous Tab&quot; Keyboard Shortcuts on Mac'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>6</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-4435398397928021957</id><published>2011-09-07T14:25:00.000-07:00</published><updated>2011-09-20T11:37:47.397-07:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="jetpack"/><title type='text'>gitflow vs. the SDK</title><content type='html'>&lt;a href=&quot;http://nvie.com/posts/a-successful-git-branching-model/&quot;&gt;gitflow&lt;/a&gt; is a model for developing and shipping software using &lt;a href=&quot;http://git-scm.com/&quot;&gt;Git&lt;/a&gt;. &lt;a href=&quot;https://addons.mozilla.org/en-US/developers/builder&quot;&gt;Add-on SDK&lt;/a&gt; uses Git, and &lt;a href=&quot;https://wiki.mozilla.org/Jetpack/Development_Process&quot;&gt;it too has a model&lt;/a&gt;, which is similar to gitflow in some ways and different in others. Here&#39;s a comparison of the two and some thoughts on why they vary.&lt;br /&gt;&lt;br /&gt;First, some similarities: both models use multiple branches, including an ongoing branch for general development and another ongoing branch that is always ready for release (their names vary, but that&#39;s a trivial difference). Both also permit development on temporary feature (topic) branches and utilize a branch for stabilization of the codebase leading up to a release. And both accommodate the occasional hotfix release in similar ways.&lt;br /&gt;&lt;br /&gt;(Aside: gitflow appears to encourage feature branches, but I tend to agree with &lt;a href=&quot;http://martinfowler.com/bliki/FeatureBranch.html&quot;&gt;Martin Fowler&lt;/a&gt; through &lt;a href=&quot;http://pauljulius.com/blog/2009/09/03/feature-branches-are-poor-mans-modular-architecture/&quot;&gt;Paul Julius&lt;/a&gt; that continuously integrating with a central development branch is preferable.)&lt;br /&gt;&lt;br /&gt;Second, some differences: the SDK uses a single ongoing stabilization branch, while gitflow uses multiple short-lived stabilization branches, one per release. And in the SDK, stabilization fixes land on the development branch and then get cherry-picked to the stabilization branch; whereas in gitflow, stabilization fixes land on the stabilization branch and then get merged to the development branch.&lt;br /&gt;&lt;br /&gt;(Also, the SDK releases on a regular time/quality-driven &quot;train&quot; schedule similar to &lt;a href=&quot;http://mozilla.github.com/process-releases/draft/development_overview/&quot;&gt;Firefox&#39;s&lt;/a&gt;, while  gitflow may anticipate an irregular feature/quality-driven release schedule, although it can be  applied to projects with train schedules, like &lt;a href=&quot;http://lloyd.io/applying-gitflow&quot;&gt;BrowserID&lt;/a&gt;.)&lt;br /&gt;&lt;br /&gt;A benefit of gitflow&#39;s approach to stabilization is that its change graph includes only distinct changes, whereas cherry-picking adds duplicate, semi-associated changes to the SDK&#39;s graph. However, a downside of gitflow&#39;s approach is that developers must attend to where they land changes, whereas SDK developers always land changes on its development branch, and its release manager takes on the chore of getting those changes onto the stabilization branch.&lt;br /&gt;&lt;br /&gt;(It isn&#39;t clear what happens in gitflow if a change lands on the development branch while a release is being stabilized and afterward is identified as being wanted for the release. Perhaps it gets cherry-picked?)&lt;br /&gt;&lt;br /&gt;Overall, these models seem fairly similar, and it wouldn&#39;t be too hard to make the SDK&#39;s be essentially gitflow. We would just need to stipulate that developers land stabilization fixes on the stabilization branch, and the release manager&#39;s job would then be to merge that branch back to the development branch periodically instead of cherry-picking in the other direction.&lt;br /&gt;&lt;br /&gt;However, it isn&#39;t clear to me that such a change would be preferable. What do you think?</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/4435398397928021957/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=4435398397928021957' title='5 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/4435398397928021957'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/4435398397928021957'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2011/09/gitflow-vs-sdk.html' title='gitflow vs. the SDK'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>5</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-8020854027593557159</id><published>2011-08-21T22:51:00.000-07:00</published><updated>2011-09-20T11:38:43.594-07:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="jetpack"/><title type='text'>Administer Git? Get a job!</title><content type='html'>As I &lt;a href=&quot;http://mykzilla.blogspot.com/2011/08/why-add-on-sdk-doesnt-land-in-mozilla.html&quot;&gt;mentioned       recently&lt;/a&gt;, &lt;a href=&quot;http://git-scm.com/&quot;&gt;Git&lt;/a&gt; (on &lt;a       href=&quot;https://github.com/&quot;&gt;GitHub&lt;/a&gt;) has become a popular VCS     for Mozilla-related projects.&lt;br&gt;     &lt;br&gt;     GitHub is a fantastic tool for collaboration, and the site does a     great job running a Git server, but given the importance of the VCS,     and because Mozilla&#39;s automated test machines don&#39;t have access to     servers outside the Mozilla firewall, Mozilla should &lt;a       href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=528360&quot;&gt;run its       own Git server&lt;/a&gt; (that syncs with GitHub, so developers can     continue to use that site for collaboration).&lt;br&gt;     &lt;br&gt;     Unfortunately, the organization doesn&#39;t have a great deal of     in-house Git server administration experience, but we&#39;re &lt;a href=&quot;http://hire.jobvite.com/CompanyJobs/Careers.aspx?c=qpX9Vfwa&amp;amp;cs=9Kt9Vfw1&amp;amp;page=Job%20Description&amp;amp;j=oIfPVfwr&quot;&gt;hiring       systems administrators&lt;/a&gt;, so if you grok Git hosting and meet     the other requirements, &lt;a href=&quot;http://hire.jobvite.com/CompanyJobs/Careers.aspx?c=qpX9Vfwa&amp;amp;page=Apply&amp;amp;j=oIfPVfwr&quot;&gt;send       in your resume&lt;/a&gt;!&lt;br&gt;     &lt;br&gt;   </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/8020854027593557159/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=8020854027593557159' title='5 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/8020854027593557159'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/8020854027593557159'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2011/08/administer-git-get-job.html' title='Administer Git? Get a job!'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>5</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-1307851753993957811</id><published>2011-08-11T13:33:00.000-07:00</published><updated>2011-09-20T11:38:43.545-07:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="jetpack"/><title type='text'>Why the Add-on SDK Doesn&#39;t &quot;Land in mozilla-central&quot;</title><content type='html'>Various Mozillians sometimes suggest that the Add-on SDK should &quot;land  in mozilla-central&quot; and wonder why it doesn&#39;t. Here&#39;s  why.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;The Add-on SDK depends on features of Firefox (and Gecko), and the SDK&#39;s development process synchronizes its release schedule with Firefox&#39;s. Nevertheless, the SDK  isn&#39;t a component of Firefox, it&#39;s a distinct product with its own  codebase, development process, and release schedule.&lt;br /&gt;&lt;br /&gt;Mozilla makes multiple products that interact with Firefox  (addons.mozilla.org, a.k.a. AMO, is another), and distinct product development  efforts should generally utilize separate code repositories, to avoid  contention between the projects regarding tree management, the stages of  the software development lifecycle (i.e. when which branch is in alpha,  beta, etc.), and the schedules for merging between branches.&lt;br /&gt;&lt;br /&gt;There can be exceptions to that principle, for products that share a  bunch of code, use the same development process, and have the same  release schedule (cf. the Firefoxes for desktop and mobile). But the  SDK is not one of those exceptions.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;It shares no code with Firefox. Its process utilizes one fewer branch  and six fewer weeks of development than the Firefox development process,  to minimize the burden of branch management and stabilization build  testing on its much smaller development team and testing community. And  it merges its branches and ships its releases two weeks before Firefox, to give AMO and addon developers time to update addons for each new version of the browser.&lt;br /&gt;&lt;br /&gt;Living in its own repository makes it possible for the SDK to have these differences in its process, and it also makes it possible for us to change the  process in the future, for example to move up the branch/release dates  one week, if we discover that AMO and addon developers would benefit from three  weeks of lead time; or to ship twice as frequently, if we determine  that doing so would get APIs for new Firefox features into  developers&#39; hands faster.&lt;br /&gt;&lt;br /&gt;Finally, the Jetpack project has a vibrant community of contributors  (including both organization staff and volunteers) who strongly prefer  contributing via Git and &lt;a href=&quot;https://github.com/&quot;&gt;GitHub&lt;/a&gt;, because they find it easier, more efficient, and more  enjoyable, and for whom working in mozilla-central would mean taking too  great a hit on their productivity, passion, and participation.&lt;br /&gt;&lt;br /&gt;Mozilla Labs innovates not only on features and  user experience but also on development process and tools, and while Jetpack didn&#39;t lead the way to GitHub, we were a fast follower once  early experiments validated its benefits. And our experience since then has only confirmed our decision, as GitHub has proven to be a fantastic tool for branch management, code review/integration, and other software development tasks.&lt;br /&gt;&lt;br /&gt;Other Mozillians agree: there are now almost two hundred members and  over one hundred repositories (not counting forks) in the Mozilla organization on GitHub, with major initiatives like &lt;a href=&quot;https://github.com/mozilla/openwebapps&quot;&gt;Open Web Apps&lt;/a&gt; and &lt;a href=&quot;https://github.com/mozilla/browserid&quot;&gt;BrowserID&lt;/a&gt; being hosted there, not to mention all the  Mozilla projects in user repositories, including &lt;a href=&quot;https://github.com/graydon/rust&quot;&gt;Rust&lt;/a&gt; and &lt;a href=&quot;https://github.com/jbalogh/zamboni&quot;&gt;Zamboni&lt;/a&gt;.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;Even if we don&#39;t make mozilla-central the canonical repository for SDK  development, however, we could still periodically drop a copy of the SDK  source against which Firefox changes should be tested into  mozilla-central. And doing so would theoretically make it easier for Firefox  developers to run SDK tests when they discover that a Firefox change  breaks the SDK, because they wouldn&#39;t have to get the SDK first.&lt;br /&gt;&lt;br /&gt;But the benefit to Firefox developers is minimal. Currently, we  periodically drop a reference to the SDK revision against which Firefox  changes should be tested, and developers have to do the following to  initiate testing:&lt;br /&gt;&lt;br /&gt;&lt;pre&gt;&amp;nbsp; wget -i testing/jetpack/jetpack-location.txt -O addon-sdk.tar.bz2
+    &lt;br /&gt;&amp;nbsp; tar xjf addon-sdk.tar.bz2
+    &lt;br /&gt;&amp;nbsp; cd addon-sdk-[revision]
+    &lt;br /&gt;&amp;nbsp; source bin/activate
+    &lt;br /&gt;&amp;nbsp; cfx testall --binary path/to/Firefox/build
+    &lt;br /&gt;&lt;/pre&gt;&lt;br /&gt;We can simplify this to:&lt;br /&gt;&lt;br /&gt;&lt;pre&gt;&amp;nbsp; testing/jetpack/clone
+    &lt;br /&gt;&amp;nbsp; cd addon-sdk
+    &lt;br /&gt;&amp;nbsp; source bin/activate
+    &lt;br /&gt;&amp;nbsp; cfx testall --binary path/to/Firefox/build
+    &lt;br /&gt;&lt;/pre&gt;&lt;br /&gt;Whereas if we dropped the source instead of just a reference to it, it would instead be the only slightly simpler:  &lt;br /&gt;&lt;br /&gt;&lt;pre&gt;&amp;nbsp; cd testing/jetpack/addon-sdk
+    &lt;br /&gt;&amp;nbsp; source bin/activate
+    &lt;br /&gt;&amp;nbsp; cfx testall --binary path/to/Firefox/build
+    &lt;br /&gt;&lt;/pre&gt;&lt;br /&gt;Either of which can be abstracted to a single make target.&lt;br /&gt;&lt;br /&gt;But if we were to drop source instead of a reference thereto, the drops  would be larger and riskier changes. And test automation would  still need to be updated to support Git (or at least continue to use  brittle Git -&amp;gt; Mercurial mirroring), in order to run tests on SDK  changes, which periodic source drops do not address.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;Now, this doesn&#39;t mean that no SDK code will ever land in mozilla-central.&lt;br /&gt;&lt;br /&gt;Various folks have discussed integrating parts of the SDK into core  Firefox&lt;span class=&quot;st&quot;&gt;—&lt;/span&gt;including stable API implementations, the module loader, and  possibly the bootstrapper&lt;span class=&quot;st&quot;&gt;—&lt;/span&gt;to reduce the size of addon packages, improve  addon startup times, and decrease addon memory consumption. I have  written a very preliminary draft of a &lt;a href=&quot;https://wiki.mozilla.org/Features/Jetpack/Land_Parts_of_Add-on_SDK_In_Core&quot;&gt;feature page describing this work&lt;/a&gt;,  although I do not think it is a high priority at the moment, relative to the other priorities identified in the &lt;a href=&quot;https://wiki.mozilla.org/Jetpack/Roadmap&quot;&gt;Jetpack roadmap&lt;/a&gt;.&lt;br /&gt;&lt;br /&gt;And Dietrich Ayala recently suggested &lt;a href=&quot;http://groups.google.com/group/mozilla.dev.planning/browse_frm/thread/2b57ebe15aad4130&quot;&gt;integrating the SDK into core  Firefox for use by core features&lt;/a&gt;,  by which he presumably also means the API implementations/module  loader/bootstrapper rather than the command-line tool for testing and  packaging addons.&lt;br /&gt;&lt;br /&gt;Nevertheless, I am (and, I suspect, the whole Jetpack team is) even open  to discussing integration of the command-line tool (or its replacement  by a graphical equivalent), merging together the two products, and  erasing the distinction between them, just as Firefox ships with core  features for web development.&amp;nbsp; We&#39;ve even drafted a &lt;a href=&quot;https://wiki.mozilla.org/Features/Jetpack/Add-on_SDK_as_an_Addon&quot;&gt;feature page for  converting the SDK into an addon&lt;/a&gt;,  which is a big step in that direction.&lt;br /&gt;&lt;br /&gt;But until that happens, farther on up the road, the SDK is its own  product that we develop with its own process and ship on its own schedule. And it has good reason to live in its own repository, and a Git one at that, as do the many (and growing number of) other Mozilla projects using similar processes and tools, which our community-wide development, collaboration, and testing infrastructure must evolve to accommodate.</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/1307851753993957811/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=1307851753993957811' title='6 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/1307851753993957811'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/1307851753993957811'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2011/08/why-add-on-sdk-doesnt-land-in-mozilla.html' title='Why the Add-on SDK Doesn&#39;t &quot;Land in mozilla-central&quot;'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>6</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-6715608859512848344</id><published>2010-12-02T11:07:00.001-08:00</published><updated>2011-09-20T11:38:43.568-07:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="jetpack"/><title type='text'>SDK Training and More at Add-on-Con</title><content type='html'>&lt;div class=&quot;moz-text-html&quot; lang=&quot;x-western&quot;&gt;Next Wednesday, December       8, I&#39;ll be at &lt;a href=&quot;http://addoncon.com/&quot;&gt;Add-on-Con&lt;/a&gt;.&lt;br&gt;       &lt;br&gt;       In the morning, I&#39;ll conduct a training session introducing       Mozilla&#39;s new Add-on SDK, which makes it faster and easier to       build Firefox add-ons. Afterwards, I&#39;ll be around and about to       discuss add-ons and answer questions about the SDK and add-on       development generally.&lt;br&gt;       &lt;br&gt;       Lots of other Mozilla folks will also be on hand over the course       of the two-day conference, including &lt;a         href=&quot;http://www.oxymoronical.com/&quot;&gt;Dave Townsend&lt;/a&gt;, Jorge       Villalobos, &lt;a href=&quot;http://jboriss.wordpress.com/&quot;&gt;Jeniffer         Boriss&lt;/a&gt;, &lt;a href=&quot;http://starkravingfinkle.org/blog/&quot;&gt;Mark         Finkle&lt;/a&gt;, and &lt;a href=&quot;http://blog.fligtar.com/&quot;&gt;Justin Scott&lt;/a&gt;.       A rockin&#39; time should be had by all. Join us!&lt;br&gt;       &lt;br&gt;     &lt;/div&gt;   </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/6715608859512848344/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=6715608859512848344' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/6715608859512848344'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/6715608859512848344'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2010/12/sdk-training-and-more-at-add-on-con.html' title='SDK Training and More at Add-on-Con'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-1323551422046235302</id><published>2010-11-27T20:47:00.001-08:00</published><updated>2011-09-20T11:38:43.550-07:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="jetpack"/><title type='text'>Further Adventures In Git(/Hub)ery</title><content type='html'>&lt;div class=&quot;moz-text-html&quot; lang=&quot;x-western&quot;&gt; This evening I decided       to check if there were any outstanding pull requests for the SDK       repository (to which I haven&#39;t been paying attention).&lt;br&gt;       &lt;br&gt;       There were! The oldest was &lt;a         href=&quot;https://github.com/mozilla/addon-sdk/pull/29&quot;&gt;pull request         29&lt;/a&gt; from Thomas Bassetto, which contains two small fixes (&lt;a href=&quot;https://github.com/tbassetto/addon-sdk/commit/8268334070d03a896d5c006d1b4db94d4cb44b17&quot;&gt;first&lt;/a&gt;,       &lt;a href=&quot;https://github.com/tbassetto/addon-sdk/commit/666ad7a99e05e338348dfc579d5b1f75e8d3bb1b&quot;&gt;second&lt;/a&gt;)       to the docs.&lt;br&gt;       &lt;br&gt;       So I fetched the branch of his fork in which the changes reside:&lt;br&gt;       &lt;br&gt;       &lt;blockquote&gt;&lt;tt&gt;$ git fetch &lt;a class=&quot;moz-txt-link-freetext&quot;             href=&quot;https://github.com/tbassetto/addon-sdk.git&quot;&gt;https://github.com/tbassetto/addon-sdk.git&lt;/a&gt;           master&lt;/tt&gt;&lt;br&gt;       &lt;/blockquote&gt;       &lt;br&gt;       But that branch (and the fork in general) is a few weeks       out-of-date, so &quot;&lt;tt&gt;git diff HEAD FETCH_HEAD&lt;/tt&gt;&quot; showed a bunch       of changes, and it was unclear how painful the merge would be.&lt;br&gt;       &lt;br&gt;       Thus I decided to try cherry-picking the changes, my first time       using &quot;&lt;tt&gt;git cherry-pick&lt;/tt&gt;&quot;.&lt;br&gt;       &lt;br&gt;       The first one went great:&lt;br&gt;       &lt;br&gt;       &lt;blockquote&gt;&lt;tt&gt;$ git cherry-pick           8268334070d03a896d5c006d1b4db94d4cb44b17&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;Finished one cherry-pick.&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;[master ceadb1f] Fixed an internal link in the widget doc&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;&amp;nbsp;1 files changed, 1 insertions(+), 1 deletions(-)&lt;/tt&gt;&lt;br&gt;       &lt;/blockquote&gt;       &lt;br&gt;       Except that I realized afterward I hadn&#39;t added &quot;r,a=myk&quot; to the       commit message. So I tried &quot;&lt;tt&gt;git commit --amend&lt;/tt&gt;&quot; for the       first time, which worked just fine:&lt;br&gt;       &lt;br&gt;       &lt;blockquote&gt;&lt;tt&gt;$ git commit --amend&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;[master 2d674a6] Fixed an internal link in the widget doc;           r,a=myk&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;&amp;nbsp;1 files changed, 1 insertions(+), 1 deletions(-)&lt;/tt&gt;&lt;br&gt;       &lt;/blockquote&gt;       &lt;br&gt;       Next time I&#39;ll remember to use the &quot;&lt;tt&gt;--edit&lt;/tt&gt;&quot; flag to &quot;&lt;tt&gt;git          cherry-pick&lt;/tt&gt;&quot;, which lets one &quot;edit the commit message prior       to committing.&quot;&lt;br&gt;       &lt;br&gt;       The second cherry-pick was more complicated, because I only wanted       one of the two changes in the commit (in &lt;a href=&quot;https://github.com/tbassetto/addon-sdk/commit/666ad7a99e05e338348dfc579d5b1f75e8d3bb1b#commitcomment-204023&quot;&gt;my          review&lt;/a&gt;, I had identified the second change as unnecessary);       and, as it turned out, also because there was a merge conflict       with other commits.&lt;br&gt;       &lt;br&gt;       I started by cherry-picking the commit with the &quot;&lt;tt&gt;--no-commit&lt;/tt&gt;&quot;       option (so I could remove the second change):&lt;br&gt;       &lt;br&gt;       &lt;blockquote&gt;&lt;tt&gt;$ git cherry-pick --no-commit           666ad7a99e05e338348dfc579d5b1f75e8d3bb1b&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;Automatic cherry-pick failed.&amp;nbsp; After resolving the           conflicts,&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;mark the corrected paths with &#39;git add &amp;lt;paths&amp;gt;&#39; or           &#39;git rm &amp;lt;paths&amp;gt;&#39; and commit the result.&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;When commiting, use the option &#39;-c 666ad7a&#39; to retain           authorship and message.&lt;/tt&gt;&lt;br&gt;       &lt;/blockquote&gt;       &lt;br&gt;       The conflict was trivial, and I knew where it was, so I resolved       it manually (instead of trying &quot;&lt;tt&gt;git mergetool&lt;/tt&gt;&quot; for the       first time), removed the second change, added the merged file, and       committed the result, using the &quot;&lt;tt&gt;-c&lt;/tt&gt;&quot; option to preserve       the original author and commit message while allowing me to edit       the message to add &quot;r,a=myk&quot;:&lt;br&gt;       &lt;br&gt;       &lt;blockquote&gt;&lt;tt&gt;$ git add packages/addon-kit/docs/request.md&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;$ git commit -c 666ad7a&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;[master 774d1cb] Completed the example in the Request module           documentation; r,a=myk&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;&amp;nbsp;1 files changed, 1 insertions(+), 0 deletions(-)&lt;/tt&gt;&lt;br&gt;       &lt;/blockquote&gt;       &lt;br&gt;       Then I used &quot;&lt;tt&gt;gitg&lt;/tt&gt;&quot; and &quot;&lt;tt&gt;git log master         ^upstream/master&lt;/tt&gt;&quot; to verify that the commits looked good to       go, after which I pushed them:&lt;br&gt;       &lt;br&gt;       &lt;blockquote&gt;&lt;tt&gt;$ git push upstream master&lt;/tt&gt;&lt;br&gt;         &lt;tt&gt;[git&#39;s standard obscure and disconcerting gobbledygook]&lt;/tt&gt;&lt;br&gt;       &lt;/blockquote&gt;       &lt;br&gt;       Finally, I closed the pull request with &lt;a         href=&quot;https://github.com/mozilla/addon-sdk/pull/29#issuecomment-570630&quot;&gt;this          comment&lt;/a&gt; that summarized what I did and provided links to the       cherry-picked commits.&lt;br&gt;       &lt;br&gt;       It would have been nice if the cherry-picked commit that didn&#39;t       have merge conflicts (and which I didn&#39;t change in the process of       merging) had kept its original commit ID, but I sense that that is       somehow a fundamental violation of the model.&lt;br&gt;       &lt;br&gt;       It would also have been nice if the cherry-picked commit messages       had been automatically annotated with references to the original       commits.&lt;br&gt;       &lt;br&gt;       But overall the process seemed pretty reasonable, it was fairly       easy to do what I wanted and recover from mistakes, and the       author, committer, reviewer, and approver are clearly indicated in       the cherry-picked commits (&lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/2d674a6ea84d3be88b5365b2d24b994297a60d7a&quot;&gt;first&lt;/a&gt;,       &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/774d1cbf49e152a030a0bf6cbde7b4139c8c3f49&quot;&gt;second&lt;/a&gt;).&lt;br&gt;       &lt;br&gt;       [Also &lt;a href=&quot;http://groups.google.com/group/mozilla-labs-jetpack/browse_thread/thread/430750c65fe80231&quot;&gt;posted         to the discussion group&lt;/a&gt;.]&lt;br&gt;       &lt;br&gt;     &lt;/div&gt;   </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/1323551422046235302/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=1323551422046235302' title='3 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/1323551422046235302'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/1323551422046235302'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2010/11/further-adventures-in-githubery.html' title='Further Adventures In Git(/Hub)ery'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>3</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-7960433840999647174</id><published>2010-11-27T20:39:00.001-08:00</published><updated>2011-09-20T11:38:43.583-07:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="jetpack"/><title type='text'>More Git/Hub Workflow Experiences</title><content type='html'>After posting about my &lt;a href=&quot;http://mykzilla.blogspot.com/2010/11/github-workflow-experiences.html&quot;&gt;first       Git/Hub workflow experiences&lt;/a&gt;, I got lots of helpful input from     various folks, particularly Erik Vold, Irakli Gozalishvili, and     Brian Warner, which led me to refine my process for handling pull     requests:&lt;br /&gt;&lt;br /&gt;&lt;ol&gt;&lt;li&gt;From the &quot;how to merge this pull request&quot; section of the pull         request page (f.e. &lt;a href=&quot;https://github.com/mozilla/addon-sdk/pull/43&quot;&gt;pull           request 34&lt;/a&gt;), copy the command from step two, but change         the word &quot;pull&quot; to &quot;fetch&quot; to fetch the remote branch containing         the changes without also merging it:&lt;br /&gt;&lt;br /&gt;&lt;code&gt;git fetch &lt;a class=&quot;moz-txt-link-freetext&quot; href=&quot;https://github.com/toolness/jetpack-sdk.git&quot;&gt;https://github.com/toolness/jetpack-sdk.git&lt;/a&gt;           bug-610507&lt;br /&gt;&lt;br /&gt;&lt;/code&gt;&lt;/li&gt;&lt;li&gt;Use the magic FETCH_HEAD reference to the last fetched branch         to verify that the set of changes is what you expect:&lt;br /&gt;&lt;br /&gt;&lt;tt&gt;git diff &lt;/tt&gt;&lt;tt&gt;HEAD &lt;/tt&gt;&lt;tt&gt;FETCH_HEAD&lt;/tt&gt;&lt;br /&gt;&lt;br /&gt;(The exact syntax here may need some work; HEAD..FETCH_HEAD?         three dots?)&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;&lt;li&gt;Merge the remote branch into your local branch with a custom         commit message:&lt;br /&gt;&lt;br /&gt;&lt;tt&gt;git merge FETCH_HEAD --no-ff -m&quot;bug 610507: get rid of the           nsjetpack package; r=myk&quot;&lt;/tt&gt;&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;&lt;li&gt;Push the changes upstream:&lt;br /&gt;&lt;br /&gt;&lt;tt&gt;git push upstream master&lt;/tt&gt;&lt;br /&gt;&lt;/li&gt;&lt;/ol&gt;&lt;br /&gt;I like this set of commands because it doesn&#39;t require me to add a     remote, I can copy/paste the fetch command from GitHub (being     careful not to issue the pull before I change it to a fetch), and I     always type the same FETCH_HEAD reference to the remote branch in     step three.&lt;br /&gt;&lt;br /&gt;However, I wish the &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/0e23d1c1555d5de228ed7ad62c8715e2775d2390&quot;&gt;merge        commit page&lt;/a&gt; explicitly referenced the &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/68b6e306dfeccef103b071e0812dc3a375830ac0&quot;&gt;specific&lt;/a&gt;     &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/715cb47c720bcdd11846cae6c6cab325bb1a982b&quot;&gt;commits&lt;/a&gt;     that were merged. It does mention that it&#39;s a branch merge, it isn&#39;t     obvious how to get from that page to the pages for the commits I     merged from the branch.&lt;br /&gt;&lt;br /&gt;&quot;&lt;tt&gt;git log --oneline --graph&lt;/tt&gt;&quot;, &lt;tt&gt;gitg&lt;/tt&gt;, and &lt;tt&gt;gitk&lt;/tt&gt;     do give me that information, though, so I&#39;m ok on the command line,     anyway.&lt;br /&gt;&lt;br /&gt;[More discussion can be found in the &lt;a href=&quot;http://groups.google.com/group/mozilla-labs-jetpack/browse_thread/thread/2c6cb3e7f3bec468&quot;&gt;discussion       group thread&lt;/a&gt;.]</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/7960433840999647174/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=7960433840999647174' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7960433840999647174'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7960433840999647174'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2010/11/more-github-workflow-experiences.html' title='More Git/Hub Workflow Experiences'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-7037189428773681360</id><published>2010-11-12T18:55:00.000-08:00</published><updated>2011-09-20T11:38:43.577-07:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="jetpack"/><title type='text'>Git/Hub Workflow Experiences</title><content type='html'>&lt;div class=&quot;moz-text-html&quot; lang=&quot;x-western&quot;&gt;The Jetpack project recently migrated its SDK repository to Git (hosted on GitHub), and we&#39;ve been working out changes to the bug/review/commit workflow that GitHub&#39;s tools enable (specifically, pull requests).&lt;/div&gt;&lt;div class=&quot;moz-text-html&quot; lang=&quot;x-western&quot;&gt;&amp;nbsp;&lt;/div&gt;&lt;div class=&quot;moz-text-html&quot; lang=&quot;x-western&quot;&gt;Here are some of my initial experiences and my thoughts on them (which I&#39;ve also &lt;a href=&quot;http://groups.google.com/group/mozilla-labs-jetpack/browse_thread/thread/2c6cb3e7f3bec468&quot;&gt;posted to the Jetpack discussion group&lt;/a&gt;).&lt;/div&gt;&lt;div class=&quot;moz-text-html&quot; lang=&quot;x-western&quot;&gt;&amp;nbsp;&lt;/div&gt;&lt;div class=&quot;moz-text-html&quot; lang=&quot;x-western&quot;&gt; &lt;/div&gt;&lt;div class=&quot;moz-text-html&quot; lang=&quot;x-western&quot;&gt;     Warning: Git wonkery ahead, with excruciating details. I would not     want to read this post. I recommend you skip it. ;-)&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;font-size: large;&quot;&gt;&lt;b&gt;     Part 1: Wherein I Handle My First Pull Request&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;To fix some test failures, Atul submitted &lt;a href=&quot;https://github.com/mozilla/addon-sdk/pull/33&quot;&gt;GitHub pull       request 33&lt;/a&gt;, I reviewed the changes (comprising &lt;a href=&quot;https://github.com/toolness/jetpack-sdk/commit/97619b0b25554712756827de883883c9b810319d&quot;&gt;two&lt;/a&gt;     &lt;a href=&quot;https://github.com/toolness/jetpack-sdk/commit/405390a586f6c09bad2b26183fe2925d09bcd52b&quot;&gt;commits&lt;/a&gt;)     on GitHub, and then I pushed them to the canonical repository via     the following set of commands:&lt;br /&gt;&lt;ol&gt;&lt;li&gt;git checkout -b toolness-&lt;span class=&quot;commit-ref from&quot;&gt;4.0b7-bustage-fixes&lt;/span&gt;         master&lt;/li&gt;&lt;li&gt;git pull &lt;a class=&quot;moz-txt-link-freetext&quot; href=&quot;https://github.com/toolness/jetpack-sdk.git&quot;&gt;https://github.com/toolness/jetpack-sdk.git&lt;/a&gt; &lt;span class=&quot;commit-ref from&quot;&gt;4.0b7-bustage-fixes&lt;/span&gt;&lt;/li&gt;&lt;li&gt;git checkout master&lt;/li&gt;&lt;li&gt;git merge toolness-&lt;span class=&quot;commit-ref from&quot;&gt;4.0b7-bustage-fixes&lt;/span&gt;&lt;/li&gt;&lt;li&gt;git push upstream master&lt;/li&gt;&lt;/ol&gt;&lt;br /&gt;That landed the &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/97619b0b25554712756827de883883c9b810319d&quot;&gt;two&lt;/a&gt;     &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/405390a586f6c09bad2b26183fe2925d09bcd52b&quot;&gt;commits&lt;/a&gt;     in the canonical repository, but it isn&#39;t obvious that they were     related (i.e. part of the same pull request), that I was the one who     reviewed them, or that I was the one who pushed them.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;font-size: large;&quot;&gt;&lt;b&gt;Part 2: Wherein I Handle My Second Pull Request&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;Thus, for the fix for &lt;a href=&quot;https://bugzilla.mozilla.org/show_bug.cgi?id=611042&quot;&gt;bug       611042&lt;/a&gt;, for which Atul submitted &lt;a href=&quot;https://github.com/mozilla/addon-sdk/pull/34&quot;&gt;GitHub pull       request 34&lt;/a&gt;, I again reviewed the changes (also comprising &lt;a href=&quot;https://github.com/toolness/jetpack-sdk/commit/5e6ca0e1834e65623f6ac87d3828965da420847c&quot;&gt;two&lt;/a&gt;     &lt;a href=&quot;https://github.com/toolness/jetpack-sdk/commit/1ab9c78c94fb08610460ad19fd763a7402fc233c&quot;&gt;commits&lt;/a&gt;)     on GitHub, but then I pushed them to the &lt;a href=&quot;https://github.com/mozilla/addon-sdk&quot;&gt;canonical repository&lt;/a&gt;     via this different set of commands (after discussion with Atul and     Patrick Walton of the Rust team):&lt;br /&gt;&lt;ol&gt;&lt;li&gt;git checkout -b toolness-bug-611042 master&lt;/li&gt;&lt;li&gt;git pull &lt;a class=&quot;moz-txt-link-freetext&quot; href=&quot;https://github.com/toolness/jetpack-sdk.git&quot;&gt;https://github.com/toolness/jetpack-sdk.git&lt;/a&gt;         bug-611042&lt;/li&gt;&lt;li&gt;(There might have been something else here, since the pull         request resulted in a merge; I don&#39;t quite remember.)&lt;br /&gt;&lt;/li&gt;&lt;li&gt;git checkout master&lt;/li&gt;&lt;li&gt;git merge --no-ff --no-commit toolness-bug-611042&lt;/li&gt;&lt;li&gt;git commit --signoff -m &quot;bug 611042: remove         request.response.xml for e10s compatibility; r=myk&quot; --author         &quot;atul&quot;&lt;/li&gt;&lt;li&gt;git push upstream master&lt;/li&gt;&lt;/ol&gt;&lt;br /&gt;Because Atul&#39;s pull request was no longer against the tip (since I     had just merged those previous changes), when I pulled the remote     bug-611042 branch into my local toolness-bug-611042 branch (step 2),     I had to merge his changes, which resulted in a &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/6a3c9e2a614f29b61e580a7a7619f91dd1306eea&quot;&gt;merge       commit&lt;/a&gt;.&lt;br /&gt;&lt;br /&gt;Merging the changes to my local master with &quot;--no-ff&quot; and     &quot;--no-commit&quot; (step 5) then allowed me to commit the merge to my     master branch manually (step 6), resulting in another &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/9f202a3003cddace040bc695ab7137d4a31051ec&quot;&gt;merge       commit&lt;/a&gt;.&lt;br /&gt;&lt;br /&gt;For the second merge commit, I specified &quot;--signoff&quot;, which added     &quot;Signed-off-by: Myk Melez &lt;a class=&quot;moz-txt-link-rfc2396E&quot; href=&quot;mailto:myk@mozilla.org&quot;&gt;&lt;myk@mozilla.org&gt;&lt;/myk@mozilla.org&gt;&lt;/a&gt;&quot; to the commit     message; crafted a custom commit message that included &quot;r=myk&quot;; and     specified &#39;--author &quot;atul&quot;&#39;, which made Atul the author of the     merge.&lt;br /&gt;&lt;br /&gt;I dislike having the former merge commit in history, since it&#39;s     extraneous, unuseful details about how I did the merging locally     before I pushed to the canonical repository. I&#39;m not sure how to     avoid it, though.&lt;br /&gt;&lt;br /&gt;On the other hand, I like having the latter merge commit in history,     since it provides context for Atul&#39;s &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/5e6ca0e1834e65623f6ac87d3828965da420847c&quot;&gt;two&lt;/a&gt;     &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/1ab9c78c94fb08610460ad19fd763a7402fc233c&quot;&gt;commits&lt;/a&gt;:     the bug number, the fact that the changes were reviewed, and a     commit message that describes the changes as a whole.&lt;br /&gt;&lt;br /&gt;I&#39;m ambivalent about --signoff vs. adding &quot;r=myk&quot; to the commit     message, as they seem equivalentish, with --signoff being more     explicit (so in theory it might form part of an enlightened workflow     in the future), while &quot;r=myk&quot; is simpler.&lt;br /&gt;&lt;br /&gt;And I dislike having made Atul the author of the merge, since it&#39;s     incorrect: he wasn&#39;t the author of the merge, he was only the author     of the changes (for which he is correctly credited). And if the     merge itself caused problems (f.e. I accidentally backed out other     recent changes in the process), I would be the one responsible for     fixing those problems, not Atul.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;font-size: large;&quot;&gt;&lt;b&gt;Part 3: Pushing Patches&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;In addition to pull requests, one can also contribute via patches.     I&#39;ve pushed a few of these via something like the following set of     commands:&lt;br /&gt;&lt;ol&gt;&lt;li&gt;git apply patch.diff&lt;/li&gt;&lt;li&gt;git commit -a -m &quot;bug &lt;number&gt;: &lt;description changes=&quot;&quot; of=&quot;&quot;&gt;; r=myk&quot; --author &quot;&lt;author name=&quot;&quot;&gt;&quot;&lt;br /&gt;&lt;/author&gt;&lt;/description&gt;&lt;/number&gt;&lt;/li&gt;&lt;li&gt;git push upstream master&lt;/li&gt;&lt;/ol&gt;That results in a commit like &lt;a href=&quot;https://github.com/mozilla/addon-sdk/commit/026b4e8e78336c2dbbf30edb14e5db78ca4afb21&quot;&gt;this       one&lt;/a&gt;, which shows me as the committer and the patch author as     the author. And that seems like a fine record of what happened.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;font-size: large;&quot;&gt;&lt;b&gt;Part 4: To Bug or Not To Bug?&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;One of the questions GitHub raises is whether or not every change     deserves a bug report. And if not, how do we differentiate those     that do from the rest?&lt;br /&gt;&lt;br /&gt;I don&#39;t have the definitive answers to these questions, but my     sense, from my experience so far, is that we shouldn&#39;t require all     changes to be accompanied by bug reports, but larger, riskier,     time-consuming, and/or controversial changes should have reports to     capture history, provide a forum for discussion, and permit project     planning; while bug reports should be optional for smaller, safer,     quickly-resolved, and/or non-controversial changes.&lt;br /&gt;&lt;br /&gt;&lt;/div&gt;</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/7037189428773681360/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=7037189428773681360' title='3 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7037189428773681360'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7037189428773681360'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2010/11/github-workflow-experiences.html' title='Git/Hub Workflow Experiences'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>3</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-236254320002589931</id><published>2010-07-15T14:22:00.000-07:00</published><updated>2011-09-20T11:38:43.557-07:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="jetpack"/><title type='text'>My Recent Jetpack Presentations</title><content type='html'>The last few weeks have been presentation-heavy.&lt;br /&gt;&lt;br /&gt;First, I gave a presentation about the Jetpack project (past accomplishments, present status, future plans) at the &lt;a href=&quot;https://wiki.mozilla.org/MAOW:2010:London&quot;&gt;2010 London Mozilla Add-ons Workshop&lt;/a&gt; (MAOW), including a demo of using &lt;a href=&quot;https://builder.mozillalabs.com/&quot;&gt;Add-on Builder&lt;/a&gt; to build an add-on in five minutes.&lt;br /&gt;&lt;br /&gt;Then I reprised the Add-on Builder demo as part of the opening day keynote at the &lt;a href=&quot;https://wiki.mozilla.org/Summit2010&quot;&gt;Mozilla Summit&lt;/a&gt;, where it got a great reception. You can watch it in &lt;a href=&quot;http://www.youtube.com/watch?v=lKN4_fOKEWQ&quot;&gt;this Youtube video&lt;/a&gt;.&lt;br /&gt;&lt;br /&gt;Finally, I gave an updated version of the MAOW presentation on the third day of the summit. The slides are available in &lt;a href=&quot;https://people.mozilla.com/%7Emyk/presentations/Prepare%20for%20Liftoff%20-%20Summit%202010.odp&quot;&gt;OpenDocument&lt;/a&gt; and &lt;a href=&quot;https://people.mozilla.com/%7Emyk/presentations/Prepare%20for%20Liftoff%20-%20Summit%202010.pdf&quot;&gt;PDF&lt;/a&gt; formats, and Jetpack presentation materials generally are all available from the &lt;a href=&quot;https://wiki.mozilla.org/Labs/Jetpack/Presentations&quot;&gt;Jetpack Presentations wiki page&lt;/a&gt;.</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/236254320002589931/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=236254320002589931' title='6 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/236254320002589931'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/236254320002589931'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2010/07/my-recent-jetpack-presentations.html' title='My Recent Jetpack Presentations'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>6</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-6583468542975089893</id><published>2010-03-05T23:03:00.001-08:00</published><updated>2010-04-18T23:58:52.841-07:00</updated><title type='text'>This blog has moved</title><content type='html'>&lt;br /&gt;       This blog is now located at http://mykzilla.blogspot.com/.&lt;br /&gt;       You will be automatically redirected in 30 seconds, or you may click &lt;a href=&#39;http://mykzilla.blogspot.com/&#39;&gt;here&lt;/a&gt;.&lt;br /&gt;&lt;br /&gt;       For feed subscribers, please update your feed subscriptions to&lt;br /&gt;       http://mykzilla.blogspot.com/feeds/posts/default.&lt;br /&gt;  </content><link rel="related" href="http://mykzilla.blogspot.com/" title="This blog has moved"/><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/6583468542975089893/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=6583468542975089893' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/6583468542975089893'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/6583468542975089893'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2010/03/this-blog-has-moved.html' title='This blog has moved'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>0</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-7015776887411907934</id><published>2009-11-17T17:26:00.000-08:00</published><updated>2009-11-17T17:26:50.807-08:00</updated><title type='text'>The Skinny on Raindrop&#39;s Mailing List Extensions</title><content type='html'>Raindrop is an exploration of messaging innovation that strives to intelligently assist people in managing their flood of incoming messages. And mailing lists are a common source of messages you need to manage. So, with assistance from the Raindrop hackers, I wrote extensions that make it easier to deal with messages from mailing lists.&lt;br /&gt;&lt;br /&gt;Their goal is to soothe two particular pain points when dealing with mailing lists: grouping their messages together by list and unsubscribing from them once you&#39;re no longer interested in their subject matter.&lt;br /&gt;&lt;br /&gt;This post explains how the extensions do this; touches on some aspects of Raindrop&#39;s message processing and data storage models; and speculates about possible future directions for the extensions.&lt;br /&gt;&lt;h3&gt;Raindrop Extensibility&lt;/h3&gt;Raindrop is being built with the explicit goal of being broadly and deeply extensible, and it includes a number of APIs for adding and modifying functionality. The mailing list enhancements comprise two related extensions, one in the backend and one in the user interface.&lt;br /&gt;&lt;br /&gt;The backend extension plugs into Raindrop&#39;s incoming message processor, intercepting incoming email messages and extracting info about the mailing lists to which they belong. It also handles much of the work of unsubscribing from a list.&lt;br /&gt;&lt;br /&gt;The frontend extension plugs into Raindrop&#39;s Inflow application, modifying its interface to show you the most recent mailing list messages at a glance, group mailing list conversations together by list, and provide a button you can press to easily unsubscribe from a mailing list.&lt;br /&gt;&lt;h3&gt;Message Processing and Data Storage&lt;br /&gt;&lt;/h3&gt;Before getting into how the extensions work, it&#39;s useful to know a bit about how Raindrop processes and stores messages.&lt;br /&gt;&lt;br /&gt;Raindrop stores information using &lt;a href=&quot;http://couchdb.apache.org/&quot;&gt;CouchDB&lt;/a&gt;, a document-centric database whose principal unit of information storage and retrieval is the document (the equivalent of a record in SQL databases). Documents are just JSON blobs that can contain arbitrary name -&gt; value pairs (unlike SQL records, which can only contain values for predeclared columns).&lt;br /&gt;&lt;br /&gt;To distinguish between different kinds of documents, Raindrop assigns each a schema (similar to a table in SQL parlance) that describes (and may one day constrain) its properties. The &lt;tt&gt;rd.msg.email&lt;/tt&gt; schema is the primary schema representing an email message, while the &lt;tt&gt;rd.mailing-list&lt;/tt&gt; is the schema representing a mailing list, and the &lt;tt&gt;rd.msg.email.mailing-list&lt;/tt&gt; is a simple schema that associates messages with their lists.&lt;br /&gt;&lt;br /&gt;(In an SQL database, &lt;tt&gt;rd.msg.email&lt;/tt&gt; and &lt;tt&gt;rd.mailing-list&lt;/tt&gt; would be tables whose rows represent email messages and mailing lists, while &lt;tt&gt;rd.msg.email.mailing-list&lt;/tt&gt; would be a table whose rows map one to the other.)&lt;br /&gt;&lt;br /&gt;Note that there&#39;s a many-to-one relationship between messages and lists, since messages belong to a single list, although lists contain many messages, so &lt;tt&gt;rd.msg.email.mailing-list&lt;/tt&gt; isn&#39;t strictly necessary. Its &lt;tt&gt;list-id&lt;/tt&gt; property (which identifies the list to which the message belongs) could simply be a property of &lt;tt&gt;rd.msg.email&lt;/tt&gt; docs (or, in SQL terms, a foreign key in the &lt;tt&gt;rd.msg.email&lt;/tt&gt; table).&lt;br /&gt;&lt;br /&gt;But putting it into its own document has several advantages. First, it improves robustness, as it reduces the possibility of conflicts between extensions and core code writing to the same documents.&lt;br /&gt;&lt;br /&gt;It also improves write performance, as it&#39;s faster to add a document than to modify an existing one (although index generation and read performance can be an issue).&lt;br /&gt;&lt;br /&gt;Finally, it improves extensibility, because it makes it possible to write an extension that extends the backend mailing list extension.&lt;br /&gt;&lt;br /&gt;That&#39;s because Raindrop&#39;s incoming message processing model allows extensions to observe the creation of any kind of document, including those created by other extensions.&lt;br /&gt;&lt;br /&gt;So just as the mailing list extension observes the creation of &lt;tt&gt;rd.msg.email&lt;/tt&gt; documents, another extension can observe the creation of &lt;tt&gt;rd.msg.email.mailing-list&lt;/tt&gt; documents and process them further in some useful way. If the mailing list extension simply modified the original document instead of creating its own, that would require some additional and more complicated API.&lt;br /&gt;&lt;h3&gt;The Backend Extension&lt;/h3&gt;The primary function of the backend extension is to examine every incoming message and dress the ones from mailing lists with some additional structured information that the frontend can use to organize them.&lt;br /&gt;&lt;br /&gt;Backend extensions are accompanied by a JSON manifest that tells Raindrop what kinds of incoming documents it wants to intercept. The mailing list extension&#39;s manifest registers it as an observer of incoming &lt;tt&gt;rd.msg.email&lt;/tt&gt; documents, which get created when Raindrop retrieves an email message:&lt;br /&gt;&lt;pre style=&quot;background-color: rgb(238, 238, 238); border: 1px solid rgb(187, 187, 187); color: black; padding: 10px;&quot;&gt;&quot;schemas&quot; : {&lt;br /&gt;  &quot;rd.ext.workqueue&quot; : {&lt;br /&gt;      &quot;source_schemas&quot; : [&quot;rd.msg.email&quot;],&lt;br /&gt;...&lt;/pre&gt;&lt;br /&gt;The extension itself is a Python script with a &lt;tt&gt;handler&lt;/tt&gt; function that gets passed the &lt;tt&gt;rd.msg.email&lt;/tt&gt; document and looks to see if it contains a &lt;tt&gt;List-ID&lt;/tt&gt; header (or, in certain cases, another identifier) identifying the mailing list from which the message comes:&lt;br /&gt;&lt;pre style=&quot;background-color: rgb(238, 238, 238); border: 1px solid rgb(187, 187, 187); color: black; padding: 10px;&quot;&gt;def handler(message):&lt;br /&gt;  ...&lt;br /&gt;  if &#39;list-id&#39; in message[&#39;headers&#39;]:&lt;br /&gt;      # Extract the ID and name of the mailing list from the list-id header.&lt;br /&gt;      # Some mailing lists give only the ID, but others (Google Groups,&lt;br /&gt;      # Mailman) provide both using the format &#39;NAME &amp;lt;id&amp;gt;&#39;, so we extract them&lt;br /&gt;      # separately if we detect that format.&lt;br /&gt;      list_id = message[&#39;headers&#39;][&#39;list-id&#39;][0]&lt;br /&gt;  ...&lt;/pre&gt;&lt;br /&gt;If it doesn&#39;t find a list identifier, it simply returns, and Raindrop continues processing the message:&lt;br /&gt;&lt;pre style=&quot;background-color: rgb(238, 238, 238); border: 1px solid rgb(187, 187, 187); color: black; padding: 10px;&quot;&gt;if not list_id:&lt;br /&gt;    logger.debug(&quot;NO LIST ID; ignoring message %s&quot;, message_id)&lt;br /&gt;    return&lt;/pre&gt;&lt;br /&gt;Otherwise, it calls Raindrop&#39;s &lt;tt&gt;emit_schema&lt;/tt&gt; function to create an &lt;tt&gt;rd.msg.email.mailing-list&lt;/tt&gt; document linking the message document to an &lt;tt&gt;rd.mailing-list&lt;/tt&gt; document representing the mailing list:&lt;br /&gt;&lt;pre style=&quot;background-color: rgb(238, 238, 238); border: 1px solid rgb(187, 187, 187); color: black; padding: 10px;&quot;&gt;emit_schema(&#39;rd.msg.email.mailing-list&#39;, { &#39;list_id&#39;: list_id })&lt;/pre&gt;&lt;br /&gt;In this function call, &lt;tt&gt;rd.msg.email.mailing-list&lt;/tt&gt; is the type of document to create, while &lt;tt&gt;{ &#39;list_id&#39;: list_id }&lt;/tt&gt; is the document itself, written as Python that will get serialized to JSON.&lt;br /&gt;&lt;br /&gt;A document created inside a backend extension like this automatically gets a reference to the document the extension is processing (i.e. the &lt;tt&gt;rd.msg.email&lt;/tt&gt; document), so the only thing it has to explicitly include is a reference to the list document, in the form of a &lt;tt&gt;list_id&lt;/tt&gt; property whose value is the list identifier.&lt;br /&gt;&lt;br /&gt;The extension also checks if there&#39;s an &lt;tt&gt;rd.mailing-list&lt;/tt&gt; document in the database for the mailing list itself, and if not, it creates one, populating it with information from the message&#39;s &lt;tt&gt;List-*&lt;/tt&gt; headers, like how to unsubscribe from the list. Otherwise, it updates the existing mailing list document if the message&#39;s &lt;tt&gt;List-*&lt;/tt&gt; headers contain updates.&lt;br /&gt;&lt;h3&gt;The Frontend Extension&lt;/h3&gt;The frontend extension uses the information extracted by the backend to help users manage mailing lists in the Inflow application.&lt;br /&gt;&lt;br /&gt;It adds a widget to the Home view that shows you the last few messages from your lists at the bottom of the page, so you can keep an eye on those messages without having to give them your full attention:&lt;br /&gt;&lt;br /&gt;&lt;div class=&quot;separator&quot; style=&quot;clear: both; text-align: center;&quot;&gt;&lt;a href=&quot;http://www.melez.com/mykzilla/uploaded_images/latest-list-messages-714113.png&quot; imageanchor=&quot;1&quot; style=&quot;margin-left: 1em; margin-right: 1em;&quot;&gt;&lt;img src=&quot;http://www.melez.com/mykzilla/uploaded_images/latest-list-messages-714111.png&quot; height=&quot;176&quot; width=&quot;320&quot; border=&quot;0&quot; /&gt;&lt;/a&gt;&lt;br /&gt;&lt;/div&gt;&lt;br /&gt;&lt;br /&gt;It adds a list of your mailing lists to the Organizer widget:&lt;br /&gt;&lt;br /&gt;&lt;div class=&quot;separator&quot; style=&quot;clear: both; text-align: center;&quot;&gt;&lt;a href=&quot;http://www.melez.com/mykzilla/uploaded_images/mailing-list-list-722772.png&quot; imageanchor=&quot;1&quot; style=&quot;margin-left: 1em; margin-right: 1em;&quot;&gt;&lt;img src=&quot;http://www.melez.com/mykzilla/uploaded_images/mailing-list-list-722768.png&quot; height=&quot;320&quot; width=&quot;190&quot; border=&quot;0&quot; /&gt;&lt;/a&gt;&lt;br /&gt;&lt;/div&gt;&lt;br /&gt;&lt;br /&gt;And when you click on the name of a list, it shows you its conversations in the conversation pane:&lt;br /&gt;&lt;br /&gt;&lt;div class=&quot;separator&quot; style=&quot;clear: both; text-align: center;&quot;&gt;&lt;a href=&quot;http://www.melez.com/mykzilla/uploaded_images/list-conversations-763392.png&quot; imageanchor=&quot;1&quot; style=&quot;margin-left: 1em; margin-right: 1em;&quot;&gt;&lt;img src=&quot;http://www.melez.com/mykzilla/uploaded_images/list-conversations-763369.png&quot; height=&quot;201&quot; width=&quot;320&quot; border=&quot;0&quot; /&gt;&lt;/a&gt;&lt;br /&gt;&lt;/div&gt;&lt;br /&gt;&lt;br /&gt;In traditional mail clients, users who want to break out their list messages into separate buckets like this typically have to create a folder for each list to contain its messages and then a filter for each list to move incoming list messages into the appropriate folders. The extension does this for you automatically!&lt;br /&gt;&lt;br /&gt;Finally, while viewing list conversations, if the extension knows how to unsubscribe you from the list, it displays an Unsubscribe button:&lt;br /&gt;&lt;br /&gt;&lt;div class=&quot;separator&quot; style=&quot;clear: both; text-align: center;&quot;&gt;&lt;a href=&quot;http://www.melez.com/mykzilla/uploaded_images/unsubscribe-button-794151.png&quot; imageanchor=&quot;1&quot; style=&quot;margin-left: 1em; margin-right: 1em;&quot;&gt;&lt;img src=&quot;http://www.melez.com/mykzilla/uploaded_images/unsubscribe-button-794149.png&quot; height=&quot;201&quot; width=&quot;320&quot; border=&quot;0&quot; /&gt;&lt;/a&gt;&lt;br /&gt;&lt;/div&gt;&lt;br /&gt;&lt;br /&gt;Pressing the button (and then confirming your decision) unsubscribes you from the list. You don&#39;t have to do anything else, like remembering your username/password for some web page, sending an email, or confirming your request with the list admin. The extensions handle all those details for you so you don&#39;t have to know about them!&lt;br /&gt;&lt;h3&gt;List Unsubscription&lt;/h3&gt;In case you do want to know the details, however, it goes like this...&lt;br /&gt;&lt;br /&gt;First, the frontend extension sends a message to the list&#39;s admin address requesting unsubscription, with a certain command (like &quot;unsubscribe&quot;) in the subject or body of the message (lists often specify exactly what command to send in the &lt;tt&gt;mailto:&lt;/tt&gt; link they include in the &lt;tt&gt;List-Unsubscribe&lt;/tt&gt; header):&lt;br /&gt;&lt;pre style=&quot;background-color: rgb(238, 238, 238); border: 1px solid rgb(187, 187, 187); color: black; padding: 10px;&quot;&gt;From: Jan Reilly &lt;jan@example.com&gt;&lt;br /&gt;To: wasbigtalk-admin@example.com&lt;br /&gt;Subject: unsubscribe&lt;/jan@example.com&gt;&lt;/pre&gt;&lt;br /&gt;Then the server responds with a message requesting confirmation of the request, often putting a unique token into the Subject or Reply-To header to track the request:&lt;br /&gt;&lt;pre style=&quot;background-color: rgb(238, 238, 238); border: 1px solid rgb(187, 187, 187); color: black; padding: 10px;&quot;&gt;From: wasbigtalk-admin@example.com&lt;br /&gt;To: jan@example.com&lt;br /&gt;Subject: please confirm unsubscribe from wasbigtalk (4bc3b7e439fd)&lt;br /&gt;&lt;br /&gt;Hello jan@example.com,&lt;br /&gt;&lt;br /&gt;We have received a request to unsubscribe you from wasbigtalk.&lt;br /&gt;Please confirm this request to unsubscribe by replying to this email.&lt;br /&gt;...&lt;/pre&gt;&lt;br /&gt;Then the backend extension responds with a message confirming the request that includes the unique token:&lt;br /&gt;&lt;pre style=&quot;background-color: rgb(238, 238, 238); border: 1px solid rgb(187, 187, 187); color: black; padding: 10px;&quot;&gt;From: jan@example.com&lt;br /&gt;To: wasbigtalk-admin@example.com&lt;br /&gt;Subject: Re: please confirm unsubscribe from wasbigtalk (4bc3b7e439fd)&lt;/pre&gt;&lt;br /&gt;Finally, the server responds with a message confirming that the subscriber has, indeed, been unsubscribed:&lt;br /&gt;&lt;pre style=&quot;background-color: rgb(238, 238, 238); border: 1px solid rgb(187, 187, 187); color: black; padding: 10px;&quot;&gt;From: wasbigtalk-admin@example.com&lt;br /&gt;To: jan@example.com&lt;br /&gt;Subject: you have been unsubscribed from wasbigtalk&lt;br /&gt;&lt;br /&gt;Hello jan@example.com,&lt;br /&gt;&lt;br /&gt;Your unsubscription from wasbigtalk was successful.&lt;br /&gt;...&lt;/pre&gt;&lt;br /&gt;At this point, the backend extension marks the list unsubscribed in the database, and the frontend extension marks it unsubscribed in the user interface.&lt;br /&gt;&lt;br /&gt;This process matches the way much mailing list server software works, although there are daemons in the details, so the extensions have to be programmed to support each server individually.&lt;br /&gt;&lt;br /&gt;Currently, they know how to handle &lt;a href=&quot;http://groups.google.com/&quot;&gt;Google Groups&lt;/a&gt; and &lt;a href=&quot;http://www.gnu.org/software/mailman/&quot;&gt;Mailman&lt;/a&gt; lists. &lt;a href=&quot;http://www.mj2.org/&quot;&gt;Majordomo2&lt;/a&gt; (used by the &lt;a href=&quot;http://www.bugzilla.org/&quot;&gt;Bugzilla&lt;/a&gt; and &lt;a href=&quot;http://www.openbsd.org/&quot;&gt;OpenBSD&lt;/a&gt; projects, among others) is not supported, because it doesn&#39;t send &lt;tt&gt;List-*&lt;/tt&gt; headers (alhough supposedly it can be configured to do so). The &lt;a href=&quot;http://www.w3.org/&quot;&gt;W3C&lt;/a&gt;&#39;s list server is not yet supported, although it does send &lt;tt&gt;List-*&lt;/tt&gt; headers, and support should be fairly easy to add.&lt;br /&gt;&lt;br /&gt;Note that some of the processing the extension does is (locale-dependent) &quot;screen&quot;-scraping, as Google Groups and Mailman don&#39;t consistently identify the list ID and message type in some of their correspondence. In the long run, hopefully server software will improve in that regard. Perhaps someone can spearhead an effort to make it so?&lt;br /&gt;&lt;h3&gt;The Future&lt;/h3&gt;The extensions&#39; current features fit in well with Raindrop&#39;s goal of helping people better handle their flood of incoming messages. But there is surely much more they could do to help in this regard.&lt;br /&gt;&lt;br /&gt;Besides general improvements to reliability and robustness--like support for additional list servers and handling of localized admin messages--they could let you resubscribe to a mailing list from which you&#39;ve unsubscribed. And perhaps they could automatically fetch the messages you missed while you were away. Or even retrieve the entire archive of a list to which you&#39;re subscribed, so you can browse the archive in Raindrop!&lt;br /&gt;&lt;br /&gt;What bugs you about mailing lists? And how might Raindrop&#39;s mailing list extensions make them easier (and even funner) to use?</content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/7015776887411907934/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=7015776887411907934' title='7 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7015776887411907934'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7015776887411907934'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2009/11/skinny-on-raindrops-mailing-list.html' title='The Skinny on Raindrop&#39;s Mailing List Extensions'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>7</thr:total></entry><entry><id>tag:blogger.com,1999:blog-18929277.post-7740719470656815276</id><published>2009-11-04T15:58:00.001-08:00</published><updated>2009-11-04T15:58:03.687-08:00</updated><title type='text'>Building/Releasing Personas</title><content type='html'>Want to know how a popular extension like Personas gets built and released? Neither do I! Yet I know anyway. And I&#39;ve written it down for your edification! So &lt;a  href=&quot;https://wiki.mozilla.org/Labs/Personas/Build&quot;&gt;check it out&lt;/a&gt;.&lt;br&gt; &lt;br&gt; </content><link rel='replies' type='application/atom+xml' href='http://mykzilla.blogspot.com/feeds/7740719470656815276/comments/default' title='Post Comments'/><link rel='replies' type='text/html' href='http://www.blogger.com/comment.g?blogID=18929277&amp;postID=7740719470656815276' title='0 Comments'/><link rel='edit' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7740719470656815276'/><link rel='self' type='application/atom+xml' href='http://www.blogger.com/feeds/18929277/posts/default/7740719470656815276'/><link rel='alternate' type='text/html' href='http://mykzilla.blogspot.com/2009/11/buildingreleasing-personas.html' title='Building/Releasing Personas'/><author><name>Myk Melez</name><uri>http://www.blogger.com/profile/01837818348188071923</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='32' height='32' src='http://2.bp.blogspot.com/-_YZppM5V97U/VedxzrpG9BI/AAAAAAAAAGA/wHrKKAgCoH0/s220/headshot-2014.jpg'/></author><thr:total>0</thr:total></entry></feed>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/resources/feed_rss_wordpress.xml
@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
+    xmlns:content="http://purl.org/rss/1.0/modules/content/"
+    xmlns:wfw="http://wellformedweb.org/CommentAPI/"
+    xmlns:dc="http://purl.org/dc/elements/1.1/"
+    xmlns:atom="http://www.w3.org/2005/Atom"
+    xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
+    xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
+    xmlns:georss="http://www.georss.org/georss" xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#" xmlns:media="http://search.yahoo.com/mrss/"
+    >
+
+    <channel>
+        <title>justasimpletest2016</title>
+        <atom:link href="https://justasimpletest2016.wordpress.com/feed/" rel="self" type="application/rss+xml" />
+        <link>https://justasimpletest2016.wordpress.com</link>
+        <description></description>
+        <lastBuildDate>Fri, 26 Feb 2016 22:08:00 +0000</lastBuildDate>
+        <language>en</language>
+        <sy:updatePeriod>hourly</sy:updatePeriod>
+        <sy:updateFrequency>1</sy:updateFrequency>
+        <generator>http://wordpress.com/</generator>
+        <cloud domain='justasimpletest2016.wordpress.com' port='80' path='/?rsscloud=notify' registerProcedure='' protocol='http-post' />
+        <image>
+            <url>https://s2.wp.com/i/buttonw-com.png</url>
+            <title>justasimpletest2016</title>
+            <link>https://justasimpletest2016.wordpress.com</link>
+        </image>
+        <atom:link rel="search" type="application/opensearchdescription+xml" href="https://justasimpletest2016.wordpress.com/osd.xml" title="justasimpletest2016" />
+        <atom:link rel='hub' href='https://justasimpletest2016.wordpress.com/?pushpress=hub'/>
+        <item>
+            <title>Hello World!</title>
+            <link>https://justasimpletest2016.wordpress.com/2016/02/26/hello-world/</link>
+            <comments>https://justasimpletest2016.wordpress.com/2016/02/26/hello-world/#respond</comments>
+            <pubDate>Fri, 26 Feb 2016 22:07:46 +0000</pubDate>
+            <dc:creator><![CDATA[justasimpletest2016]]></dc:creator>
+            <category><![CDATA[Uncategorized]]></category>
+
+            <guid isPermaLink="false">http://justasimpletest2016.wordpress.com/?p=6</guid>
+            <description><![CDATA[What&#8217;s up?<img alt="" border="0" src="https://pixel.wp.com/b.gif?host=justasimpletest2016.wordpress.com&#038;blog=107552275&#038;post=6&#038;subd=justasimpletest2016&#038;ref=&#038;feed=1" width="1" height="1" />]]></description>
+            <content:encoded><![CDATA[<p>What&#8217;s up?</p><br />  <a rel="nofollow" href="http://feeds.wordpress.com/1.0/gocomments/justasimpletest2016.wordpress.com/6/"><img alt="" border="0" src="http://feeds.wordpress.com/1.0/comments/justasimpletest2016.wordpress.com/6/" /></a> <img alt="" border="0" src="https://pixel.wp.com/b.gif?host=justasimpletest2016.wordpress.com&#038;blog=107552275&#038;post=6&#038;subd=justasimpletest2016&#038;ref=&#038;feed=1" width="1" height="1" />]]></content:encoded>
+            <wfw:commentRss>https://justasimpletest2016.wordpress.com/2016/02/26/hello-world/feed/</wfw:commentRss>
+            <slash:comments>0</slash:comments>
+
+            <media:content url="https://2.gravatar.com/avatar/ba82024e6f43884f4ae0e379273b0743?s=96&#38;d=identicon&#38;r=G" medium="image">
+                <media:title type="html">justasimpletest2016</media:title>
+            </media:content>
+        </item>
+        <item>
+            <title>The second post</title>
+            <link>https://justasimpletest2016.wordpress.com/2016/02/26/the-second-post/</link>
+            <comments>https://justasimpletest2016.wordpress.com/2016/02/26/the-second-post/#respond</comments>
+            <pubDate>Fri, 26 Feb 2016 00:23:04 +0000</pubDate>
+            <dc:creator><![CDATA[justasimpletest2016]]></dc:creator>
+            <category><![CDATA[Uncategorized]]></category>
+
+            <guid isPermaLink="false">http://justasimpletest2016.wordpress.com/2016/02/26/the-second-post/</guid>
+            <description><![CDATA[Hello.<img alt="" border="0" src="https://pixel.wp.com/b.gif?host=justasimpletest2016.wordpress.com&#038;blog=107552275&#038;post=4&#038;subd=justasimpletest2016&#038;ref=&#038;feed=1" width="1" height="1" />]]></description>
+            <content:encoded><![CDATA[<p>Hello.</p><br />  <a rel="nofollow" href="http://feeds.wordpress.com/1.0/gocomments/justasimpletest2016.wordpress.com/4/"><img alt="" border="0" src="http://feeds.wordpress.com/1.0/comments/justasimpletest2016.wordpress.com/4/" /></a> <img alt="" border="0" src="https://pixel.wp.com/b.gif?host=justasimpletest2016.wordpress.com&#038;blog=107552275&#038;post=4&#038;subd=justasimpletest2016&#038;ref=&#038;feed=1" width="1" height="1" />]]></content:encoded>
+            <wfw:commentRss>https://justasimpletest2016.wordpress.com/2016/02/26/the-second-post/feed/</wfw:commentRss>
+            <slash:comments>0</slash:comments>
+
+            <media:content url="https://2.gravatar.com/avatar/ba82024e6f43884f4ae0e379273b0743?s=96&#38;d=identicon&#38;r=G" medium="image">
+                <media:title type="html">justasimpletest2016</media:title>
+            </media:content>
+        </item>
+        <item>
+            <title>This is just a test</title>
+            <link>https://justasimpletest2016.wordpress.com/2016/02/26/this-is-just-a-test/</link>
+            <comments>https://justasimpletest2016.wordpress.com/2016/02/26/this-is-just-a-test/#respond</comments>
+            <pubDate>Fri, 26 Feb 2016 00:22:58 +0000</pubDate>
+            <dc:creator><![CDATA[justasimpletest2016]]></dc:creator>
+            <category><![CDATA[Uncategorized]]></category>
+
+            <guid isPermaLink="false">http://justasimpletest2016.wordpress.com/?p=2</guid>
+            <description><![CDATA[Hello World. First blog post from WordPress.<img alt="" border="0" src="https://pixel.wp.com/b.gif?host=justasimpletest2016.wordpress.com&#038;blog=107552275&#038;post=2&#038;subd=justasimpletest2016&#038;ref=&#038;feed=1" width="1" height="1" />]]></description>
+            <content:encoded><![CDATA[<p>Hello World. First blog post from WordPress.</p><br />  <a rel="nofollow" href="http://feeds.wordpress.com/1.0/gocomments/justasimpletest2016.wordpress.com/2/"><img alt="" border="0" src="http://feeds.wordpress.com/1.0/comments/justasimpletest2016.wordpress.com/2/" /></a> <img alt="" border="0" src="https://pixel.wp.com/b.gif?host=justasimpletest2016.wordpress.com&#038;blog=107552275&#038;post=2&#038;subd=justasimpletest2016&#038;ref=&#038;feed=1" width="1" height="1" />]]></content:encoded>
+            <wfw:commentRss>https://justasimpletest2016.wordpress.com/2016/02/26/this-is-just-a-test/feed/</wfw:commentRss>
+            <slash:comments>0</slash:comments>
+
+            <media:content url="https://2.gravatar.com/avatar/ba82024e6f43884f4ae0e379273b0743?s=96&#38;d=identicon&#38;r=G" medium="image">
+                <media:title type="html">justasimpletest2016</media:title>
+            </media:content>
+        </item>
+    </channel>
+</rss>
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/dlc/catalog/TestDownloadContentCatalog.java
@@ -1,16 +1,16 @@
 /* -*- 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.util.AtomicFile;
+import android.support.v4.util.AtomicFile;
 
 import org.junit.Assert;
 import org.junit.Assume;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteBlogger.java
@@ -0,0 +1,74 @@
+/* -*- 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.feeds.knownsites;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.helpers.AssertUtil;
+
+@RunWith(TestRunner.class)
+public class TestKnownSiteBlogger {
+    /**
+     * Test that the search string is a substring of some known URLs.
+     */
+    @Test
+    public void testURLSearchString() {
+        final KnownSite blogger = new KnownSiteBlogger();
+        final String searchString = blogger.getURLSearchString();
+
+        AssertUtil.assertContains(
+                "http://mykzilla.blogspot.com/",
+                searchString);
+
+        AssertUtil.assertContains(
+                "http://example.blogspot.com",
+                searchString);
+
+        AssertUtil.assertContains(
+                "https://mykzilla.blogspot.com/2015/06/introducing-pluotsorbet.html",
+                searchString);
+
+        AssertUtil.assertContains(
+                "http://android-developers.blogspot.com/2016/02/android-support-library-232.html",
+                searchString);
+
+        AssertUtil.assertContainsNot(
+                "http://www.mozilla.org",
+                searchString);
+    }
+
+    /**
+     * Test that we get a feed URL for valid Blogger URLs.
+     */
+    @Test
+    public void testGettingFeedFromURL() {
+        final KnownSite blogger = new KnownSiteBlogger();
+
+        Assert.assertEquals(
+                "https://mykzilla.blogspot.com/feeds/posts/default",
+                blogger.getFeedFromURL("http://mykzilla.blogspot.com/"));
+
+        Assert.assertEquals(
+                "https://example.blogspot.com/feeds/posts/default",
+                blogger.getFeedFromURL("http://example.blogspot.com"));
+
+        Assert.assertEquals(
+                "https://mykzilla.blogspot.com/feeds/posts/default",
+                blogger.getFeedFromURL("https://mykzilla.blogspot.com/2015/06/introducing-pluotsorbet.html"));
+
+        Assert.assertEquals(
+                "https://android-developers.blogspot.com/feeds/posts/default",
+                blogger.getFeedFromURL("http://android-developers.blogspot.com/2016/02/android-support-library-232.html"));
+
+        Assert.assertEquals(
+                "https://example.blogspot.com/feeds/posts/default",
+                blogger.getFeedFromURL("http://example.blogspot.com/2016/03/i-moved-to-example.blogspot.com"));
+
+        Assert.assertNull(blogger.getFeedFromURL("http://www.mozilla.org"));
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/knownsites/TestKnownSiteMedium.java
@@ -0,0 +1,66 @@
+/* -*- 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.feeds.knownsites;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.helpers.AssertUtil;
+
+@RunWith(TestRunner.class)
+public class TestKnownSiteMedium {
+    /**
+     * Test that the search string is a substring of some known URLs.
+     */
+    @Test
+    public void testURLSearchString() {
+        final KnownSite medium = new KnownSiteMedium();
+        final String searchString = medium.getURLSearchString();
+
+        AssertUtil.assertContains(
+                "https://medium.com/@Antlam/",
+                searchString);
+
+        AssertUtil.assertContains(
+                "https://medium.com/google-developers",
+                searchString);
+
+        AssertUtil.assertContains(
+                "http://medium.com/@brandonshin/how-slackbot-forced-us-to-workout-7b4741a2de73",
+                searchString
+        );
+
+        AssertUtil.assertContainsNot(
+                "http://www.mozilla.org",
+                searchString);
+    }
+
+    /**
+     * Test that we get a feed URL for valid Medium URLs.
+     */
+    @Test
+    public void testGettingFeedFromURL() {
+        final KnownSite medium = new KnownSiteMedium();
+
+        Assert.assertEquals(
+                "https://medium.com/feed/@Antlam",
+                medium.getFeedFromURL("https://medium.com/@Antlam/")
+        );
+
+        Assert.assertEquals(
+                "https://medium.com/feed/google-developers",
+                medium.getFeedFromURL("https://medium.com/google-developers")
+        );
+
+        Assert.assertEquals(
+                "https://medium.com/feed/@brandonshin",
+                medium.getFeedFromURL("http://medium.com/@brandonshin/how-slackbot-forced-us-to-workout-7b4741a2de73")
+        );
+
+        Assert.assertNull(medium.getFeedFromURL("http://www.mozilla.org"));
+    }
+}
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/parser/TestSimpleFeedParser.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/feeds/parser/TestSimpleFeedParser.java
@@ -15,16 +15,18 @@ import java.io.BufferedReader;
 import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.Reader;
 import java.io.UnsupportedEncodingException;
 import java.net.URISyntaxException;
 import java.net.URL;
+import java.text.SimpleDateFormat;
+import java.util.Locale;
 
 @RunWith(TestRunner.class)
 public class TestSimpleFeedParser {
     /**
      * Parse and verify the RSS example from Wikipedia:
      * https://en.wikipedia.org/wiki/RSS#Example
      */
     @Test
@@ -260,16 +262,61 @@ public class TestSimpleFeedParser {
         Item item = feed.getLastItem();
 
         Assert.assertNotNull(item);
         Assert.assertEquals("Google: “Dramatische Verbesserungen” für Chrome in iOS", item.getTitle());
         Assert.assertEquals("http://www.heise.de/newsticker/meldung/Google-Dramatische-Verbesserungen-fuer-Chrome-in-iOS-3085808.html?wt_mc=rss.ho.beitrag.atom", item.getURL());
         Assert.assertEquals(1453915920000L, item.getTimestamp());
     }
 
+    @Test
+    public void testWordpressFeed() throws Exception {
+        InputStream stream = openFeed("feed_rss_wordpress.xml");
+
+        SimpleFeedParser parser = new SimpleFeedParser();
+        Feed feed = parser.parse(stream);
+
+        Assert.assertNotNull(feed);
+        Assert.assertEquals("justasimpletest2016", feed.getTitle());
+        Assert.assertEquals("https://justasimpletest2016.wordpress.com", feed.getWebsiteURL());
+        Assert.assertEquals("https://justasimpletest2016.wordpress.com/feed/", feed.getFeedURL());
+        Assert.assertTrue(feed.isSufficientlyComplete());
+
+        Item item = feed.getLastItem();
+
+        Assert.assertNotNull(item);
+        Assert.assertEquals("Hello World!", item.getTitle());
+        Assert.assertEquals("https://justasimpletest2016.wordpress.com/2016/02/26/hello-world/", item.getURL());
+        Assert.assertEquals(1456524466000L, item.getTimestamp());
+    }
+
+    /**
+     * Parse and test a snapshot of mykzilla.blogspot.com
+     */
+    @Test
+    public void testBloggerFeed() throws Exception {
+        InputStream stream = openFeed("feed_atom_blogger.xml");
+
+        SimpleFeedParser parser = new SimpleFeedParser();
+        Feed feed = parser.parse(stream);
+
+        Assert.assertNotNull(feed);
+        Assert.assertEquals("mykzilla", feed.getTitle());
+        Assert.assertEquals("http://mykzilla.blogspot.com/", feed.getWebsiteURL());
+        Assert.assertEquals("http://www.blogger.com/feeds/18929277/posts/default", feed.getFeedURL());
+        Assert.assertTrue(feed.isSufficientlyComplete());
+
+        Item item = feed.getLastItem();
+
+        Assert.assertNotNull(item);
+        Assert.assertEquals("URL Has Been Changed", item.getTitle());
+        Assert.assertEquals("http://mykzilla.blogspot.com/2016/01/url-has-been-changed.html", item.getURL());
+        Assert.assertEquals(1452531451366L, item.getTimestamp());
+    }
+
     private InputStream openFeed(String fileName) throws URISyntaxException, FileNotFoundException, UnsupportedEncodingException {
         URL url = getClass().getResource("/" + fileName);
         if (url == null) {
             throw new FileNotFoundException(fileName);
         }
 
         return new BufferedInputStream(new FileInputStream(url.getPath()));
     }
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/helpers/AssertUtil.java
@@ -0,0 +1,29 @@
+/* -*- 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.helpers;
+
+import org.junit.Assert;
+
+/**
+ * Some additional assert methods on top of org.junit.Assert.
+ */
+public class AssertUtil {
+    /**
+     * Asserts that the String {@code text} contains the String {@code sequence}. If it doesn't then
+     * an {@link AssertionError} will be thrown.
+     */
+    public static void assertContains(String text, String sequence) {
+        Assert.assertTrue(text.contains(sequence));
+    }
+
+    /**
+     * Asserts that the String {@code text} contains not the String {@code sequence}. If it does
+     * then an {@link AssertionError} will be thrown.
+     */
+    public static void assertContainsNot(String text, String sequence) {
+        Assert.assertFalse(text.contains(sequence));
+    }
+}
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -3766,16 +3766,20 @@ pref("layout.css.scroll-snap.enabled", f
 // temporary popup windows.  Setting this to false would make the default
 // level "parent" which is implemented with managed windows.
 // A problem with using managed windows is that metacity sometimes deactivates
 // the parent window when the managed popup is shown.
 pref("ui.panel.default_level_parent", true);
 
 pref("mousewheel.system_scroll_override_on_root_content.enabled", false);
 
+// Forward downloads with known OMA MIME types to Android's download manager
+// instead of downloading them in the browser.
+pref("browser.download.forward_oma_android_download_manager", false);
+
 # ANDROID
 #endif
 
 #ifndef ANDROID
 #ifndef XP_MACOSX
 #ifdef XP_UNIX
 // Handled differently under Mac/Windows
 pref("network.protocol-handler.warn-external.file", false);
--- a/toolkit/components/extensions/ext-webRequest.js
+++ b/toolkit/components/extensions/ext-webRequest.js
@@ -8,17 +8,16 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/MatchPattern.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "WebRequest",
                                   "resource://gre/modules/WebRequest.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   SingletonEventManager,
   runSafeSync,
-  ignoreEvent,
 } = ExtensionUtils;
 
 // EventManager-like class specifically for WebRequest. Inherits from
 // SingletonEventManager. Takes care of converting |details| parameter
 // when invoking listeners.
 function WebRequestEventManager(context, eventName) {
   let name = `webRequest.${eventName}`;
   let register = (callback, filter, info) => {
@@ -48,17 +47,17 @@ function WebRequestEventManager(context,
 
       // Fills in tabId typically.
       let result = {};
       extensions.emit("fill-browser-data", data.browser, data2, result);
       if (result.cancel) {
         return;
       }
 
-      let optional = ["requestHeaders", "responseHeaders", "statusCode", "statusLine", "redirectUrl"];
+      let optional = ["requestHeaders", "responseHeaders", "statusCode", "statusLine", "error", "redirectUrl"];
       for (let opt of optional) {
         if (opt in data) {
           data2[opt] = data[opt];
         }
       }
 
       return runSafeSync(context, callback, data2);
     };
@@ -102,18 +101,16 @@ extensions.registerSchemaAPI("webRequest
   return {
     webRequest: {
       onBeforeRequest: new WebRequestEventManager(context, "onBeforeRequest").api(),
       onBeforeSendHeaders: new WebRequestEventManager(context, "onBeforeSendHeaders").api(),
       onSendHeaders: new WebRequestEventManager(context, "onSendHeaders").api(),
       onHeadersReceived: new WebRequestEventManager(context, "onHeadersReceived").api(),
       onBeforeRedirect: new WebRequestEventManager(context, "onBeforeRedirect").api(),
       onResponseStarted: new WebRequestEventManager(context, "onResponseStarted").api(),
+      onErrorOccurred: new WebRequestEventManager(context, "onErrorOccurred").api(),
       onCompleted: new WebRequestEventManager(context, "onCompleted").api(),
       handlerBehaviorChanged: function() {
         // TODO: Flush all caches.
       },
-
-      // TODO
-      onErrorOccurred: ignoreEvent(context, "webRequest.onErrorOccurred"),
     },
   };
 });
--- a/toolkit/components/extensions/test/mochitest/file_WebRequest_page1.html
+++ b/toolkit/components/extensions/test/mochitest/file_WebRequest_page1.html
@@ -21,11 +21,12 @@
 
 <script src="file_script_xhr.js"></script>
 
 <script src="nonexistent_script_url.js"></script>
 
 <iframe src="file_WebRequest_page2.html" width="200" height="200"></iframe>
 <iframe src="redirection.sjs" width="200" height="200"></iframe>
 <iframe src="data:text/plain,webRequestTest" width="200" height="200"></iframe>
-
+<iframe src="data:text/plain,webRequestTest_bad" width="200" height="200"></iframe>
+<iframe src="https://invalid.localhost/" width="200" height="200"></iframe>
 </body>
 </html>
--- a/toolkit/components/extensions/test/mochitest/test_ext_webrequest.html
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest.html
@@ -28,31 +28,34 @@ const expected_requested = [BASE + "/fil
                             BASE + "/file_script_bad.js",
                             BASE + "/file_script_redirect.js",
                             BASE + "/file_script_xhr.js",
                             BASE + "/file_WebRequest_page2.html",
                             BASE + "/nonexistent_script_url.js",
                             BASE + "/redirection.sjs",
                             BASE + "/dummy_page.html",
                             BASE + "/xhr_resource",
+                            "https://invalid.localhost/",
+                            "data:text/plain,webRequestTest_bad",
                             "data:text/plain,webRequestTest"];
 
 const expected_beforeSendHeaders = [BASE + "/file_WebRequest_page1.html",
                               BASE + "/file_style_good.css",
                               BASE + "/file_style_redirect.css",
                               BASE + "/file_image_good.png",
                               BASE + "/file_image_redirect.png",
                               BASE + "/file_script_good.js",
                               BASE + "/file_script_redirect.js",
                               BASE + "/file_script_xhr.js",
                               BASE + "/file_WebRequest_page2.html",
                               BASE + "/nonexistent_script_url.js",
                               BASE + "/redirection.sjs",
                               BASE + "/dummy_page.html",
-                              BASE + "/xhr_resource"];
+                              BASE + "/xhr_resource",
+                              "https://invalid.localhost/"];
 
 const expected_sendHeaders = expected_beforeSendHeaders.filter(u => !/_redirect\./.test(u))
                             .concat(BASE + "/redirection.sjs");
 
 const expected_redirect = expected_beforeSendHeaders.filter(u => /_redirect\./.test(u))
                             .concat(BASE + "/redirection.sjs");
 
 const expected_response = [BASE + "/file_WebRequest_page1.html",
@@ -60,16 +63,18 @@ const expected_response = [BASE + "/file
                            BASE + "/file_image_good.png",
                            BASE + "/file_script_good.js",
                            BASE + "/file_script_xhr.js",
                            BASE + "/file_WebRequest_page2.html",
                            BASE + "/nonexistent_script_url.js",
                            BASE + "/dummy_page.html",
                            BASE + "/xhr_resource"];
 
+const expected_error = expected_requested.filter(u => /_bad\b|\binvalid\b/.test(u));
+
 const expected_complete = expected_response.concat("data:text/plain,webRequestTest");
 
 function removeDupes(list) {
   let j = 0;
   for (let i = 1; i < list.length; i++) {
     if (list[i] != list[j]) {
       j++;
       if (i != j) {
@@ -88,68 +93,69 @@ function compareLists(list1, list2, kind
   is(String(list1), String(list2), `${kind} URLs correct`);
 }
 
 function backgroundScript() {
   let checkCompleted = true;
   let savedTabId = -1;
 
   function shouldRecord(url) {
-    return url.startsWith(BASE) || /^data:.*\bwebRequestTest\b/.test(url);
+    return url.startsWith(BASE) || /^data:.*\bwebRequestTest|\/invalid\./.test(url);
   }
 
   let statuses = [
     {url: /_script_good\b/, code: 200, line: /^HTTP\/1.1 200 OK\b/i},
     {url: /\bredirection\b/, code: 302, line: /^HTTP\/1.1 302\b/},
     {url: /\bnonexistent_script_/, code: 404, line: /^HTTP\/1.1 404 Not Found\b/i},
   ];
   function checkStatus(details) {
     for (let {url, code, line} of statuses) {
       if (url.test(details.url)) {
-        browser.test.assertTrue(code === details.statusCode, `HTTP status code ${code} for ${details.url} (found ${details.statusCode})`);
+        browser.test.assertEq(code, details.statusCode, `HTTP status code ${code} for ${details.url} (found ${details.statusCode})`);
         browser.test.assertTrue(line.test(details.statusLine), `HTTP status line ${line} for ${details.url} (found ${details.statusLine})`);
       }
     }
   }
 
   function checkType(details) {
     let expected_type = "???";
-    if (details.url.indexOf("style") != -1) {
+    if (details.url.includes("style")) {
       expected_type = "stylesheet";
-    } else if (details.url.indexOf("image") != -1) {
+    } else if (details.url.includes("image")) {
       expected_type = "image";
-    } else if (details.url.indexOf("script") != -1) {
+    } else if (details.url.includes("script")) {
       expected_type = "script";
-    } else if (details.url.indexOf("page1") != -1) {
+    } else if (details.url.includes("page1")) {
       expected_type = "main_frame";
-    } else if (/page2|redirection|dummy_page|data:text\/(?:plain|html),/.test(details.url)) {
+    } else if (/page2|redirection|dummy_page|data:text\/(?:plain|html),|\/\/invalid\b/.test(details.url)) {
       expected_type = "sub_frame";
-    } else if (details.url.indexOf("xhr") != -1) {
+    } else if (details.url.includes("xhr")) {
       expected_type = "xmlhttprequest";
     }
     browser.test.assertEq(details.type, expected_type, "resource type is correct");
   }
 
   let requestIDs = new Map();
   let idDisposalEvents = new Set(["completed", "error", "redirect"]);
   function checkRequestId(details, event = "unknown") {
     let ids = requestIDs.get(details.url);
-    browser.test.assertTrue(ids && ids.has(details.requestId), `correct requestId for ${details.url} (${details.requestId} in [${ids && [...ids].join(", ")}])`);
+    browser.test.assertTrue(ids && ids.has(details.requestId), `correct requestId for ${details.url} in ${event} (${details.requestId} in [${ids && [...ids].join(", ")}])`);
     if (ids && idDisposalEvents.has(event)) {
       ids.delete(details.requestId);
     }
   }
 
   let frameIDs = new Map();
 
   let recorded = {requested: [],
                   beforeSendHeaders: [],
                   beforeRedirect: [],
                   sendHeaders: [],
                   responseStarted: [],
+                  error: [],
                   completed: []};
   let testHeaders = {
     request: {
       added: {
         "X-WebRequest-request": "text",
         "X-WebRequest-request-binary": "binary",
       },
       modified: {
@@ -281,27 +287,27 @@ function backgroundScript() {
         browser.test.assertTrue(details.tabId !== undefined, "tab ID defined");
         savedTabId = details.tabId;
       }
 
       browser.test.assertEq(details.tabId, savedTabId, "correct tab ID");
       checkType(details);
 
       frameIDs.set(details.url, details.frameId);
-      if (details.url.indexOf("page1") != -1) {
+      if (details.url.includes("page1")) {
         browser.test.assertEq(details.frameId, 0, "frame ID correct");
         browser.test.assertEq(details.parentFrameId, -1, "parent frame ID correct");
       }
-      if (details.url.indexOf("page2") != -1) {
+      if (details.url.includes("page2")) {
         browser.test.assertTrue(details.frameId != 0, "sub-frame gets its own frame ID");
         browser.test.assertTrue(details.frameId !== undefined, "sub-frame ID defined");
         browser.test.assertEq(details.parentFrameId, 0, "parent frame id is correct");
       }
     }
-    if (details.url.indexOf("_bad.") != -1) {
+    if (details.url.includes("_bad")) {
       return {cancel: true};
     }
     return {};
   }
 
   function onBeforeSendHeaders(details) {
     browser.test.log(`onBeforeSendHeaders ${details.url}`);
     checkRequestId(details);
@@ -311,17 +317,17 @@ function backgroundScript() {
       recorded.beforeSendHeaders.push(details.url);
 
       browser.test.assertEq(details.tabId, savedTabId, "correct tab ID");
       checkType(details);
 
       let id = frameIDs.get(details.url);
       browser.test.assertEq(id, details.frameId, "frame ID same in onBeforeSendHeaders as onBeforeRequest");
     }
-    if (details.url.indexOf("_redirect.") != -1) {
+    if (details.url.includes("_redirect.")) {
       return {redirectUrl: details.url.replace("_redirect.", "_good.")};
     }
     return {requestHeaders: details.requestHeaders};
   }
 
   function onBeforeRedirect(details) {
     browser.test.log(`onBeforeRedirect ${details.url} -> ${details.redirectUrl}`);
     checkRequestId(details, "redirect");
@@ -332,17 +338,17 @@ function backgroundScript() {
       browser.test.assertEq(details.tabId, savedTabId, "correct tab ID");
       checkType(details);
       checkStatus(details);
 
       let id = frameIDs.get(details.url);
       browser.test.assertEq(id, details.frameId, "frame ID same in onBeforeRedirect as onBeforeRequest");
       frameIDs.set(details.redirectUrl, details.frameId);
     }
-    if (details.url.indexOf("_redirect.") != -1) {
+    if (details.url.includes("_redirect.")) {
       let expectedUrl = details.url.replace("_redirect.", "_good.");
       browser.test.assertEq(details.redirectUrl, expectedUrl, "correct redirectUrl value");
     }
     return {};
   }
 
   function onRecord(kind, details) {
     browser.test.log(`${kind} ${details.requestId} ${details.url}`);
@@ -379,27 +385,33 @@ function backgroundScript() {
 
   function onHeadersReceived(details) {
     checkIpAndRecord("headersReceived", details);
     processHeaders("response", details);
     browser.test.log(`After processing response headers: ${details.responseHeaders.toSource()}`);
     return {responseHeaders: details.responseHeaders};
   }
 
+  function onErrorOccurred(details) {
+    onRecord("error", details);
+    browser.test.assertTrue(/^NS_ERROR_/.test(details.error), `onErrorOccurred reported for ${details.url} (${details.error})`);
+  }
+
   function onCompleted(details) {
     checkIpAndRecord("completed", details);
     checkHeaders("response", details);
   }
 
   browser.webRequest.onBeforeRequest.addListener(onBeforeRequest, {urls: ["<all_urls>"]}, ["blocking"]);
   browser.webRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, {urls: ["<all_urls>"]}, ["blocking", "requestHeaders"]);
   browser.webRequest.onSendHeaders.addListener(onSendHeaders, {urls: ["<all_urls>"]}, ["requestHeaders"]);
   browser.webRequest.onBeforeRedirect.addListener(onBeforeRedirect, {urls: ["<all_urls>"]});
   browser.webRequest.onHeadersReceived.addListener(onHeadersReceived, {urls: ["<all_urls>"]}, ["blocking", "responseHeaders"]);
   browser.webRequest.onResponseStarted.addListener(checkIpAndRecord.bind(null, "responseStarted"), {urls: ["<all_urls>"]});
+  browser.webRequest.onErrorOccurred.addListener(onErrorOccurred, {urls: ["<all_urls>"]});
   browser.webRequest.onCompleted.addListener(onCompleted, {urls: ["<all_urls>"]}, ["responseHeaders"]);
 
   function onTestMessage(msg) {
     if (msg == "skipCompleted") {
       checkCompleted = false;
       browser.test.sendMessage("ackSkipCompleted");
     } else {
       browser.test.sendMessage("results", recorded);
@@ -469,16 +481,17 @@ function* test_once(skipCompleted) {
   extension.sendMessage("getResults");
   let recorded = yield extension.awaitMessage("results");
 
   compareLists(recorded.requested, expected_requested, "requested");
   compareLists(recorded.beforeSendHeaders, expected_beforeSendHeaders, "beforeSendHeaders");
   compareLists(recorded.sendHeaders, expected_sendHeaders, "sendHeaders");
   compareLists(recorded.beforeRedirect, expected_redirect, "beforeRedirect");
   compareLists(recorded.responseStarted, expected_response, "responseStarted");
+  compareLists(recorded.error, expected_error, "error");
   compareLists(recorded.completed, expected_complete, "completed");
 
   yield extension.unload();
   info("webrequest extension unloaded");
 }
 
 // Run the test twice to make sure it works with caching.
 add_task(function*() { yield test_once(false); });
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -8543,16 +8543,25 @@
   },
   "LOOP_ROOM_SESSION_WITHCHAT": {
     "alert_emails": ["firefox-dev@mozilla.org", "mdeboer@mozilla.com"],
     "expires_in_version": "50",
     "kind": "count",
     "releaseChannelCollection": "opt-out",
     "description": "Number of sessions where at least one chat message was exchanged"
   },
+  "LOOP_COPY_PANEL_ACTIONS": {
+    "alert_emails": ["firefox-dev@mozilla.org", "edilee@mozilla.com"],
+    "expires_in_version": "51",
+    "kind": "enumerated",
+    "n_values": 5,
+    "releaseChannelCollection": "opt-out",
+    "bug_numbers": [1239965, 1259506],
+    "description": "Number of times each of the following copy panel actions are triggered: 0=SHOWN, 1=NO_AGAIN, 2=NO_NEVER, 3=YES_AGAIN, 4=YES_NEVER"
+  },
   "LOOP_INFOBAR_ACTION_BUTTONS": {
     "alert_emails": ["firefox-dev@mozilla.org", "mbanner@mozilla.com"],
     "expires_in_version": "51",
     "kind": "enumerated",
     "n_values": 4,
     "releaseChannelCollection": "opt-out",
     "bug_numbers": [1245486],
     "description": "Number times info bar buttons are clicked (0=PAUSED, 1=CREATED)"
--- a/toolkit/modules/addons/WebRequest.jsm
+++ b/toolkit/modules/addons/WebRequest.jsm
@@ -8,73 +8,76 @@ const EXPORTED_SYMBOLS = ["WebRequest"];
 
 /* exported WebRequest */
 
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
+const {nsIHttpActivityObserver, nsISocketTransport} = Ci;
+
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
                                   "resource://gre/modules/BrowserUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "WebRequestCommon",
                                   "resource://gre/modules/WebRequestCommon.jsm");
 
 function attachToChannel(channel, key, data) {
   if (channel instanceof Ci.nsIWritablePropertyBag2) {
-    let wrapper = {value: data};
-    wrapper.wrappedJSObject = wrapper;
+    let wrapper = {wrappedJSObject: data};
     channel.setPropertyAsInterface(key, wrapper);
   }
+  return data;
 }
 
 function extractFromChannel(channel, key) {
   if (channel instanceof Ci.nsIPropertyBag2 && channel.hasKey(key)) {
     let data = channel.get(key);
-    if (data && data.wrappedJSObject) {
-      data = data.wrappedJSObject;
-    }
-    return "value" in data ? data.value : data;
+    return data && data.wrappedJSObject;
   }
   return null;
 }
 
+function getData(channel) {
+  const key = "mozilla.webRequest.data";
+  return extractFromChannel(channel, key) || attachToChannel(channel, key, {});
+}
+
 var RequestId = {
   count: 1,
-  KEY: "mozilla.webRequest.requestId",
   create(channel = null) {
     let id = (this.count++).toString();
     if (channel) {
-      attachToChannel(channel, this.KEY, id);
+      getData(channel).requestId = id;
     }
     return id;
   },
 
   get(channel) {
-    return channel && extractFromChannel(channel, this.KEY) || this.create(channel);
+    return channel && getData(channel).requestId || this.create(channel);
   },
 };
 
 function runLater(job) {
   Services.tm.currentThread.dispatch(job, Ci.nsIEventTarget.DISPATCH_NORMAL);
 }
 
 function parseFilter(filter) {
   if (!filter) {
     filter = {};
   }
 
   // FIXME: Support windowId filtering.
   return {urls: filter.urls || null, types: filter.types || null};
 }
 
-function parseExtra(extra, allowed) {
+function parseExtra(extra, allowed = []) {
   if (extra) {
     for (let ex of extra) {
       if (allowed.indexOf(ex) == -1) {
         throw new Error(`Invalid option ${ex}`);
       }
     }
   }
 
@@ -82,27 +85,30 @@ function parseExtra(extra, allowed) {
   for (let al of allowed) {
     if (extra && extra.indexOf(al) != -1) {
       result[al] = true;
     }
   }
   return result;
 }
 
-function mergeStatus(data, channel) {
+function mergeStatus(data, channel, event) {
   try {
     data.statusCode = channel.responseStatus;
     let statusText = channel.responseStatusText;
     let maj = {};
     let min = {};
     channel.QueryInterface(Ci.nsIHttpChannelInternal).getResponseVersion(maj, min);
     data.statusLine = `HTTP/${maj.value}.${min.value} ${data.statusCode} ${statusText}`;
   } catch (e) {
-    // NS_ERROR_NOT_AVAILABLE might be thrown.
-    Cu.reportError(e);
+    // NS_ERROR_NOT_AVAILABLE might be thrown if it's an internal redirect, happening before
+    // any actual HTTP traffic. Otherwise, let's report.
+    if (event !== "onRedirect" || e.result !== Cr.NS_ERROR_NOT_AVAILABLE) {
+      Cu.reportError(`webRequest Error: ${e} trying to merge status in ${event}@${channel.name}`);
+    }
   }
 }
 
 var HttpObserverManager;
 
 var ContentPolicyManager = {
   policyData: new Map(),
   policies: new Map(),
@@ -122,35 +128,39 @@ var ContentPolicyManager = {
     for (let id of msg.data.ids) {
       let callback = this.policies.get(id);
       if (!callback) {
         // It's possible that this listener has been removed and the
         // child hasn't learned yet.
         continue;
       }
       let response = null;
+      let listenerKind = "onStop";
       let data = {
         url: msg.data.url,
         windowId: msg.data.windowId,
         parentWindowId: msg.data.parentWindowId,
         type: msg.data.type,
         browser: browser,
         requestId: RequestId.create(),
       };
       try {
         response = callback(data);
-        if (response && response.cancel) {
-          return {cancel: true};
+        if (response) {
+          if (response.cancel) {
+            listenerKind = "onError";
+            data.error = "NS_ERROR_ABORT";
+            return {cancel: true};
+          }
+          // FIXME: Need to handle redirection here (for non-HTTP URIs only)
         }
-
-        // FIXME: Need to handle redirection here. (Bug 1163862)
       } catch (e) {
         Cu.reportError(e);
       } finally {
-        runLater(() => this.runChannelListener("onStop", data));
+        runLater(() => this.runChannelListener(listenerKind, data));
       }
     }
 
     return {};
   },
 
   runChannelListener(kind, data) {
     let listeners = HttpObserverManager.listeners[kind];
@@ -262,40 +272,50 @@ var ChannelEventSink = {
 };
 
 ChannelEventSink.init();
 
 HttpObserverManager = {
   modifyInitialized: false,
   examineInitialized: false,
   redirectInitialized: false,
+  activityInitialized: false,
+  needTracing: false,
 
   listeners: {
     opening: new Map(),
     modify: new Map(),
     afterModify: new Map(),
     headersReceived: new Map(),
     onRedirect: new Map(),
     onStart: new Map(),
+    onError: new Map(),
     onStop: new Map(),
   },
 
+  get activityDistributor() {
+    return Cc["@mozilla.org/network/http-activity-distributor;1"].getService(Ci.nsIHttpActivityDistributor);
+  },
+
   addOrRemove() {
     let needModify = this.listeners.opening.size || this.listeners.modify.size || this.listeners.afterModify.size;
     if (needModify && !this.modifyInitialized) {
       this.modifyInitialized = true;
       Services.obs.addObserver(this, "http-on-modify-request", false);
     } else if (!needModify && this.modifyInitialized) {
       this.modifyInitialized = false;
       Services.obs.removeObserver(this, "http-on-modify-request");
     }
+    this.needTracing = this.listeners.onStart.size ||
+                       this.listeners.onError.size ||
+                       this.listeners.onStop.size;
 
-    let needExamine = this.listeners.headersReceived.size ||
-                      this.listeners.onStart.size ||
-                      this.listeners.onStop.size;
+    let needExamine = this.needTracing ||
+                      this.listeners.headersReceived.size;
+
     if (needExamine && !this.examineInitialized) {
       this.examineInitialized = true;
       Services.obs.addObserver(this, "http-on-examine-response", false);
       Services.obs.addObserver(this, "http-on-examine-cached-response", false);
       Services.obs.addObserver(this, "http-on-examine-merged-response", false);
     } else if (!needExamine && this.examineInitialized) {
       this.examineInitialized = false;
       Services.obs.removeObserver(this, "http-on-examine-response");
@@ -306,16 +326,25 @@ HttpObserverManager = {
     let needRedirect = this.listeners.onRedirect.size;
     if (needRedirect && !this.redirectInitialized) {
       this.redirectInitialized = true;
       ChannelEventSink.register();
     } else if (!needRedirect && this.redirectInitialized) {
       this.redirectInitialized = false;
       ChannelEventSink.unregister();
     }
+
+    let needActivity = this.listeners.onError.size;
+    if (needActivity && !this.activityInitialized) {
+      this.activityInitialized = true;
+      this.activityDistributor.addObserver(this);
+    } else if (!needActivity && this.activityInitialized) {
+      this.activityInitialized = false;
+      this.activityDistributor.removeObserver(this);
+    }
   },
 
   addListener(kind, callback, opts) {
     this.listeners[kind].set(callback, opts);
     this.addOrRemove();
   },
 
   removeListener(kind, callback) {
@@ -334,28 +363,32 @@ HttpObserverManager = {
                       .notificationCallbacks
                       .getInterface(Components.interfaces.nsILoadContext);
       } catch (e) {
         return null;
       }
     }
   },
 
-  getHeaders(channel, method) {
+  getHeaders(channel, method, event) {
     let headers = [];
     let visitor = {
       visitHeader(name, value) {
         headers.push({name, value});
       },
 
       QueryInterface: XPCOMUtils.generateQI([Ci.nsIHttpHeaderVisitor,
                                              Ci.nsISupports]),
     };
 
-    channel[method](visitor);
+    try {
+      channel[method](visitor);
+    } catch (e) {
+      Cu.reportError(`webRequest Error: ${e} trying to perform ${method} in ${event}@${channel.name}`);
+    }
     return headers;
   },
 
   replaceHeaders(headers, originalNames, setHeader) {
     let failures = new Set();
     // Start by clearing everything.
     for (let name of originalNames) {
       try {
@@ -393,24 +426,84 @@ HttpObserverManager = {
       case "http-on-examine-response":
       case "http-on-examine-cached-response":
       case "http-on-examine-merged-response":
         this.examine(channel, topic, data);
         break;
     }
   },
 
+  // We map activity values with tentative error names, e.g. "STATUS_RESOLVING" => "NS_ERROR_NET_ON_RESOLVING".
+  get activityErrorsMap() {
+    let prefix = /^(?:ACTIVITY_SUBTYPE_|STATUS_)/;
+    let map = new Map();
+    for (let iface of [nsIHttpActivityObserver, nsISocketTransport]) {
+      for (let c of Object.keys(iface).filter(name => prefix.test(name))) {
+        map.set(iface[c], c.replace(prefix, "NS_ERROR_NET_ON_"));
+      }
+    }
+    delete this.activityErrorsMap;
+    this.activityErrorsMap = map;
+    return this.activityErrorsMap;
+  },
+  GOOD_LAST_ACTIVITY: nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_HEADER,
+  observeActivity(channel, activityType, activitySubtype /* , aTimestamp, aExtraSizeData, aExtraStringData */) {
+    let channelData = getData(channel);
+    let lastActivity = channelData.lastActivity || 0;
+    if (activitySubtype === nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE &&
+        lastActivity && lastActivity !== this.GOOD_LAST_ACTIVITY) {
+      let loadContext = this.getLoadContext(channel);
+      if (!this.errorCheck(channel, loadContext, channelData)) {
+        this.runChannelListener(channel, loadContext, "onError",
+                                {error: this.activityErrorsMap.get(lastActivity) ||
+                                        `NS_ERROR_NET_UNKNOWN_${lastActivity}`});
+      }
+    } else if (lastActivity !== this.GOOD_LAST_ACTIVITY &&
+               lastActivity !== nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE) {
+      channelData.lastActivity = activitySubtype;
+    }
+  },
+
   shouldRunListener(policyType, uri, filter) {
     return WebRequestCommon.typeMatches(policyType, filter.types) &&
            WebRequestCommon.urlMatches(uri, filter.urls);
   },
 
+  get resultsMap() {
+    delete this.resultsMap;
+    this.resultsMap = new Map(Object.keys(Cr).map(name => [Cr[name], name]));
+    return this.resultsMap;
+  },
+  maybeError(channel, extraData = null, channelData = null) {
+    if (!(extraData && extraData.error)) {
+      if (!Components.isSuccessCode(channel.status)) {
+        extraData = {error: this.resultsMap.get(channel.status)};
+      }
+    }
+    return extraData;
+  },
+  errorCheck(channel, loadContext, channelData = null) {
+    let errorData = this.maybeError(channel, null, channelData);
+    if (errorData) {
+      this.runChannelListener(channel, loadContext, "onError", errorData);
+    }
+    return errorData;
+  },
+
   runChannelListener(channel, loadContext, kind, extraData = null) {
-    if (channel.status === Cr.NS_ERROR_ABORT) {
-      return false;
+    if (this.activityInitialized) {
+      let channelData = getData(channel);
+      if (kind === "onError") {
+        if (channelData.errorNotified) {
+          return false;
+        }
+        channelData.errorNotified = true;
+      } else if (this.errorCheck(channel, loadContext, channelData)) {
+        return false;
+      }
     }
     let listeners = this.listeners[kind];
     let browser = loadContext ? loadContext.topFrameElement : null;
     let loadInfo = channel.loadInfo;
     let policyType = loadInfo ?
                      loadInfo.externalContentPolicyType :
                      Ci.nsIContentPolicy.TYPE_OTHER;
 
@@ -444,39 +537,40 @@ HttpObserverManager = {
         // The remoteAddress getter throws if the address is unavailable,
         // but ip is an optional property so just ignore the exception.
       }
 
       if (extraData) {
         Object.assign(data, extraData);
       }
       if (opts.requestHeaders) {
-        data.requestHeaders = this.getHeaders(channel, "visitRequestHeaders");
+        data.requestHeaders = this.getHeaders(channel, "visitRequestHeaders", kind);
         requestHeaderNames = data.requestHeaders.map(h => h.name);
       }
       if (opts.responseHeaders) {
-        data.responseHeaders = this.getHeaders(channel, "visitResponseHeaders");
+        data.responseHeaders = this.getHeaders(channel, "visitResponseHeaders", kind);
         responseHeaderNames = data.responseHeaders.map(h => h.name);
       }
       if (includeStatus) {
-        mergeStatus(data, channel);
+        mergeStatus(data, channel, kind);
       }
 
       let result = null;
       try {
         result = callback(data);
       } catch (e) {
         Cu.reportError(e);
       }
 
       if (!result || !opts.blocking) {
         return true;
       }
       if (result.cancel) {
         channel.cancel(Cr.NS_ERROR_ABORT);
+        this.errorCheck(channel, loadContext);
         return false;
       }
       if (result.redirectUrl) {
         channel.redirectTo(BrowserUtils.makeURI(result.redirectUrl));
         return false;
       }
       if (opts.requestHeaders && result.requestHeaders) {
         this.replaceHeaders(
@@ -502,17 +596,17 @@ HttpObserverManager = {
         this.runChannelListener(channel, loadContext, "modify")) {
       this.runChannelListener(channel, loadContext, "afterModify");
     }
   },
 
   examine(channel, topic, data) {
     let loadContext = this.getLoadContext(channel);
 
-    if (this.listeners.onStart.size || this.listeners.onStop.size) {
+    if (this.needTracing) {
       if (channel instanceof Ci.nsITraceableChannel) {
         let responseStatus = channel.responseStatus;
         // skip redirections, https://bugzilla.mozilla.org/show_bug.cgi?id=728901#c8
         if (responseStatus < 300 || responseStatus >= 400) {
           let listener = new StartStopListener(this, loadContext);
           let orig = channel.setNewListener(listener);
           listener.orig = orig;
         }
@@ -569,33 +663,37 @@ HttpEvent.prototype = {
 };
 
 var onBeforeSendHeaders = new HttpEvent("modify", ["requestHeaders", "blocking"]);
 var onSendHeaders = new HttpEvent("afterModify", ["requestHeaders"]);
 var onHeadersReceived = new HttpEvent("headersReceived", ["blocking", "responseHeaders"]);
 var onBeforeRedirect = new HttpEvent("onRedirect", ["responseHeaders"]);
 var onResponseStarted = new HttpEvent("onStart", ["responseHeaders"]);
 var onCompleted = new HttpEvent("onStop", ["responseHeaders"]);
+var onErrorOccurred = new HttpEvent("onError");
 
 var WebRequest = {
   // http-on-modify observer for HTTP(S), content policy for the other protocols (notably, data:)
   onBeforeRequest: onBeforeRequest,
 
   // http-on-modify observer.
   onBeforeSendHeaders: onBeforeSendHeaders,
 
   // http-on-modify observer.
   onSendHeaders: onSendHeaders,
 
   // http-on-examine-*observer.
   onHeadersReceived: onHeadersReceived,
 
-  // nsIChannelEventSink
+  // nsIChannelEventSink.
   onBeforeRedirect: onBeforeRedirect,
 
   // OnStartRequest channel listener.
   onResponseStarted: onResponseStarted,
 
   // OnStopRequest channel listener.
   onCompleted: onCompleted,
+
+  // nsIHttpActivityObserver.
+  onErrorOccurred: onErrorOccurred,
 };
 
 Services.ppmm.loadProcessScript("resource://gre/modules/WebRequestContent.js", true);
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -1737,17 +1737,16 @@ function installAllFiles(aFiles, aCallba
     });
   });
 }
 
 function promiseInstallAllFiles(aFiles, aIgnoreIncompatible) {
   let deferred = Promise.defer();
   installAllFiles(aFiles, deferred.resolve, aIgnoreIncompatible);
   return deferred.promise;
-
 }
 
 if ("nsIWindowsRegKey" in AM_Ci) {
   var MockRegistry = {
     LOCAL_MACHINE: {},
     CURRENT_USER: {},
     CLASSES_ROOT: {},
 
--- a/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
@@ -158,18 +158,20 @@ add_task(function*() {
 
   yield promiseRestartManager();
 });
 
 add_task(function* test_manifest_localization() {
   const ID = "webextension3@tests.mozilla.org";
 
   yield promiseInstallAllFiles([do_get_addon("webextension_3")], true);
+  yield promiseAddonStartup();
 
   let addon = yield promiseAddonByID(ID);
+  addon.userDisabled = true;
 
   equal(addon.name, "Web Extensiøn foo ☹");
   equal(addon.description, "Descriptïon bar ☹ of add-on");
 
   Services.prefs.setCharPref(PREF_SELECTED_LOCALE, "fr-FR");
   yield promiseRestartManager();
 
   addon = yield promiseAddonByID(ID);
@@ -179,16 +181,18 @@ add_task(function* test_manifest_localiz
 
   Services.prefs.setCharPref(PREF_SELECTED_LOCALE, "de");
   yield promiseRestartManager();
 
   addon = yield promiseAddonByID(ID);
 
   equal(addon.name, "Web Extensiøn foo ☹");
   equal(addon.description, "Descriptïon bar ☹ of add-on");
+
+  addon.uninstall();
 });
 
 // Missing ID should cause a failure
 add_task(function*() {
   writeWebManifestForExtension({
     name: "Web Extension Name",
     version: "1.0",
     manifest_version: 2,
--- a/toolkit/mozapps/update/updater/updater.cpp
+++ b/toolkit/mozapps/update/updater/updater.cpp
@@ -3214,17 +3214,34 @@ int NS_main(int argc, NS_tchar **argv)
       *d = NS_T('\0');
       ++d;
 
       // Make a copy of the callback executable so it can be read when patching.
       NS_tsnprintf(gCallbackBackupPath,
                    sizeof(gCallbackBackupPath)/sizeof(gCallbackBackupPath[0]),
                    NS_T("%s" CALLBACK_BACKUP_EXT), argv[callbackIndex]);
       NS_tremove(gCallbackBackupPath);
-      CopyFileW(argv[callbackIndex], gCallbackBackupPath, false);
+      if(!CopyFileW(argv[callbackIndex], gCallbackBackupPath, true)) {
+        DWORD copyFileError = GetLastError();
+        LOG(("NS_main: failed to copy callback file " LOG_S
+             " into place at " LOG_S, argv[callbackIndex], gCallbackBackupPath));
+        LogFinish();
+        if (copyFileError == ERROR_ACCESS_DENIED) {
+          WriteStatusFile(WRITE_ERROR_ACCESS_DENIED);
+        } else {
+          WriteStatusFile(WRITE_ERROR_CALLBACK_APP);
+        }
+
+        EXIT_WHEN_ELEVATED(elevatedLockFilePath, updateLockFileHandle, 1);
+        LaunchCallbackApp(argv[callbackIndex],
+                          argc - callbackIndex,
+                          argv + callbackIndex,
+                          sUsingService);
+        return 1;
+      }
 
       // Since the process may be signaled as exited by WaitForSingleObject before
       // the release of the executable image try to lock the main executable file
       // multiple times before giving up.  If we end up giving up, we won't
       // fail the update.
       const int max_retries = 10;
       int retries = 1;
       DWORD lastWriteError = 0;