merge fx-team to mozilla-central
authorCarsten "Tomcat" Book <cbook@mozilla.com>
Fri, 07 Feb 2014 09:52:32 +0100
changeset 167451 dedf12c4e805269219bf3ed6a0f061b44d7b033a
parent 167424 1ca0ce406aad20597a7f22603611ff400b2242d4 (current diff)
parent 167450 b4d8ec13d060c69c05991dea18eb1f4af1f328ce (diff)
child 167466 d05c721ea1b0def89ed49532845398722478e909
push id26169
push usercbook@mozilla.com
push dateFri, 07 Feb 2014 08:53:21 +0000
treeherdermozilla-central@dedf12c4e805 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
milestone30.0a1
first release with
nightly linux32
dedf12c4e805 / 30.0a1 / 20140207030201 / files
nightly linux64
dedf12c4e805 / 30.0a1 / 20140207030201 / files
nightly mac
dedf12c4e805 / 30.0a1 / 20140207030201 / files
nightly win32
dedf12c4e805 / 30.0a1 / 20140207030201 / files
nightly win64
dedf12c4e805 / 30.0a1 / 20140207030201 / 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 mozilla-central
browser/components/sessionstore/src/SessionHistory.jsm
docshell/base/nsDocShell.cpp
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -853,16 +853,20 @@ pref("browser.sessionstore.privacy_level
 // how many tabs can be reopened (per window)
 pref("browser.sessionstore.max_tabs_undo", 10);
 // how many windows can be reopened (per session) - on non-OS X platforms this
 // pref may be ignored when dealing with pop-up windows to ensure proper startup
 pref("browser.sessionstore.max_windows_undo", 3);
 // number of crashes that can occur before the about:sessionrestore page is displayed
 // (this pref has no effect if more than 6 hours have passed since the last crash)
 pref("browser.sessionstore.max_resumed_crashes", 1);
+// number of back button session history entries to restore (-1 = all of them)
+pref("browser.sessionstore.max_serialize_back", 10);
+// number of forward button session history entries to restore (-1 = all of them)
+pref("browser.sessionstore.max_serialize_forward", -1);
 // restore_on_demand overrides MAX_CONCURRENT_TAB_RESTORES (sessionstore constant)
 // and restore_hidden_tabs. When true, tabs will not be restored until they are
 // focused (also applies to tabs that aren't visible). When false, the values
 // for MAX_CONCURRENT_TAB_RESTORES and restore_hidden_tabs are respected.
 // Selected tabs are always restored regardless of this pref.
 pref("browser.sessionstore.restore_on_demand", true);
 // Whether to automatically restore hidden tabs (i.e., tabs in other tab groups) or not
 pref("browser.sessionstore.restore_hidden_tabs", false);
--- a/browser/base/content/tabbrowser.xml
+++ b/browser/base/content/tabbrowser.xml
@@ -1087,18 +1087,23 @@
 
             this._setCloseKeyState(!this.mCurrentTab.pinned);
 
             // TabSelect events are suppressed during preview mode to avoid confusing extensions and other bits of code
             // that might rely upon the other changes suppressed.
             // Focus is suppressed in the event that the main browser window is minimized - focusing a tab would restore the window
             if (!this._previewMode) {
               // We've selected the new tab, so go ahead and notify listeners.
-              let event = document.createEvent("Events");
-              event.initEvent("TabSelect", true, false);
+              let event = new CustomEvent("TabSelect", {
+                bubbles: true,
+                cancelable: false,
+                detail: {
+                  previousTab: oldTab
+                }
+              });
               this.mCurrentTab.dispatchEvent(event);
 
               this._tabAttrModified(oldTab);
               this._tabAttrModified(this.mCurrentTab);
 
               // Adjust focus
               oldBrowser._urlbarFocused = (gURLBar && gURLBar.focused);
               do {
--- a/browser/base/content/test/social/head.js
+++ b/browser/base/content/test/social/head.js
@@ -308,24 +308,34 @@ function updateBlocklist(aCallback) {
     Services.obs.removeObserver(observer, "blocklist-updated");
     if (aCallback)
       executeSoon(aCallback);
   };
   Services.obs.addObserver(observer, "blocklist-updated", false);
   blocklistNotifier.notify(null);
 }
 
+var _originalTestBlocklistURL = null;
 function setAndUpdateBlocklist(aURL, aCallback) {
+  if (!_originalTestBlocklistURL)
+    _originalTestBlocklistURL = Services.prefs.getCharPref("extensions.blocklist.url");
   Services.prefs.setCharPref("extensions.blocklist.url", aURL);
   updateBlocklist(aCallback);
 }
 
 function resetBlocklist(aCallback) {
-  Services.prefs.clearUserPref("extensions.blocklist.url");
-  updateBlocklist(aCallback);
+  // XXX - this has "forked" from the head.js helpers in our parent directory :(
+  // But let's reuse their blockNoPlugins.xml.  Later, we should arrange to
+  // use their head.js helpers directly
+  let noBlockedURL = "http://example.com/browser/browser/base/content/test/general/blockNoPlugins.xml";
+  setAndUpdateBlocklist(noBlockedURL, function() {
+    Services.prefs.setCharPref("extensions.blocklist.url", _originalTestBlocklistURL);
+    if (aCallback)
+      aCallback();
+  });
 }
 
 function setManifestPref(name, manifest) {
   let string = Cc["@mozilla.org/supports-string;1"].
                createInstance(Ci.nsISupportsString);
   string.data = JSON.stringify(manifest);
   Services.prefs.setComplexValue(name, Ci.nsISupportsString, string);
 }
--- a/browser/components/customizableui/src/CustomizeMode.jsm
+++ b/browser/components/customizableui/src/CustomizeMode.jsm
@@ -839,17 +839,17 @@ CustomizeMode.prototype = {
       this._updateResetButton();
       this._showPanelCustomizationPlaceholders();
       this.resetting = false;
     }.bind(this)).then(null, ERROR);
   },
 
   _onToolbarVisibilityChange: function(aEvent) {
     let toolbar = aEvent.target;
-    if (aEvent.detail.visible) {
+    if (aEvent.detail.visible && toolbar.getAttribute("customizable") == "true") {
       toolbar.setAttribute("customizing", "true");
     } else {
       toolbar.removeAttribute("customizing");
     }
   },
 
   onWidgetMoved: function(aWidgetId, aArea, aOldPosition, aNewPosition) {
     this._onUIChange();
--- a/browser/components/sessionstore/src/SessionHistory.jsm
+++ b/browser/components/sessionstore/src/SessionHistory.jsm
@@ -65,33 +65,52 @@ let SessionHistoryInternal = {
    */
   collect: function (docShell) {
     let data = {entries: []};
     let isPinned = docShell.isAppTab;
     let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation);
     let history = webNavigation.sessionHistory;
 
     if (history && history.count > 0) {
+      let oldest;
+      let maxSerializeBack =
+        Services.prefs.getIntPref("browser.sessionstore.max_serialize_back");
+      if (maxSerializeBack >= 0) {
+        oldest = Math.max(0, history.index - maxSerializeBack);
+      } else { // History.getEntryAtIndex(0, ...) is the oldest.
+        oldest = 0;
+      }
+
+      let newest;
+      let maxSerializeFwd =
+        Services.prefs.getIntPref("browser.sessionstore.max_serialize_forward");
+      if (maxSerializeFwd >= 0) {
+        newest = Math.min(history.count - 1, history.index + maxSerializeFwd);
+      } else { // History.getEntryAtIndex(history.count - 1, ...) is the newest.
+        newest = history.count - 1;
+      }
+
       try {
-        for (let i = 0; i < history.count; i++) {
+        for (let i = oldest; i <= newest; i++) {
           let shEntry = history.getEntryAtIndex(i, false);
           let entry = this.serializeEntry(shEntry, isPinned);
           data.entries.push(entry);
         }
       } catch (ex) {
         // In some cases, getEntryAtIndex will throw. This seems to be due to
         // history.count being higher than it should be. By doing this in a
         // try-catch, we'll update history to where it breaks, print an error
         // message, and still save sessionstore.js.
         debug("SessionStore failed gathering complete history " +
               "for the focused window/tab. See bug 669196.");
       }
 
-      // Ensure the index isn't out of bounds if an exception was thrown above.
-      data.index = Math.min(history.index + 1, data.entries.length);
+      // Set the one-based index of the currently active tab,
+      // ensuring it isn't out of bounds if an exception was thrown above.
+      data.index = Math.min(history.index - oldest + 1, data.entries.length);
     }
 
     // If either the session history isn't available yet or doesn't have any
     // valid entries, make sure we at least include the current page.
     if (data.entries.length == 0) {
       let uri = webNavigation.currentURI.spec;
       // We landed here because the history is inaccessible or there are no
       // history entries. In that case we should at least record the docShell's
--- a/browser/components/sessionstore/test/browser.ini
+++ b/browser/components/sessionstore/test/browser.ini
@@ -63,16 +63,17 @@ support-files =
 [browser_dynamic_frames.js]
 [browser_form_restore_events.js]
 [browser_formdata.js]
 [browser_formdata_format.js]
 [browser_formdata_xpath.js]
 [browser_frametree.js]
 [browser_frame_history.js]
 [browser_global_store.js]
+[browser_history_cap.js]
 [browser_label_and_icon.js]
 [browser_merge_closed_tabs.js]
 [browser_pageStyle.js]
 [browser_privatetabs.js]
 [browser_scrollPositions.js]
 [browser_sessionHistory.js]
 [browser_sessionStorage.js]
 [browser_swapDocShells.js]
--- a/browser/components/sessionstore/test/browser_447951.js
+++ b/browser/components/sessionstore/test/browser_447951.js
@@ -4,16 +4,24 @@
 
 function test() {
   /** Test for Bug 447951 **/
 
   waitForExplicitFinish();
   const baseURL = "http://mochi.test:8888/browser/" +
     "browser/components/sessionstore/test/browser_447951_sample.html#";
 
+  // Make sure the functionality added in bug 943339 doesn't affect the results
+  gPrefService.setIntPref("browser.sessionstore.max_serialize_back", -1);
+  gPrefService.setIntPref("browser.sessionstore.max_serialize_forward", -1);
+  registerCleanupFunction(function () {
+    gPrefService.clearUserPref("browser.sessionstore.max_serialize_back");
+    gPrefService.clearUserPref("browser.sessionstore.max_serialize_forward");
+  });
+
   let tab = gBrowser.addTab();
   whenBrowserLoaded(tab.linkedBrowser, function() {
     let tabState = { entries: [] };
     let max_entries = gPrefService.getIntPref("browser.sessionhistory.max_entries");
     for (let i = 0; i < max_entries; i++)
       tabState.entries.push({ url: baseURL + i });
 
     ss.setTabState(tab, JSON.stringify(tabState));
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_history_cap.js
@@ -0,0 +1,125 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test ensures that the preferences (added in bug 943339) that control how
+ * many back and forward button session history entries we store work correctly.
+ *
+ * It adds a number of entries to the session history, restores them and checks
+ * that the restored state matches the preferences.
+ */
+
+add_task(function *test_history_cap() {
+  const baseURL = "http://example.com/browser_history_cap#"
+  const maxEntries  = 9; // The number of generated session history entries.
+  const middleEntry = 4; // The zero-based index of the middle entry.
+
+  const maxBack1 = 2; // The history cap settings used for the first test,
+  const maxFwd1 = 3;  // where maxBack1 + 1 + maxFwd1 < maxEntries.
+
+  const maxBack2 = 5; // The history cap settings used for the other tests, 
+  const maxFwd2 = 5;  // where maxBack2 + 1 + maxFwd2 > maxEntries.
+
+  // Set the relevant preferences for the first test.
+  gPrefService.setIntPref("browser.sessionhistory.max_entries", maxEntries);
+  gPrefService.setIntPref("browser.sessionstore.max_serialize_back", maxBack1);
+  gPrefService.setIntPref("browser.sessionstore.max_serialize_forward", maxFwd1);
+
+  // Make sure the settings we modify are reset afterward.
+  registerCleanupFunction(() => {
+    gPrefService.clearUserPref("browser.sessionhistory.max_entries");
+    gPrefService.clearUserPref("browser.sessionstore.max_serialize_back");
+    gPrefService.clearUserPref("browser.sessionstore.max_serialize_forward");
+  });
+
+  let tab = gBrowser.addTab();
+  let browser = tab.linkedBrowser;
+  yield promiseBrowserLoaded(browser);
+
+  // Generate the tab state entries and set the one-based
+  // tab-state index to the middle session history entry.
+  let tabState = {entries: [], index: middleEntry + 1};
+  for (let i = 0; i < maxEntries; i++) {
+    tabState.entries.push({url: baseURL + i});
+  }
+
+  info("Testing situation where only a subset of session history entries should be restored.");
+
+  ss.setTabState(tab, JSON.stringify(tabState));
+  yield promiseTabRestored(tab);
+  SyncHandlers.get(tab.linkedBrowser).flush();
+
+  let restoredTabState = JSON.parse(ss.getTabState(tab));
+  is(restoredTabState.entries.length, maxBack1 + 1 + maxFwd1,
+    "The expected number of session history entries was restored.");
+  is(restoredTabState.index, maxBack1 + 1, "The restored tab-state index is correct");
+
+  let indexURLOffset = middleEntry - (restoredTabState.index - 1);
+  for (let i = 0; i < restoredTabState.entries.length; i++) {
+    is(restoredTabState.entries[i].url, baseURL + (i + indexURLOffset),
+        "URL of restored entry matches the expected URL.");
+  }
+
+  // Set the relevant preferences for the other tests.
+  gPrefService.setIntPref("browser.sessionstore.max_serialize_back", maxBack2);
+  gPrefService.setIntPref("browser.sessionstore.max_serialize_forward", maxFwd2);
+
+  info("Testing situation where all of the entries in the session history should be restored.");
+
+  ss.setTabState(tab, JSON.stringify(tabState));
+  yield promiseTabRestored(tab);
+  SyncHandlers.get(tab.linkedBrowser).flush();
+
+  restoredTabState = JSON.parse(ss.getTabState(tab));
+  is(restoredTabState.entries.length, maxEntries,
+    "The expected number of session history entries was restored.");
+  is(restoredTabState.index, middleEntry + 1, "The restored tab-state index is correct");
+
+  for (let i = middleEntry - 2; i <= middleEntry + 2; i++) {
+    is(restoredTabState.entries[i].url, baseURL + i,
+        "URL of restored entry matches the expected URL.");
+  }
+
+  info("Testing situation where only the 1 + maxFwd2 oldest entries should be restored.");
+
+  // Set the one-based tab-state index to the oldest session history entry.
+  tabState.index = 1;
+
+  ss.setTabState(tab, JSON.stringify(tabState));
+  yield promiseTabRestored(tab);
+  SyncHandlers.get(tab.linkedBrowser).flush();
+
+  restoredTabState = JSON.parse(ss.getTabState(tab));
+  is(restoredTabState.entries.length, 1 + maxFwd2,
+    "The expected number of session history entries was restored.");
+  is(restoredTabState.index, 1, "The restored tab-state index is correct");
+
+  for (let i = 0; i <= 2; i++) {
+    is(restoredTabState.entries[i].url, baseURL + i,
+        "URL of restored entry matches the expected URL.");
+  }
+
+  info("Testing situation where only the maxBack2 + 1 newest entries should be restored.");
+
+  // Set the one-based tab-state index to the newest session history entry.
+  tabState.index = maxEntries;
+
+  ss.setTabState(tab, JSON.stringify(tabState));
+  yield promiseTabRestored(tab);
+  SyncHandlers.get(tab.linkedBrowser).flush();
+
+  restoredTabState = JSON.parse(ss.getTabState(tab));
+  is(restoredTabState.entries.length, maxBack2 + 1,
+    "The expected number of session history entries was restored.");
+  is(restoredTabState.index, maxBack2 + 1, "The restored tab-state index is correct");
+
+  indexURLOffset = (maxEntries - 1) - maxBack2;
+  for (let i = maxBack2 - 2; i <= maxBack2; i++) {
+    is(restoredTabState.entries[i].url, baseURL + (i + indexURLOffset),
+        "URL of restored entry matches the expected URL.");
+  }
+
+  gBrowser.removeTab(tab);
+});
--- a/browser/devtools/markupview/markup-view.js
+++ b/browser/devtools/markupview/markup-view.js
@@ -161,16 +161,17 @@ MarkupView.prototype = {
       this._containers.get(nodeFront).hovered = true;
 
       this._hoveredNode = nodeFront;
     }
   },
 
   _onMouseLeave: function() {
     this._hideBoxModel();
+    this._hoveredNode = null;
   },
 
   _showBoxModel: function(nodeFront, options={}) {
     this._inspector.toolbox.highlighterUtils.highlightNodeFront(nodeFront, options);
   },
 
   _hideBoxModel: function() {
     this._inspector.toolbox.highlighterUtils.unhighlight();
@@ -252,21 +253,24 @@ MarkupView.prototype = {
    * In a few cases, we don't want to highlight the node:
    * - If the reason is null (used to reset the selection),
    * - if it's "inspector-open" (when the inspector opens up, let's not highlight
    * the default node)
    * - if it's "navigateaway" (since the page is being navigated away from)
    * - if it's "test" (this is a special case for mochitest. In tests, we often
    * need to select elements but don't necessarily want the highlighter to come
    * and go after a delay as this might break test scenarios)
+   * We also do not want to start a brief highlight timeout if the node is already
+   * being hovered over, since in that case it will already be highlighted.
    */
   _shouldNewSelectionBeHighlighted: function() {
     let reason = this._inspector.selection.reason;
     let unwantedReasons = ["inspector-open", "navigateaway", "test"];
-    return reason && unwantedReasons.indexOf(reason) === -1;
+    let isHighlitNode = this._hoveredNode === this._inspector.selection.nodeFront;
+    return !isHighlitNode && reason && unwantedReasons.indexOf(reason) === -1;
   },
 
   /**
    * Highlight the inspector selected node.
    */
   _onNewSelection: function() {
     let selection = this._inspector.selection;
 
--- a/browser/devtools/markupview/test/browser.ini
+++ b/browser/devtools/markupview/test/browser.ini
@@ -19,8 +19,10 @@ skip-if = os == "linux"
 [browser_inspector_markup_edit_outerhtml2.js]
 [browser_inspector_markup_mutation.js]
 [browser_inspector_markup_mutation_flashing.js]
 [browser_inspector_markup_navigation.js]
 [browser_inspector_markup_subset.js]
 [browser_inspector_markup_765105_tooltip.js]
 [browser_inspector_markup_950732.js]
 [browser_inspector_markup_964014_copy_image_data.js]
+[browser_inspector_markup_968316_highlit_node_on_hover_then_select.js]
+[browser_inspector_markup_968316_highlight_node_after_mouseleave_mousemove.js]
--- a/browser/devtools/markupview/test/browser_inspector_markup_964014_copy_image_data.js
+++ b/browser/devtools/markupview/test/browser_inspector_markup_964014_copy_image_data.js
@@ -37,17 +37,17 @@ function createDocument() {
   context.lineTo(0, 600);
   context.closePath();
   context.fillStyle = "#ffc821";
   context.fill();
 
   openInspector().then(startTests);
 }
 
-function startTests(aInspector, aToolbox) {
+function startTests({inspector: aInspector, toolbox: aToolbox}) {
   inspector = aInspector;
   markup = inspector.markup;
 
   Task.spawn(function() {
     yield selectNode("div", inspector);
     yield assertCopyImageDataNotAvailable();
 
     yield selectNode("img", inspector);
new file mode 100644
--- /dev/null
+++ b/browser/devtools/markupview/test/browser_inspector_markup_968316_highlight_node_after_mouseleave_mousemove.js
@@ -0,0 +1,47 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that when after an element is selected and highlighted on hover, if the
+// mouse leaves the markup-view and comes back again on the same element, that
+// the highlighter is shown again on the node
+
+function test() {
+  waitForExplicitFinish();
+
+  gBrowser.selectedTab = gBrowser.addTab();
+  gBrowser.selectedBrowser.addEventListener("load", function onload(evt) {
+    gBrowser.selectedBrowser.removeEventListener("load", onload, true);
+    waitForFocus(startTests, content);
+  }, true);
+
+  content.location = "data:text/html,<p>Select me!</p>";
+}
+
+function startTests(aInspector, aToolbox) {
+  let p = content.document.querySelector("p");
+  Task.spawn(function() {
+    info("opening the inspector tool");
+    let {inspector, toolbox} = yield openInspector();
+
+    info("hover over the <p> line in the markup-view so that it's the currently hovered node");
+    yield hoverContainer(p, inspector);
+
+    info("select the <p> markup-container line by clicking");
+    yield clickContainer(p, inspector);
+    ok(isHighlighterVisible(), "the highlighter is shown");
+
+    info("mouse-leave the markup-view");
+    yield mouseLeaveMarkupView(inspector);
+    ok(!isHighlighterVisible(), "the highlighter is hidden after mouseleave");
+
+    info("hover over the <p> line again, which is still selected");
+    yield hoverContainer(p, inspector);
+    ok(isHighlighterVisible(), "the highlighter is visible again");
+  }).then(null, ok.bind(null, false)).then(endTests);
+}
+
+function endTests() {
+  gBrowser.removeCurrentTab();
+  finish();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/markupview/test/browser_inspector_markup_968316_highlit_node_on_hover_then_select.js
@@ -0,0 +1,54 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that when first hovering over a node and immediately after selecting it
+// by clicking on it leaves the highlighter visible for as long as the mouse is
+// over the node
+
+function test() {
+  waitForExplicitFinish();
+
+  gBrowser.selectedTab = gBrowser.addTab();
+  gBrowser.selectedBrowser.addEventListener("load", function onload(evt) {
+    gBrowser.selectedBrowser.removeEventListener("load", onload, true);
+    waitForFocus(startTests, content);
+  }, true);
+
+  content.location = "data:text/html,<p>It's going to be legen....</p>";
+}
+
+function startTests(aInspector, aToolbox) {
+  let p = content.document.querySelector("p");
+  Task.spawn(function() {
+    info("opening the inspector tool");
+    let {inspector, toolbox} = yield openInspector();
+
+    info("hovering over the <p> line in the markup-view");
+    yield hoverContainer(p, inspector);
+    ok(isHighlighterVisible(), "the highlighter is still visible");
+
+    info("selecting the <p> line by clicking in the markup-view");
+    yield clickContainer(p, inspector);
+
+    p.textContent = "wait for it ....";
+    info("wait and see if the highlighter stays visible even after the node was selected");
+    yield waitForTheBrieflyShowBoxModelTimeout();
+
+    p.textContent = "dary!!!!";
+    ok(isHighlighterVisible(), "the highlighter is still visible");
+  }).then(null, ok.bind(null, false)).then(endTests);
+}
+
+function endTests() {
+  gBrowser.removeCurrentTab();
+  finish();
+}
+
+function waitForTheBrieflyShowBoxModelTimeout() {
+  let deferred = promise.defer();
+  // Note that the current timeout is 1 sec and is neither configurable nor
+  // exported anywhere we can access, so hard-coding the timeout
+  content.setTimeout(deferred.resolve, 1500);
+  return deferred.promise;
+}
--- a/browser/devtools/markupview/test/head.js
+++ b/browser/devtools/markupview/test/head.js
@@ -35,31 +35,93 @@ function getContainerForRawNode(markupVi
  */
 function openInspector() {
   let deferred = promise.defer();
 
   let target = TargetFactory.forTab(gBrowser.selectedTab);
   gDevTools.showToolbox(target, "inspector").then(function(toolbox) {
     let inspector = toolbox.getCurrentPanel();
     inspector.once("inspector-updated", () => {
-      deferred.resolve(inspector, toolbox);
+      deferred.resolve({toolbox: toolbox, inspector: inspector});
     });
   }).then(null, console.error);
 
   return deferred.promise;
 }
 
+function getNode(nodeOrSelector) {
+  let node = nodeOrSelector;
+
+  if (typeof nodeOrSelector === "string") {
+    node = content.document.querySelector(nodeOrSelector);
+    ok(node, "A node was found for selector " + nodeOrSelector);
+  }
+
+  return node;
+}
+
 /**
- * Set the inspector's current selection to the first match of the given css
- * selector
+ * Set the inspector's current selection to a node or to the first match of the
+ * given css selector
  * @return a promise that resolves when the inspector is updated with the new
  * node
  */
-function selectNode(selector, inspector) {
+function selectNode(nodeOrSelector, inspector) {
+  let node = getNode(nodeOrSelector);
+  let updated = inspector.once("inspector-updated");
+  inspector.selection.setNode(node, "test");
+  return updated;
+}
+
+/**
+ * Simulate a mouse-over on the markup-container (a line in the markup-view)
+ * that corresponds to the node or selector passed.
+ * @return a promise that resolves when the container is hovered and the higlighter
+ * is shown on the corresponding node
+ */
+function hoverContainer(nodeOrSelector, inspector) {
+  let highlit = inspector.toolbox.once("node-highlight");
+  let container = getContainerForRawNode(inspector.markup, getNode(nodeOrSelector));
+  EventUtils.synthesizeMouse(container.tagLine, 2, 2, {type: "mousemove"},
+    inspector.markup.doc.defaultView);
+  return highlit;
+}
+
+/**
+ * Simulate a click on the markup-container (a line in the markup-view)
+ * that corresponds to the node or selector passed.
+ * @return a promise that resolves when the node has been selected.
+ */
+function clickContainer(nodeOrSelector, inspector) {
+  let updated = inspector.once("inspector-updated");
+  let container = getContainerForRawNode(inspector.markup, getNode(nodeOrSelector));
+  EventUtils.synthesizeMouseAtCenter(container.tagLine, {type: "mousedown"},
+    inspector.markup.doc.defaultView);
+  EventUtils.synthesizeMouseAtCenter(container.tagLine, {type: "mouseup"},
+    inspector.markup.doc.defaultView);
+  return updated;
+}
+
+/**
+ * Checks if the highlighter is visible currently
+ */
+function isHighlighterVisible() {
+  let outline = gBrowser.selectedBrowser.parentNode.querySelector(".highlighter-container .highlighter-outline");
+  return outline && !outline.hasAttribute("hidden");
+}
+
+/**
+ * Simulate the mouse leaving the markup-view area
+ * @return a promise when done
+ */
+function mouseLeaveMarkupView(inspector) {
   let deferred = promise.defer();
-  let node = content.document.querySelector(selector);
-  ok(node, "A node was found for selector " + selector + ". Selecting it now");
-  inspector.selection.setNode(node, "test");
-  inspector.once("inspector-updated", () => {
-    deferred.resolve(node);
-  });
+
+  // Find another element to mouseover over in order to leave the markup-view
+  let btn = inspector.toolbox.doc.querySelector(".toolbox-dock-button");
+
+  EventUtils.synthesizeMouse(btn, 2, 2, {type: "mousemove"},
+    inspector.toolbox.doc.defaultView);
+  executeSoon(deferred.resolve);
+
   return deferred.promise;
 }
+
--- a/browser/metro/base/content/flyoutpanels/SettingsCharm.js
+++ b/browser/metro/base/content/flyoutpanels/SettingsCharm.js
@@ -51,17 +51,18 @@ var SettingsCharm = {
         label: Strings.browser.GetStringFromName("aboutCharm1"),
         onselected: function() FlyoutPanelsUI.show('AboutFlyoutPanel')
     });
 
     // Help
     this.addEntry({
         label: Strings.browser.GetStringFromName("helpOnlineCharm"),
         onselected: function() {
-          let url = Services.urlFormatter.formatURLPref("app.support.baseURL");
+          let url = Services.urlFormatter.formatURLPref("app.support.baseURL") +
+            "firefox-help";
           BrowserUI.addAndShowTab(url, Browser.selectedTab);
         }
     });
   },
 
   observe: function SettingsCharm_observe(aSubject, aTopic, aData) {
     if (aTopic == "metro-settings-entry-selected") {
       let entry = this._entries.get(parseInt(aData, 10));
--- a/browser/metro/profile/metro.js
+++ b/browser/metro/profile/metro.js
@@ -423,17 +423,17 @@ pref("dom.ipc.plugins.enabled", true);
 // higher values give content process less CPU time
 pref("dom.ipc.content.nice", 1);
 
 // product URLs
 // The breakpad report server to link to in about:crashes
 pref("breakpad.reportURL", "https://crash-stats.mozilla.com/report/index/");
 // TODO: This is not the correct article for metro!!!
 pref("app.sync.tutorialURL", "https://support.mozilla.org/kb/sync-firefox-between-desktop-and-mobile");
-pref("app.support.baseURL", "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/");
+pref("app.support.baseURL", "https://support.mozilla.org/1/touch/%VERSION%/%OS%/%LOCALE%/");
 pref("app.privacyURL", "http://www.mozilla.org/%LOCALE%/legal/privacy/firefox.html");
 pref("app.creditsURL", "http://www.mozilla.org/credits/");
 pref("app.channelURL", "http://www.mozilla.org/%LOCALE%/firefox/channel/");
 
 // Name of alternate about: page for certificate errors (when undefined, defaults to about:neterror)
 pref("security.alternate_certificate_error_page", "certerror");
 
 pref("security.warn_viewing_mixed", false); // Warning is disabled.  See Bug 616712.
--- a/browser/modules/BrowserUITelemetry.jsm
+++ b/browser/modules/BrowserUITelemetry.jsm
@@ -12,16 +12,25 @@ Cu.import("resource://gre/modules/Servic
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry",
   "resource://gre/modules/UITelemetry.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
   "resource:///modules/RecentWindow.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
   "resource:///modules/CustomizableUI.jsm");
+XPCOMUtils.defineLazyGetter(this, "Timer", function() {
+  let timer = {};
+  Cu.import("resource://gre/modules/Timer.jsm", timer);
+  return timer;
+});
+
+const MS_SECOND = 1000;
+const MS_MINUTE = MS_SECOND * 60;
+const MS_HOUR = MS_MINUTE * 60;
 
 XPCOMUtils.defineLazyGetter(this, "DEFAULT_AREA_PLACEMENTS", function() {
   let result = {
     "PanelUI-contents": [
       "edit-controls",
       "zoom-controls",
       "new-window-button",
       "privatebrowsing-button",
@@ -144,16 +153,24 @@ const MOUSEDOWN_MONITORED_ITEMS = [
 
 // Weakly maps browser windows to objects whose keys are relative
 // timestamps for when some kind of session started. For example,
 // when a customization session started. That way, when the window
 // exits customization mode, we can determine how long the session
 // lasted.
 const WINDOW_DURATION_MAP = new WeakMap();
 
+// Default bucket name, when no other bucket is active.
+const BUCKET_DEFAULT = "__DEFAULT__";
+// Bucket prefix, for named buckets.
+const BUCKET_PREFIX = "bucket_";
+// Standard separator to use between different parts of a bucket name, such
+// as primary name and the time step string.
+const BUCKET_SEPARATOR = "|";
+
 this.BrowserUITelemetry = {
   init: function() {
     UITelemetry.addSimpleMeasureFunction("toolbars",
                                          this.getToolbarMeasures.bind(this));
     Services.obs.addObserver(this, "sessionstore-windows-restored", false);
     Services.obs.addObserver(this, "browser-delayed-startup-finished", false);
     CustomizableUI.addListener(this);
   },
@@ -199,16 +216,17 @@ this.BrowserUITelemetry = {
    * @param aKeys the Array of keys to chain Objects together with.
    * @param aEndWith the value to assign to the last key.
    * @returns a reference to the second last object in the chain -
    *          so in our example, that'd be "b".
    */
   _ensureObjectChain: function(aKeys, aEndWith) {
     let current = this._countableEvents;
     let parent = null;
+    aKeys.unshift(this._bucket);
     for (let [i, key] of Iterator(aKeys)) {
       if (!(key in current)) {
         if (i == aKeys.length - 1) {
           current[key] = aEndWith;
         } else {
           current[key] = {};
         }
       }
@@ -533,27 +551,163 @@ this.BrowserUITelemetry = {
 
   onCustomizeStart: function(aWindow) {
     this._countEvent(["customize", "start"]);
     let durationMap = WINDOW_DURATION_MAP.get(aWindow);
     if (!durationMap) {
       durationMap = {};
       WINDOW_DURATION_MAP.set(aWindow, durationMap);
     }
-    durationMap.customization = aWindow.performance.now();
+
+    durationMap.customization = {
+      start: aWindow.performance.now(),
+      bucket: this._bucket,
+    };
   },
 
   onCustomizeEnd: function(aWindow) {
     let durationMap = WINDOW_DURATION_MAP.get(aWindow);
     if (durationMap && "customization" in durationMap) {
-      let duration = aWindow.performance.now() - durationMap.customization;
-      this._durations.customization.push(duration);
+      let duration = aWindow.performance.now() - durationMap.customization.start;
+      this._durations.customization.push({
+        duration: duration,
+        bucket: durationMap.customization.bucket,
+      });
       delete durationMap.customization;
     }
   },
+
+  _bucket: BUCKET_DEFAULT,
+  _bucketTimer: null,
+
+  /**
+   * Default bucket name, when no other bucket is active.
+   */
+  get BUCKET_DEFAULT() BUCKET_DEFAULT,
+
+  /**
+   * Bucket prefix, for named buckets.
+   */
+  get BUCKET_PREFIX() BUCKET_PREFIX,
+
+  /**
+   * Standard separator to use between different parts of a bucket name, such
+   * as primary name and the time step string.
+   */
+  get BUCKET_SEPARATOR() BUCKET_SEPARATOR,
+
+  get currentBucket() {
+    return this._bucket;
+  },
+
+  /**
+   * Sets a named bucket for all countable events and select durections to be
+   * put into.
+   *
+   * @param aName  Name of bucket, or null for default bucket name (__DEFAULT__)
+   */
+  setBucket: function(aName) {
+    if (this._bucketTimer) {
+      Timer.clearTimeout(this._bucketTimer);
+      this._bucketTimer = null;
+    }
+
+    if (aName)
+      this._bucket = BUCKET_PREFIX + aName;
+    else
+      this._bucket = BUCKET_DEFAULT;
+  },
+
+  /**
+  * Sets a bucket that expires at the rate of a given series of time steps.
+  * Once the bucket expires, the current bucket will automatically revert to
+  * the default bucket. While the bucket is expiring, it's name is postfixed
+  * by '|' followed by a short string representation of the time step it's
+  * currently in.
+  * If any other bucket (expiring or normal) is set while an expiring bucket is
+  * still expiring, the old expiring bucket stops expiring and the new bucket
+  * immediately takes over.
+  *
+  * @param aName       Name of bucket.
+  * @param aTimeSteps  An array of times in milliseconds to count up to before
+  *                    reverting back to the default bucket. The array of times
+  *                    is expected to be pre-sorted in ascending order.
+  *                    For example, given a bucket name of 'bucket', the times:
+  *                      [60000, 300000, 600000]
+  *                    will result in the following buckets:
+  *                    * bucket|1m - for the first 1 minute
+  *                    * bucket|5m - for the following 4 minutes
+  *                                  (until 5 minutes after the start)
+  *                    * bucket|10m - for the following 5 minutes
+  *                                   (until 10 minutes after the start)
+  *                    * __DEFAULT__ - until a new bucket is set
+  * @param aTimeOffset Time offset, in milliseconds, from which to start
+  *                    counting. For example, if the first time step is 1000ms,
+  *                    and the time offset is 300ms, then the next time step
+  *                    will become active after 700ms. This affects all
+  *                    following time steps also, meaning they will also all be
+  *                    timed as though they started expiring 300ms before
+  *                    setExpiringBucket was called.
+  */
+  setExpiringBucket: function(aName, aTimeSteps, aTimeOffset = 0) {
+    if (aTimeSteps.length === 0) {
+      this.setBucket(null);
+      return;
+    }
+
+    if (this._bucketTimer) {
+      Timer.clearTimeout(this._bucketTimer);
+      this._bucketTimer = null;
+    }
+
+    // Make a copy of the time steps array, so we can safely modify it without
+    // modifying the original array that external code has passed to us.
+    let steps = [...aTimeSteps];
+    let msec = steps.shift();
+    let postfix = this._toTimeStr(msec);
+    this.setBucket(aName + BUCKET_SEPARATOR + postfix);
+
+    this._bucketTimer = Timer.setTimeout(() => {
+      this._bucketTimer = null;
+      this.setExpiringBucket(aName, steps, aTimeOffset + msec);
+    }, msec - aTimeOffset);
+  },
+
+  /**
+   * Formats a time interval, in milliseconds, to a minimal non-localized string
+   * representation. Format is: 'h' for hours, 'm' for minutes, 's' for seconds,
+   * 'ms' for milliseconds.
+   * Examples:
+   *   65 => 65ms
+   *   1000 => 1s
+   *   60000 => 1m
+   *   61000 => 1m01s
+   *
+   * @param aTimeMS  Time in milliseconds
+   *
+   * @return Minimal string representation.
+   */
+  _toTimeStr: function(aTimeMS) {
+    let timeStr = "";
+
+    function reduce(aUnitLength, aSymbol) {
+      if (aTimeMS >= aUnitLength) {
+        let units = Math.floor(aTimeMS / aUnitLength);
+        aTimeMS = aTimeMS - (units * aUnitLength)
+        timeStr += units + aSymbol;
+      }
+    }
+
+    reduce(MS_HOUR, "h");
+    reduce(MS_MINUTE, "m");
+    reduce(MS_SECOND, "s");
+    reduce(1, "ms");
+
+    return timeStr;
+  },
 };
 
 /**
  * Returns the id of the first ancestor of aNode that has an id. If aNode
  * has no parent, or no ancestor has an id, returns null.
  *
  * @param aNode the node to find the first ID'd ancestor of
  */
--- a/browser/modules/UITour.jsm
+++ b/browser/modules/UITour.jsm
@@ -13,24 +13,40 @@ Cu.import("resource://gre/modules/XPCOMU
 Cu.import("resource://gre/modules/Promise.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
   "resource://gre/modules/LightweightThemeManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PermissionsUtils",
   "resource://gre/modules/PermissionsUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
   "resource:///modules/CustomizableUI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry",
+  "resource://gre/modules/UITelemetry.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BrowserUITelemetry",
+  "resource:///modules/BrowserUITelemetry.jsm");
 
 
 const UITOUR_PERMISSION   = "uitour";
 const PREF_PERM_BRANCH    = "browser.uitour.";
 const MAX_BUTTONS         = 4;
 
+const BUCKET_NAME         = "UITour";
+const BUCKET_TIMESTEPS    = [
+  1 * 60 * 1000, // Until 1 minute after tab is closed/inactive.
+  3 * 60 * 1000, // Until 3 minutes after tab is closed/inactive.
+  10 * 60 * 1000, // Until 10 minutes after tab is closed/inactive.
+  60 * 60 * 1000, // Until 1 hour after tab is closed/inactive.
+];
+
+
 
 this.UITour = {
+  seenPageIDs: new Set(),
+  pageIDSourceTabs: new WeakMap(),
+  pageIDSourceWindows: new WeakMap(),
   originTabs: new WeakMap(),
   pinnedTabs: new WeakMap(),
   urlbarCapture: new WeakMap(),
   appMenuOpenForAnnotation: new Set(),
 
   highlightEffects: ["random", "wobble", "zoom", "color"],
   targets: new Map([
     ["accountStatus", {
@@ -75,28 +91,33 @@ this.UITour = {
       widgetName: "search-container",
     }],
     ["selectedTabIcon", {
       query: (aDocument) => {
         let selectedtab = aDocument.defaultView.gBrowser.selectedTab;
         let element = aDocument.getAnonymousElementByAttribute(selectedtab,
                                                                "anonid",
                                                                "tab-icon-image");
-        if (!element || !_isElementVisible(element)) {
+        if (!element || !this.isElementVisible(element)) {
           return null;
         }
         return element;
       },
     }],
     ["urlbar",      {
       query: "#urlbar",
       widgetName: "urlbar-container",
     }],
   ]),
 
+  init: function() {
+    UITelemetry.addSimpleMeasureFunction("UITour",
+                                         this.getTelemetry.bind(this));
+  },
+
   onPageEvent: function(aEvent) {
     let contentDocument = null;
     if (aEvent.target instanceof Ci.nsIDOMHTMLDocument)
       contentDocument = aEvent.target;
     else if (aEvent.target instanceof Ci.nsIDOMHTMLElement)
       contentDocument = aEvent.target.ownerDocument;
     else
       return false;
@@ -112,18 +133,36 @@ this.UITour = {
     if (typeof action != "string" || !action)
       return false;
 
     let data = aEvent.detail.data;
     if (typeof data != "object")
       return false;
 
     let window = this.getChromeWindow(contentDocument);
+    let tab = window.gBrowser._getTabForContentWindow(contentDocument.defaultView);
 
     switch (action) {
+      case "registerPageID": {
+        // We don't want to allow BrowserUITelemetry.BUCKET_SEPARATOR in the
+        // pageID, as it could make parsing the telemetry bucket name difficult.
+        if (typeof data.pageID == "string" &&
+            !data.pageID.contains(BrowserUITelemetry.BUCKET_SEPARATOR)) {
+          this.seenPageIDs.add(data.pageID);
+
+          // Store tabs and windows separately so we don't need to loop over all
+          // tabs when a window is closed.
+          this.pageIDSourceTabs.set(tab, data.pageID);
+          this.pageIDSourceWindows.set(window, data.pageID);
+
+          this.setTelemetryBucket(data.pageID);
+        }
+        break;
+      }
+
       case "showHighlight": {
         let targetPromise = this.getTarget(window, data.target);
         targetPromise.then(target => {
           if (!target.node) {
             Cu.reportError("UITour: Target could not be resolved: " + data.target);
             return;
           }
           let effect = undefined;
@@ -253,17 +292,16 @@ this.UITour = {
           return false;
         }
 
         this.getConfiguration(contentDocument, data.configuration, data.callbackID);
         break;
       }
     }
 
-    let tab = window.gBrowser._getTabForContentWindow(contentDocument.defaultView);
     if (!this.originTabs.has(window))
       this.originTabs.set(window, new Set());
     this.originTabs.get(window).add(tab);
 
     tab.addEventListener("TabClose", this);
     window.gBrowser.tabContainer.addEventListener("TabSelect", this);
     window.addEventListener("SSWindowClosing", this);
 
@@ -274,36 +312,63 @@ this.UITour = {
     switch (aEvent.type) {
       case "pagehide": {
         let window = this.getChromeWindow(aEvent.target);
         this.teardownTour(window);
         break;
       }
 
       case "TabClose": {
-        let window = aEvent.target.ownerDocument.defaultView;
+        let tab = aEvent.target;
+        if (this.pageIDSourceTabs.has(tab)) {
+          let pageID = this.pageIDSourceTabs.get(tab);
+
+          // Delete this from the window cache, so if the window is closed we
+          // don't expire this page ID twice.
+          let window = tab.ownerDocument.defaultView;
+          if (this.pageIDSourceWindows.get(window) == pageID)
+            this.pageIDSourceWindows.delete(window);
+
+          this.setExpiringTelemetryBucket(pageID, "closed");
+        }
+
+        let window = tab.ownerDocument.defaultView;
         this.teardownTour(window);
         break;
       }
 
       case "TabSelect": {
+        if (aEvent.detail && aEvent.detail.previousTab) {
+          let previousTab = aEvent.detail.previousTab;
+
+          if (this.pageIDSourceTabs.has(previousTab)) {
+            let pageID = this.pageIDSourceTabs.get(previousTab);
+            this.setExpiringTelemetryBucket(pageID, "inactive");
+          }
+        }
+
         let window = aEvent.target.ownerDocument.defaultView;
         let pinnedTab = this.pinnedTabs.get(window);
         if (pinnedTab && pinnedTab.tab == window.gBrowser.selectedTab)
           break;
         let originTabs = this.originTabs.get(window);
         if (originTabs && originTabs.has(window.gBrowser.selectedTab))
           break;
 
         this.teardownTour(window);
         break;
       }
 
       case "SSWindowClosing": {
         let window = aEvent.target;
+        if (this.pageIDSourceWindows.has(window)) {
+          let pageID = this.pageIDSourceWindows.get(window);
+          this.setExpiringTelemetryBucket(pageID, "closed");
+        }
+
         this.teardownTour(window, true);
         break;
       }
 
       case "input": {
         if (aEvent.target.id == "urlbar") {
           let window = aEvent.target.ownerDocument.defaultView;
           this.handleUrlbarInput(window);
@@ -316,16 +381,35 @@ this.UITour = {
           let window = aEvent.target.ownerDocument.defaultView;
           this.hideInfo(window);
         }
         break;
       }
     }
   },
 
+  setTelemetryBucket: function(aPageID) {
+    let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID;
+    BrowserUITelemetry.setBucket(bucket);
+  },
+
+  setExpiringTelemetryBucket: function(aPageID, aType) {
+    let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID +
+                 BrowserUITelemetry.BUCKET_SEPARATOR + aType;
+
+    BrowserUITelemetry.setExpiringBucket(bucket,
+                                         BUCKET_TIMESTEPS);
+  },
+
+  getTelemetry: function() {
+    return {
+      seenPageIDs: [...this.seenPageIDs],
+    };
+  },
+
   teardownTour: function(aWindow, aWindowClosing = false) {
     aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this);
     aWindow.PanelUI.panel.removeEventListener("popuphiding", this.onAppMenuHiding);
     aWindow.removeEventListener("SSWindowClosing", this);
 
     let originTabs = this.originTabs.get(aWindow);
     if (originTabs) {
       for (let tab of originTabs)
@@ -420,16 +504,21 @@ this.UITour = {
     let event = new aDocument.defaultView.CustomEvent("mozUITourResponse", {
       bubbles: true,
       detail: detail
     });
 
     aDocument.dispatchEvent(event);
   },
 
+  isElementVisible: function(aElement) {
+    let targetStyle = aElement.ownerDocument.defaultView.getComputedStyle(aElement);
+    return (targetStyle.display != "none" && targetStyle.visibility == "visible");
+  },
+
   getTarget: function(aWindow, aTargetName, aSticky = false) {
     let deferred = Promise.defer();
     if (typeof aTargetName != "string" || !aTargetName) {
       deferred.reject("Invalid target name specified");
       return deferred.promise;
     }
 
     if (aTargetName == "pinnedTab") {
@@ -616,17 +705,17 @@ this.UITour = {
       let offsetX = paddingTopPx
                       - (Math.max(0, highlightWidthWithMin - targetRect.width) / 2);
       let offsetY = paddingLeftPx
                       - (Math.max(0, highlightHeightWithMin - targetRect.height) / 2);
       highlighter.parentElement.openPopup(aTargetEl, "overlap", offsetX, offsetY);
     }
 
     // Prevent showing a panel at an undefined position.
-    if (!_isElementVisible(aTarget.node))
+    if (!this.isElementVisible(aTarget.node))
       return;
 
     this._setAppMenuStateForAnnotation(aTarget.node.ownerDocument.defaultView, "highlight",
                                        this.targetIsInAppMenu(aTarget),
                                        showHighlightPanel.bind(this, aTarget.node));
   },
 
   hideHighlight: function(aWindow) {
@@ -689,17 +778,17 @@ this.UITour = {
 
       tooltip.setAttribute("targetName", aAnchor.targetName);
       tooltip.hidden = false;
       let alignment = "bottomcenter topright";
       tooltip.openPopup(aAnchorEl, alignment);
     }
 
     // Prevent showing a panel at an undefined position.
-    if (!_isElementVisible(aAnchor.node))
+    if (!this.isElementVisible(aAnchor.node))
       return;
 
     this._setAppMenuStateForAnnotation(aAnchor.node.ownerDocument.defaultView, "info",
                                        this.targetIsInAppMenu(aAnchor),
                                        showInfoPanel.bind(this, aAnchor.node));
   },
 
   hideInfo: function(aWindow) {
@@ -828,12 +917,9 @@ this.UITour = {
       default:
         Cu.reportError("getConfiguration: Unknown configuration requested: " + aConfiguration);
         break;
     }
     this.sendPageCallback(aContentDocument, aCallbackId, config);
   },
 };
 
-function _isElementVisible(aElement) {
-  let targetStyle = aElement.ownerDocument.defaultView.getComputedStyle(aElement);
-  return (targetStyle.display != "none" && targetStyle.visibility == "visible");
-}
+this.UITour.init();
--- a/browser/modules/test/browser.ini
+++ b/browser/modules/test/browser.ini
@@ -1,16 +1,18 @@
 [DEFAULT]
 support-files =
   head.js
   uitour.*
   image.png
 
+[browser_BrowserUITelemetry_buckets.js]
 [browser_NetworkPrioritizer.js]
 [browser_SignInToWebsite.js]
 [browser_UITour.js]
 skip-if = os == "linux" # Intermittent failures, bug 951965
 [browser_UITour2.js]
 [browser_UITour3.js]
 [browser_UITour_panel_close_annotation.js]
+[browser_UITour_registerPageID.js]
 [browser_UITour_sync.js]
 [browser_taskbar_preview.js]
 run-if = os == "win"
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser_BrowserUITelemetry_buckets.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+    WHERE'S MAH BUCKET?!
+          \
+                     ___
+                  .-9 9 `\
+                =(:(::)=  ;
+                  ||||     \
+                  ||||      `-.
+                 ,\|\|         `,
+                /                \
+               ;                  `'---.,
+               |                         `\
+               ;                     /     |
+               \                    |      /
+                )           \  __,.--\    /
+             .-' \,..._\     \`   .-'  .-'
+            `-=``      `:    |  /-/-/`
+                         `.__/
+*/
+
+"use strict";
+
+
+function generatorTest() {
+  let s = {};
+  Components.utils.import("resource:///modules/BrowserUITelemetry.jsm", s);
+  let BUIT = s.BrowserUITelemetry;
+
+  registerCleanupFunction(function() {
+    BUIT.setBucket(null);
+  });
+
+
+  // setBucket
+  is(BUIT.currentBucket, BUIT.BUCKET_DEFAULT, "Bucket should be default bucket");
+  BUIT.setBucket("mah-bucket");
+  is(BUIT.currentBucket, BUIT.BUCKET_PREFIX + "mah-bucket", "Bucket should have correct name");
+  BUIT.setBucket(null);
+  is(BUIT.currentBucket, BUIT.BUCKET_DEFAULT, "Bucket should be reset to default");
+
+
+  // _toTimeStr
+  is(BUIT._toTimeStr(10), "10ms", "Checking time string reprentation, 10ms");
+  is(BUIT._toTimeStr(1000 + 10), "1s10ms", "Checking time string reprentation, 1s10ms");
+  is(BUIT._toTimeStr((20 * 1000) + 10), "20s10ms", "Checking time string reprentation, 20s10ms");
+  is(BUIT._toTimeStr(60 * 1000), "1m", "Checking time string reprentation, 1m");
+  is(BUIT._toTimeStr(3 * 60 * 1000), "3m", "Checking time string reprentation, 3m");
+  is(BUIT._toTimeStr((3 * 60 * 1000) + 1), "3m1ms", "Checking time string reprentation, 3m1ms");
+  is(BUIT._toTimeStr((60 * 60 * 1000) + (10 * 60 * 1000)), "1h10m", "Checking time string reprentation, 1h10m");
+  is(BUIT._toTimeStr(100 * 60 * 60 * 1000), "100h", "Checking time string reprentation, 100h");
+
+
+  // setExpiringBucket
+  BUIT.setExpiringBucket("walrus", [1001, 2001, 3001, 10001]);
+  is(BUIT.currentBucket, BUIT.BUCKET_PREFIX + "walrus" + BUIT.BUCKET_SEPARATOR + "1s1ms",
+     "Bucket should be expiring and have time step of 1s1ms");
+
+  waitForCondition(function() {
+    return BUIT.currentBucket == (BUIT.BUCKET_PREFIX + "walrus" + BUIT.BUCKET_SEPARATOR + "2s1ms");
+  }, nextStep, "Bucket should be expiring and have time step of 2s1ms");
+  yield undefined;
+
+  waitForCondition(function() {
+    return BUIT.currentBucket == (BUIT.BUCKET_PREFIX + "walrus" + BUIT.BUCKET_SEPARATOR + "3s1ms");
+  }, nextStep, "Bucket should be expiring and have time step of 3s1ms");
+  yield undefined;
+
+
+  // Interupt previous expiring bucket
+  BUIT.setExpiringBucket("walrus2", [1002, 2002]);
+  is(BUIT.currentBucket, BUIT.BUCKET_PREFIX + "walrus2" + BUIT.BUCKET_SEPARATOR + "1s2ms",
+     "Should be new expiring bucket, with time step of 1s2ms");
+
+  waitForCondition(function() {
+    return BUIT.currentBucket == (BUIT.BUCKET_PREFIX + "walrus2" + BUIT.BUCKET_SEPARATOR + "2s2ms");
+  }, nextStep, "Should be new expiring bucket, with time step of 2s2ms");
+  yield undefined;
+
+
+  // Let expiring bucket expire
+  waitForCondition(function() {
+    return BUIT.currentBucket == BUIT.BUCKET_DEFAULT;
+  }, nextStep, "Bucket should have expired, default bucket should now be active");
+  yield undefined;
+
+
+  // Interupt expiring bucket with normal bucket
+  BUIT.setExpiringBucket("walrus3", [1003, 2003]);
+  is(BUIT.currentBucket, BUIT.BUCKET_PREFIX + "walrus3" + BUIT.BUCKET_SEPARATOR + "1s3ms",
+     "Should be new expiring bucket, with time step of 1s3ms");
+
+  BUIT.setBucket("mah-bucket");
+  is(BUIT.currentBucket, BUIT.BUCKET_PREFIX + "mah-bucket", "Bucket should have correct name");
+
+  waitForCondition(function() {
+    return BUIT.currentBucket == (BUIT.BUCKET_PREFIX + "mah-bucket");
+  }, nextStep, "Next step of old expiring bucket shouldn't have progressed");
+  yield undefined;
+}
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser_UITour_registerPageID.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let gTestTab;
+let gContentAPI;
+let gContentWindow;
+
+Components.utils.import("resource:///modules/UITour.jsm");
+Components.utils.import("resource:///modules/BrowserUITelemetry.jsm");
+
+function test() {
+  registerCleanupFunction(function() {
+    UITour.seenPageIDs.clear();
+    BrowserUITelemetry.setBucket(null);
+    delete window.BrowserUITelemetry;
+  });
+  UITourTest();
+}
+
+let tests = [
+  function test_seenPageIDs_1(done) {
+    gContentAPI.registerPageID("testpage1");
+
+    is(UITour.seenPageIDs.size, 1, "Should be 1 seen page ID");
+    ok(UITour.seenPageIDs.has("testpage1"), "Should have seen 'testpage1' page ID");
+
+    const PREFIX = BrowserUITelemetry.BUCKET_PREFIX;
+    const SEP = BrowserUITelemetry.BUCKET_SEPARATOR;
+
+    let bucket = PREFIX + "UITour" + SEP + "testpage1";
+    is(BrowserUITelemetry.currentBucket, bucket, "Bucket should have correct name");
+
+    gBrowser.selectedTab = gBrowser.addTab("about:blank");
+    bucket = PREFIX + "UITour" + SEP + "testpage1" + SEP + "inactive" + SEP + "1m";
+    is(BrowserUITelemetry.currentBucket, bucket,
+       "After switching tabs, bucket should be expiring");
+
+    gBrowser.removeTab(gBrowser.selectedTab);
+    gBrowser.selectedTab = gTestTab;
+    BrowserUITelemetry.setBucket(null);
+    done();
+  },
+  function test_seenPageIDs_2(done) {
+    gContentAPI.registerPageID("testpage2");
+
+    is(UITour.seenPageIDs.size, 2, "Should be 2 seen page IDs");
+    ok(UITour.seenPageIDs.has("testpage1"), "Should have seen 'testpage1' page ID");
+    ok(UITour.seenPageIDs.has("testpage2"), "Should have seen 'testpage2' page ID");
+
+    const PREFIX = BrowserUITelemetry.BUCKET_PREFIX;
+    const SEP = BrowserUITelemetry.BUCKET_SEPARATOR;
+
+    let bucket = PREFIX + "UITour" + SEP + "testpage2";
+    is(BrowserUITelemetry.currentBucket, bucket, "Bucket should have correct name");
+
+    gBrowser.removeTab(gTestTab);
+    gTestTab = null;
+    bucket = PREFIX + "UITour" + SEP + "testpage2" + SEP + "closed" + SEP + "1m";
+    is(BrowserUITelemetry.currentBucket, bucket,
+       "After closing tab, bucket should be expiring");
+
+    BrowserUITelemetry.setBucket(null);
+    done();
+  },
+];
--- a/browser/modules/test/uitour.js
+++ b/browser/modules/test/uitour.js
@@ -55,16 +55,22 @@ if (typeof Mozilla == 'undefined') {
 		}
 		document.addEventListener("mozUITourResponse", listener);
 
 		return id;
 	}
 
 	Mozilla.UITour.DEFAULT_THEME_CYCLE_DELAY = 10 * 1000;
 
+	Mozilla.UITour.registerPageID = function(pageID) {
+		_sendEvent('registerPageID', {
+			pageID: pageID
+		});
+	};
+
 	Mozilla.UITour.showHighlight = function(target, effect) {
 		_sendEvent('showHighlight', {
 			target: target,
 			effect: effect
 		});
 	};
 
 	Mozilla.UITour.hideHighlight = function() {
--- a/docshell/base/nsDocShell.cpp
+++ b/docshell/base/nsDocShell.cpp
@@ -2962,18 +2962,25 @@ nsDocShell::RecomputeCanExecuteScripts()
             static_cast<nsDocShell*>(iter.GetNext())->RecomputeCanExecuteScripts();
         }
     }
 }
 
 nsresult
 nsDocShell::SetDocLoaderParent(nsDocLoader * aParent)
 {
+    bool wasFrame = IsFrame();
+
     nsDocLoader::SetDocLoaderParent(aParent);
 
+    nsCOMPtr<nsISupportsPriority> priorityGroup = do_QueryInterface(mLoadGroup);
+    if (wasFrame != IsFrame() && priorityGroup) {
+        priorityGroup->AdjustPriority(wasFrame ? -1 : 1);
+    }
+
     // Curse ambiguous nsISupports inheritance!
     nsISupports* parent = GetAsSupports(aParent);
 
     // If parent is another docshell, we inherit all their flags for
     // allowing plugins, scripting etc.
     bool value;
     nsCOMPtr<nsIDocShell> parentAsDocShell(do_QueryInterface(parent));
     if (parentAsDocShell)
--- a/docshell/shistory/public/nsISHistory.idl
+++ b/docshell/shistory/public/nsISHistory.idl
@@ -58,16 +58,17 @@ interface nsISHistory: nsISupports
    */
    attribute long maxLength;
 
   /**
    * Called to obtain handle to the history entry at a
    * given index.
    *
    * @param index             The index value whose entry is requested.
+   *                          The oldest entry is located at index == 0.
    * @param modifyIndex       A boolean flag that indicates if the current
    *                          index of session history should be modified 
    *                          to the parameter index.
    *
    * @return                  <code>NS_OK</code> history entry for 
    *                          the index is obtained successfully.
    *                          <code>NS_ERROR_FAILURE</code> Error in obtaining
    *                          history entry for the given index.
--- a/layout/xul/nsMenuPopupFrame.cpp
+++ b/layout/xul/nsMenuPopupFrame.cpp
@@ -906,32 +906,32 @@ nsMenuPopupFrame::AdjustPositionForAncho
   }
 
   // first, determine at which corner of the anchor the popup should appear
   nsPoint pnt;
   switch (popupAnchor) {
     case POPUPALIGNMENT_LEFTCENTER:
       pnt = nsPoint(anchorRect.x, anchorRect.y + anchorRect.height / 2);
       anchorRect.y = pnt.y;
-      anchorRect.height = 1;
+      anchorRect.height = 0;
       break;
     case POPUPALIGNMENT_RIGHTCENTER:
       pnt = nsPoint(anchorRect.XMost(), anchorRect.y + anchorRect.height / 2);
       anchorRect.y = pnt.y;
-      anchorRect.height = 1;
+      anchorRect.height = 0;
       break;
     case POPUPALIGNMENT_TOPCENTER:
       pnt = nsPoint(anchorRect.x + anchorRect.width / 2, anchorRect.y);
       anchorRect.x = pnt.x;
-      anchorRect.width = 1;
+      anchorRect.width = 0;
       break;
     case POPUPALIGNMENT_BOTTOMCENTER:
       pnt = nsPoint(anchorRect.x + anchorRect.width / 2, anchorRect.YMost());
       anchorRect.x = pnt.x;
-      anchorRect.width = 1;
+      anchorRect.width = 0;
       break;
     case POPUPALIGNMENT_TOPRIGHT:
       pnt = anchorRect.TopRight();
       break;
     case POPUPALIGNMENT_BOTTOMLEFT:
       pnt = anchorRect.BottomLeft();
       break;
     case POPUPALIGNMENT_BOTTOMRIGHT:
@@ -1276,29 +1276,18 @@ nsMenuPopupFrame::SetPopupPosition(nsIFr
     vFlip = FlipStyle_Outside;
   }
 
   // If a panel is being moved or has flip="none", don't constrain or flip it. But always do this for
   // content shells, so that the popup doesn't extend outside the containing frame.
   if (mInContentShell || (mFlip != FlipType_None && (!aIsMove || mPopupType != ePopupTypePanel))) {
     nsRect screenRect = GetConstraintRect(anchorRect, rootScreenRect);
 
-    // ensure that anchorRect is on screen
-    if (!anchorRect.IntersectRect(anchorRect, screenRect)) {
-      anchorRect.width = anchorRect.height = 0;
-      // if the anchor isn't within the screen, move it to the edge of the screen.
-      if (anchorRect.x < screenRect.x)
-        anchorRect.x = screenRect.x;
-      if (anchorRect.XMost() > screenRect.XMost())
-        anchorRect.x = screenRect.XMost();
-      if (anchorRect.y < screenRect.y)
-        anchorRect.y = screenRect.y;
-      if (anchorRect.YMost() > screenRect.YMost())
-        anchorRect.y = screenRect.YMost();
-    }
+    // Ensure that anchorRect is on screen.
+    anchorRect = anchorRect.Intersect(screenRect);
 
     // shrink the the popup down if it is larger than the screen size
     if (mRect.width > screenRect.width)
       mRect.width = screenRect.width;
     if (mRect.height > screenRect.height)
       mRect.height = screenRect.height;
 
     // at this point the anchor (anchorRect) is within the available screen
--- a/mobile/android/app/mobile.js
+++ b/mobile/android/app/mobile.js
@@ -820,8 +820,14 @@ pref("browser.snippets.statsUrl", "https
 pref("browser.snippets.enabled", true);
 pref("browser.snippets.syncPromo.enabled", false);
 
 #ifdef MOZ_ANDROID_SYNTHAPKS
 // The URL of the APK factory from which we obtain APKs for webapps.
 // This currently points to the development server.
 pref("browser.webapps.apkFactoryUrl", "http://dapk.net/application.apk");
 #endif
+
+// Whether or not to only sync home provider data when the user is on wifi.
+pref("home.sync.wifiOnly", false);
+
+// How frequently to check if we should sync home provider data.
+pref("home.sync.checkIntervalSecs", 3600);
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -572,18 +572,16 @@ public abstract class GeckoApp
                 }
             } else if (event.equals("log")) {
                 // generic log listener
                 final String msg = message.getString("msg");
                 Log.d(LOGTAG, "Log: " + msg);
             } else if (event.equals("Reader:FaviconRequest")) {
                 final String url = message.getString("url");
                 handleFaviconRequest(url);
-            } else if (event.equals("Gecko:DelayedStartup")) {
-                ThreadUtils.postToBackgroundThread(new UninstallListener.DelayedStartupTask(this));
             } else if (event.equals("Gecko:Ready")) {
                 mGeckoReadyStartupTimer.stop();
                 geckoConnected();
 
                 // This method is already running on the background thread, so we
                 // know that mHealthRecorder will exist. That doesn't stop us being
                 // paranoid.
                 // This method is cheap, so don't spawn a new runnable.
@@ -1325,16 +1323,21 @@ public abstract class GeckoApp
 
                 final String uiLocale = appLocale;
                 ThreadUtils.postToUiThread(new Runnable() {
                     @Override
                     public void run() {
                         GeckoApp.this.onLocaleReady(uiLocale);
                     }
                 });
+
+                // Perform webapp uninstalls as appropiate.
+                if (AppConstants.MOZ_ANDROID_SYNTHAPKS) {
+                    UninstallListener.initUninstallPackageScan(getApplicationContext());
+                }
             }
         });
 
         GeckoAppShell.setNotificationClient(makeNotificationClient());
         NotificationHelper.init(getApplicationContext());
     }
 
     /**
@@ -1520,17 +1523,16 @@ public abstract class GeckoApp
         registerEventListener("Reader:ListCountRequest");
         registerEventListener("Reader:ListStatusRequest");
         registerEventListener("Reader:Added");
         registerEventListener("Reader:Removed");
         registerEventListener("Reader:Share");
         registerEventListener("Reader:FaviconRequest");
         registerEventListener("onCameraCapture");
         registerEventListener("Gecko:Ready");
-        registerEventListener("Gecko:DelayedStartup");
         registerEventListener("Toast:Show");
         registerEventListener("DOMFullScreen:Start");
         registerEventListener("DOMFullScreen:Stop");
         registerEventListener("ToggleChrome:Hide");
         registerEventListener("ToggleChrome:Show");
         registerEventListener("ToggleChrome:Focus");
         registerEventListener("Permissions:Data");
         registerEventListener("Session:StatePurged");
@@ -2047,17 +2049,16 @@ public abstract class GeckoApp
         unregisterEventListener("Reader:ListCountRequest");
         unregisterEventListener("Reader:ListStatusRequest");
         unregisterEventListener("Reader:Added");
         unregisterEventListener("Reader:Removed");
         unregisterEventListener("Reader:Share");
         unregisterEventListener("Reader:FaviconRequest");
         unregisterEventListener("onCameraCapture");
         unregisterEventListener("Gecko:Ready");
-        unregisterEventListener("Gecko:DelayedStartup");
         unregisterEventListener("Toast:Show");
         unregisterEventListener("DOMFullScreen:Start");
         unregisterEventListener("DOMFullScreen:Stop");
         unregisterEventListener("ToggleChrome:Hide");
         unregisterEventListener("ToggleChrome:Show");
         unregisterEventListener("ToggleChrome:Focus");
         unregisterEventListener("Permissions:Data");
         unregisterEventListener("Session:StatePurged");
--- a/mobile/android/base/Makefile.in
+++ b/mobile/android/base/Makefile.in
@@ -59,16 +59,17 @@ GARBAGE += \
 GARBAGE_DIRS += classes db jars res sync services generated
 
 JAVA_CLASSPATH = $(ANDROID_SDK)/android.jar
 
 ALL_JARS = \
   gecko-browser.jar \
   gecko-mozglue.jar \
   gecko-util.jar \
+  squareup-picasso.jar \
   sync-thirdparty.jar \
   websockets.jar \
   $(NULL)
 
 ifdef MOZ_WEBRTC
 ALL_JARS += webrtc.jar
 endif
 
--- a/mobile/android/base/db/HomeProvider.java
+++ b/mobile/android/base/db/HomeProvider.java
@@ -98,29 +98,31 @@ public class HomeProvider extends SQLite
             return null;
         }
 
         final String[] itemsColumns = new String[] {
             HomeItems._ID,
             HomeItems.DATASET_ID,
             HomeItems.URL,
             HomeItems.TITLE,
-            HomeItems.DESCRIPTION
+            HomeItems.DESCRIPTION,
+            HomeItems.IMAGE_URL
         };
 
         final MatrixCursor c = new MatrixCursor(itemsColumns);
         for (int i = 0; i < items.length(); i++) {
             try {
                 final JSONObject item = items.getJSONObject(i);
                 c.addRow(new Object[] {
                     item.getInt("id"),
                     item.getString("dataset_id"),
                     item.getString("url"),
                     item.getString("title"),
-                    item.getString("description")
+                    item.getString("description"),
+                    item.getString("image_url")
                 });
             } catch (JSONException e) {
                 Log.e(LOGTAG, "Error creating cursor row for fake home item", e);
             }
         }
         return c;
     }
 
--- a/mobile/android/base/home/PanelGridItemView.java
+++ b/mobile/android/base/home/PanelGridItemView.java
@@ -3,20 +3,22 @@
  * 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.home;
 
 import java.net.MalformedURLException;
 import java.net.URL;
 
-import org.mozilla.gecko.db.BrowserContract.URLColumns;
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
 import org.mozilla.gecko.favicons.Favicons;
 import org.mozilla.gecko.R;
 
+import com.squareup.picasso.Picasso;
+
 import android.content.Context;
 import android.database.Cursor;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.Color;
 import android.text.TextUtils;
 import android.util.AttributeSet;
 import android.util.Log;
@@ -42,10 +44,17 @@ public class PanelGridItemView extends F
 
     public PanelGridItemView(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
 
         LayoutInflater.from(context).inflate(R.layout.panel_grid_item_view, this);
         mThumbnailView = (ImageView) findViewById(R.id.image);
     }
 
-    public void updateFromCursor(Cursor cursor) { }
+    public void updateFromCursor(Cursor cursor) {
+        int imageIndex = cursor.getColumnIndexOrThrow(HomeItems.IMAGE_URL);
+        final String imageUrl = cursor.getString(imageIndex);
+
+        Picasso.with(getContext())
+               .load(imageUrl)
+               .into(mThumbnailView);
+    }
 }
--- a/mobile/android/base/home/PanelListRow.java
+++ b/mobile/android/base/home/PanelListRow.java
@@ -1,60 +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.home;
 
-import android.util.Log;
-import org.mozilla.gecko.favicons.Favicons;
 import org.mozilla.gecko.R;
-import org.mozilla.gecko.Tab;
-import org.mozilla.gecko.Tabs;
-import org.mozilla.gecko.db.BrowserContract.Combined;
-import org.mozilla.gecko.db.BrowserDB.URLColumns;
-import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
-import org.mozilla.gecko.util.ThreadUtils;
-import org.mozilla.gecko.widget.FaviconView;
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+
+import com.squareup.picasso.Picasso;
 
 import android.content.Context;
 import android.database.Cursor;
-import android.graphics.Bitmap;
-import android.text.TextUtils;
 import android.util.AttributeSet;
 import android.view.View;
-
-import java.lang.ref.WeakReference;
+import android.widget.ImageView;
 
 public class PanelListRow extends TwoLineRow {
 
+    private final ImageView mIcon;
+
     public PanelListRow(Context context) {
         this(context, null);
     }
 
     public PanelListRow(Context context, AttributeSet attrs) {
         super(context, attrs);
 
-        // XXX: Never show icon for now. We have to figure out
-        // how the images will be passed through the cursor.
-        final View iconView = findViewById(R.id.icon);
-        iconView.setVisibility(View.GONE);
+        mIcon = (ImageView) findViewById(R.id.icon);
     }
 
     @Override
     public void updateFromCursor(Cursor cursor) {
         if (cursor == null) {
             return;
         }
 
         // XXX: This will have to be updated once we come up with the
         // final schema for Panel datasets (see bug 942288).
 
-        int titleIndex = cursor.getColumnIndexOrThrow(URLColumns.TITLE);
+        int titleIndex = cursor.getColumnIndexOrThrow(HomeItems.TITLE);
         final String title = cursor.getString(titleIndex);
         setPrimaryText(title);
 
-        int urlIndex = cursor.getColumnIndexOrThrow(URLColumns.URL);
+        int urlIndex = cursor.getColumnIndexOrThrow(HomeItems.URL);
         final String url = cursor.getString(urlIndex);
         setSecondaryText(url);
+
+        int imageIndex = cursor.getColumnIndexOrThrow(HomeItems.IMAGE_URL);
+        final String imageUrl = cursor.getString(imageIndex);
+
+        Picasso.with(getContext())
+               .load(imageUrl)
+               .error(R.drawable.favicon)
+               .into(mIcon);
     }
 }
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -403,21 +403,58 @@ if CONFIG['MOZ_CRASHREPORTER']:
     gbjar.sources += [ 'CrashReporter.java' ]
     ANDROID_RES_DIRS += [ SRCDIR + '/crashreporter/res' ]
 
 gbjar.sources += sync_java_files
 gbjar.generated_sources += sync_generated_java_files
 gbjar.extra_jars = [
     'gecko-mozglue.jar',
     'gecko-util.jar',
+    'squareup-picasso.jar',
     'sync-thirdparty.jar',
     'websockets.jar',
 ]
 gbjar.javac_flags += ['-Xlint:all,-deprecation,-fallthrough']
 
+spjar = add_java_jar('squareup-picasso')
+spjar.sources += [ thirdparty_source_dir + f for f in [
+    'com/squareup/picasso/Action.java',
+    'com/squareup/picasso/AssetBitmapHunter.java',
+    'com/squareup/picasso/BitmapHunter.java',
+    'com/squareup/picasso/Cache.java',
+    'com/squareup/picasso/Callback.java',
+    'com/squareup/picasso/ContactsPhotoBitmapHunter.java',
+    'com/squareup/picasso/ContentStreamBitmapHunter.java',
+    'com/squareup/picasso/DeferredRequestCreator.java',
+    'com/squareup/picasso/Dispatcher.java',
+    'com/squareup/picasso/Downloader.java',
+    'com/squareup/picasso/FetchAction.java',
+    'com/squareup/picasso/FileBitmapHunter.java',
+    'com/squareup/picasso/GetAction.java',
+    'com/squareup/picasso/ImageViewAction.java',
+    'com/squareup/picasso/LruCache.java',
+    'com/squareup/picasso/MarkableInputStream.java',
+    'com/squareup/picasso/MediaStoreBitmapHunter.java',
+    'com/squareup/picasso/NetworkBitmapHunter.java',
+    'com/squareup/picasso/Picasso.java',
+    'com/squareup/picasso/PicassoDrawable.java',
+    'com/squareup/picasso/PicassoExecutorService.java',
+    'com/squareup/picasso/Request.java',
+    'com/squareup/picasso/RequestCreator.java',
+    'com/squareup/picasso/ResourceBitmapHunter.java',
+    'com/squareup/picasso/Stats.java',
+    'com/squareup/picasso/StatsSnapshot.java',
+    'com/squareup/picasso/Target.java',
+    'com/squareup/picasso/TargetAction.java',
+    'com/squareup/picasso/Transformation.java',
+    'com/squareup/picasso/UrlConnectionDownloader.java',
+    'com/squareup/picasso/Utils.java',
+] ]
+#spjar.javac_flags += ['-Xlint:all']
+
 ANDROID_RES_DIRS += [
     SRCDIR + '/resources',
     TOPSRCDIR + '/' + CONFIG['MOZ_BRANDING_DIRECTORY'] + '/res',
     OBJDIR + '/res',
 ]
 
 ANDROID_GENERATED_RESFILES += [
     'res/values/strings.xml',
--- a/mobile/android/base/resources/raw/fake_home_items.json
+++ b/mobile/android/base/resources/raw/fake_home_items.json
@@ -1,13 +1,15 @@
 [{
     "id": 1,
     "dataset_id": "fake-dataset",
     "url": "http://example.com/first",
     "title": "First Example",
-    "description": "This is an example"
+    "description": "This is an example",
+    "image_url": "http://lorempixel.com/64/64?id=1"
 }, {
     "id": 2,
     "dataset_id": "fake-dataset",
     "url": "http://example.com/second",
     "title": "Second Example",
-    "description": "This is a second example"
+    "description": "This is a second example",
+    "image_url": "http://lorempixel.com/64/64?id=2"
 }]
--- a/mobile/android/base/tests/BaseTest.java
+++ b/mobile/android/base/tests/BaseTest.java
@@ -85,26 +85,16 @@ abstract class BaseTest extends Activity
                 geckoReadyExpector.blockForEvent();
             }
             geckoReadyExpector.unregisterListener();
         } catch (Exception e) {
             mAsserter.dumpLog("Exception in blockForGeckoReady", e);
         }
     }
 
-    protected void blockForGeckoDelayedStartup() {
-        try {
-            Actions.EventExpecter geckoReadyExpector = mActions.expectGeckoEvent("Gecko:DelayedStartup");
-            geckoReadyExpector.blockForEvent();
-            geckoReadyExpector.unregisterListener();
-        } catch (Exception e) {
-            mAsserter.dumpLog("Exception in blockForGeckoDelayedStartup", e);
-        }
-    }
-
     static {
         try {
             mLauncherActivityClass = (Class<Activity>)Class.forName(LAUNCH_ACTIVITY_FULL_CLASSNAME);
         } catch (ClassNotFoundException e) {
             throw new RuntimeException(e);
         }
     }
 
--- a/mobile/android/base/tests/helpers/GeckoHelper.java
+++ b/mobile/android/base/tests/helpers/GeckoHelper.java
@@ -22,30 +22,18 @@ public final class GeckoHelper {
     private GeckoHelper() { /* To disallow instantiation. */ }
 
     protected static void init(final UITestContext context) {
         sActivity = context.getActivity();
         sActions = context.getActions();
     }
 
     public static void blockForReady() {
-        blockForEvent("Gecko:Ready");
-    }
+        final EventExpecter geckoReady = sActions.expectGeckoEvent("Gecko:Ready");
 
-    /**
-     * Blocks for the "Gecko:DelayedStartup" event, which occurs after "Gecko:Ready" and the
-     * first page load.
-     */
-    public static void blockForDelayedStartup() {
-        blockForEvent("Gecko:DelayedStartup");
-    }
-
-    private static void blockForEvent(final String eventName) {
-        final EventExpecter eventExpecter = sActions.expectGeckoEvent(eventName);
-
-        final boolean isRunning = GeckoThread.checkLaunchState(LaunchState.GeckoRunning);
-        if (!isRunning) {
-            eventExpecter.blockForEvent();
+        final boolean isReady = GeckoThread.checkLaunchState(LaunchState.GeckoRunning);
+        if (!isReady) {
+            geckoReady.blockForEvent();
         }
 
-        eventExpecter.unregisterListener();
+        geckoReady.unregisterListener();
     }
 }
--- a/mobile/android/base/tests/testAboutHomePageNavigation.java
+++ b/mobile/android/base/tests/testAboutHomePageNavigation.java
@@ -8,17 +8,17 @@ import org.mozilla.gecko.tests.helpers.*
 /**
  * Tests functionality related to navigating between the various about:home panels.
  */
 public class testAboutHomePageNavigation extends UITest {
     // TODO: Define this test dynamically by creating dynamic representations of the Page
     // enum for both phone and tablet, then swiping through the panels. This will also
     // benefit having a HomePager with custom panels.
     public void testAboutHomePageNavigation() {
-        GeckoHelper.blockForDelayedStartup();
+        GeckoHelper.blockForReady();
 
         mAboutHome.assertVisible()
                   .assertCurrentPanel(PanelType.TOP_SITES);
 
         mAboutHome.swipeToPanelOnRight();
         mAboutHome.assertCurrentPanel(PanelType.BOOKMARKS);
 
         mAboutHome.swipeToPanelOnRight();
--- a/mobile/android/base/tests/testFormHistory.java
+++ b/mobile/android/base/tests/testFormHistory.java
@@ -24,17 +24,17 @@ public class testFormHistory extends Bas
     }
 
     public void testFormHistory() {
         Context context = (Context)getActivity();
         ContentResolver cr = context.getContentResolver();
         ContentValues[] cvs = new ContentValues[1];
         cvs[0] = new ContentValues();
  
-        blockForGeckoDelayedStartup();
+        blockForGeckoReady();
 
         Uri formHistoryUri;
         Uri insertUri;
         Uri expectedUri;
         int numUpdated;
         int numDeleted;
 
         cvs[0].put("fieldname", "fieldname");
--- a/mobile/android/base/tests/testHomeProvider.js
+++ b/mobile/android/base/tests/testHomeProvider.js
@@ -2,24 +2,53 @@
 /* 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/. */
 
 const { utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/HomeProvider.jsm");
 Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Sqlite.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 
 const TEST_DATASET_ID = "test-dataset-id";
 const TEST_URL = "http://test.com";
 
+const PREF_SYNC_CHECK_INTERVAL_SECS = "home.sync.checkIntervalSecs";
+const TEST_INTERVAL_SECS = 1;
+
 const DB_PATH = OS.Path.join(OS.Constants.Path.profileDir, "home.sqlite");
 
+add_test(function test_request_sync() {
+  // The current implementation of requestSync is synchronous.
+  let success = HomeProvider.requestSync(TEST_DATASET_ID, function callback(datasetId) {
+    do_check_eq(datasetId, TEST_DATASET_ID);
+  });
+
+  do_check_true(success);
+  run_next_test();
+});
+
+add_test(function test_periodic_sync() {
+  do_register_cleanup(function cleanup() {
+    Services.prefs.clearUserPref(PREF_SYNC_CHECK_INTERVAL_SECS);
+    HomeProvider.removePeriodicSync(TEST_DATASET_ID);
+  });
+
+  // Lower the check interval for testing purposes.
+  Services.prefs.setIntPref(PREF_SYNC_CHECK_INTERVAL_SECS, TEST_INTERVAL_SECS);
+
+  HomeProvider.addPeriodicSync(TEST_DATASET_ID, TEST_INTERVAL_SECS, function callback(datasetId) {
+    do_check_eq(datasetId, TEST_DATASET_ID);
+    run_next_test();
+  });
+});
+
 add_task(function test_save_and_delete() {
   // Use the HomeProvider API to save some data.
   let storage = HomeProvider.getStorage(TEST_DATASET_ID);
   yield storage.save([{ url: TEST_URL }]);
 
   // Peek in the DB to make sure we have the right data.
   let db = yield Sqlite.openConnection({ path: DB_PATH });
 
--- a/mobile/android/base/tests/testPasswordProvider.java
+++ b/mobile/android/base/tests/testPasswordProvider.java
@@ -25,17 +25,17 @@ public class testPasswordProvider extend
     }
 
     public void testPasswordProvider() {
         Context context = (Context)getActivity();
         ContentResolver cr = context.getContentResolver();
         ContentValues[] cvs = new ContentValues[1];
         cvs[0] = new ContentValues();
   
-        blockForGeckoDelayedStartup();
+        blockForGeckoReady();
   
         cvs[0].put("hostname", "http://www.example.com");
         cvs[0].put("httpRealm", "http://www.example.com");
         cvs[0].put("formSubmitURL", "http://www.example.com");
         cvs[0].put("usernameField", "usernameField");
         cvs[0].put("passwordField", "passwordField");
         cvs[0].put("encryptedUsername", "username");
         cvs[0].put("encryptedPassword", "password");
--- a/mobile/android/base/webapp/UninstallListener.java
+++ b/mobile/android/base/webapp/UninstallListener.java
@@ -1,37 +1,33 @@
 /* -*- 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.webapp;
 
-import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.GeckoApp;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
-import org.mozilla.gecko.util.ThreadUtils;
 
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
 import android.text.TextUtils;
 import android.util.Log;
 
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.json.JSONArray;
 
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
 
 import java.util.HashSet;
 import java.util.List;
-import java.util.Locale;
 import java.util.Set;
 import java.util.ArrayList;
 
 public class UninstallListener extends BroadcastReceiver {
 
     private static String LOGTAG = "GeckoWebAppUninstallListener";
 
     @Override
@@ -90,25 +86,9 @@ public class UninstallListener extends B
                 }
                 message.put("apkPackageNames", packageNames);
                 GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Webapps:AutoUninstall", message.toString()));
             } catch (JSONException e) {
                 Log.e(LOGTAG, "JSON EXCEPTION " + e);
             }
         }
     }
-
-    public static class DelayedStartupTask implements Runnable {
-        private GeckoApp mApp;
-
-        public DelayedStartupTask(GeckoApp app) {
-            mApp = app;
-        }
-
-        @Override
-        public void run() {
-            // Perform webapp uninstalls as appropiate.
-            if (AppConstants.MOZ_ANDROID_SYNTHAPKS) {
-                UninstallListener.initUninstallPackageScan(mApp.getApplicationContext());
-            }
-        }
-    }
 }
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -282,24 +282,16 @@ var BrowserApp = {
 
   deck: null,
 
   startup: function startup() {
     window.QueryInterface(Ci.nsIDOMChromeWindow).browserDOMWindow = new nsBrowserAccess();
     dump("zerdatime " + Date.now() + " - browser chrome startup finished.");
 
     this.deck = document.getElementById("browsers");
-    this.deck.addEventListener("DOMContentLoaded", function BrowserApp_delayedStartup() {
-      try {
-        BrowserApp.deck.removeEventListener("DOMContentLoaded", BrowserApp_delayedStartup, false);
-        Services.obs.notifyObservers(window, "browser-delayed-startup-finished", "");
-        sendMessageToJava({ type: "Gecko:DelayedStartup" });
-      } catch(ex) { console.log(ex); }
-    }, false);
-
     BrowserEventHandler.init();
     ViewportHandler.init();
 
     Services.androidBridge.browserApp = this;
 
     Services.obs.addObserver(this, "Locale:Changed", false);
     Services.obs.addObserver(this, "Tab:Load", false);
     Services.obs.addObserver(this, "Tab:Selected", false);
@@ -418,18 +410,16 @@ var BrowserApp = {
     // XXX maybe we don't do this if the launch was kicked off from external
     Services.io.offline = false;
 
     // Broadcast a UIReady message so add-ons know we are finished with startup
     let event = document.createEvent("Events");
     event.initEvent("UIReady", true, false);
     window.dispatchEvent(event);
 
-    Services.obs.addObserver(this, "browser-delayed-startup-finished", false);
-
     if (this._startupStatus)
       this.onAppUpdated();
 
     // Store the low-precision buffer pref
     this.gUseLowPrecision = Services.prefs.getBoolPref("layers.low-precision-buffer");
 
     // notify java that gecko has loaded
     sendMessageToJava({ type: "Gecko:Ready" });
@@ -691,16 +681,20 @@ var BrowserApp = {
         // Skipped trying to pull MIME type out of cache for now
         ContentAreaUtils.internalSave(url, null, null, null, null, false,
                                       filePickerTitleKey, null, aTarget.ownerDocument.documentURIObject,
                                       aTarget.ownerDocument, true, null);
       });
   },
 
   onAppUpdated: function() {
+    // initialize the form history and passwords databases on upgrades
+    Services.obs.notifyObservers(null, "FormHistory:Init", "");
+    Services.obs.notifyObservers(null, "Passwords:Init", "");
+
     // Migrate user-set "plugins.click_to_play" pref. See bug 884694.
     // Because the default value is true, a user-set pref means that the pref was set to false.
     if (Services.prefs.prefHasUserValue("plugins.click_to_play")) {
       Services.prefs.setIntPref("plugin.default.state", Ci.nsIPluginTag.STATE_ENABLED);
       Services.prefs.clearUserPref("plugins.click_to_play");
     }
   },
 
@@ -1607,35 +1601,23 @@ var BrowserApp = {
         console.log("Locale:Changed: " + aData);
 
         // TODO: do we need to be more nuanced here -- e.g., checking for the
         // OS locale -- or should it always be false on Fennec?
         Services.prefs.setBoolPref("intl.locale.matchOS", false);
         Services.prefs.setCharPref("general.useragent.locale", aData);
         break;
 
-      case "browser-delayed-startup-finished":
-        this._delayedStartup();
-        break;
-
       default:
         dump('BrowserApp.observe: unexpected topic "' + aTopic + '"\n');
         break;
 
     }
   },
 
-  _delayedStartup: function() {
-    // initialize the form history and passwords databases on upgrades
-    if (this._startupStatus) {
-      Services.obs.notifyObservers(null, "FormHistory:Init", "");
-      Services.obs.notifyObservers(null, "Passwords:Init", "");
-    }
-  },
-
   get defaultBrowserWidth() {
     delete this.defaultBrowserWidth;
     let width = Services.prefs.getIntPref("browser.viewport.desktopWidth");
     return this.defaultBrowserWidth = width;
   },
 
   // nsIAndroidBrowserApp
   getBrowserTab: function(tabId) {
--- a/mobile/android/components/Snippets.js
+++ b/mobile/android/components/Snippets.js
@@ -317,20 +317,16 @@ function Snippets() {}
 
 Snippets.prototype = {
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsITimerCallback]),
   classID: Components.ID("{a78d7e59-b558-4321-a3d6-dffe2f1e76dd}"),
 
   observe: function(subject, topic, data) {
     switch(topic) {
       case "profile-after-change":
-        Services.obs.addObserver(this, "browser-delayed-startup-finished", false);
-        break;
-      case "browser-delayed-startup-finished":
-        Services.obs.removeObserver(this, "browser-delayed-startup-finished", false);
         if (Services.prefs.getBoolPref("browser.snippets.syncPromo.enabled")) {
           loadSyncPromoBanner();
         }
 
         if (Services.prefs.getBoolPref("browser.snippets.enabled")) {
           loadSnippetsFromCache();
         }
         break;
--- a/mobile/android/modules/HomeProvider.jsm
+++ b/mobile/android/modules/HomeProvider.jsm
@@ -2,26 +2,47 @@
 /* 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";
 
 this.EXPORTED_SYMBOLS = [ "HomeProvider" ];
 
-const { utils: Cu } = Components;
+const { utils: Cu, classes: Cc, interfaces: Ci } = Components;
 
 Cu.import("resource://gre/modules/osfile.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Sqlite.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
+/*
+ * XXX: Add migration logic to getDatabaseConnection if you ever rev SCHEMA_VERSION.
+ *
+ * SCHEMA_VERSION history:
+ *   1: Create HomeProvider (bug 942288)
+ */
 const SCHEMA_VERSION = 1;
 
-const DB_PATH = OS.Path.join(OS.Constants.Path.profileDir, "home.sqlite");
+XPCOMUtils.defineLazyGetter(this, "DB_PATH", function() {
+  return OS.Path.join(OS.Constants.Path.profileDir, "home.sqlite");
+});
+
+const PREF_STORAGE_LAST_SYNC_TIME_PREFIX = "home.storage.lastSyncTime.";
+const PREF_SYNC_WIFI_ONLY = "home.sync.wifiOnly";
+const PREF_SYNC_CHECK_INTERVAL_SECS = "home.sync.checkIntervalSecs";
+
+XPCOMUtils.defineLazyGetter(this, "gSyncCheckIntervalSecs", function() {
+  return Services.prefs.getIntPref(PREF_SYNC_CHECK_INTERVAL_SECS);
+});
+
+XPCOMUtils.defineLazyServiceGetter(this,
+  "gUpdateTimerManager", "@mozilla.org/updates/timer-manager;1", "nsIUpdateTimerManager");
 
 /**
  * All SQL statements should be defined here.
  */
 const SQL = {
   createItemsTable:
     "CREATE TABLE items (" +
       "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
@@ -36,55 +57,187 @@ const SQL = {
   insertItem:
     "INSERT INTO items (dataset_id, url, title, description, image_url, created) " +
       "VALUES (:dataset_id, :url, :title, :description, :image_url, :created)",
 
   deleteFromDataset:
     "DELETE FROM items WHERE dataset_id = :dataset_id"
 }
 
+/**
+ * Technically this function checks to see if the user is on a local network,
+ * but we express this as "wifi" to the user.
+ */
+function isUsingWifi() {
+  let network = Cc["@mozilla.org/network/network-link-service;1"].getService(Ci.nsINetworkLinkService);
+  return (network.linkType === Ci.nsINetworkLinkService.LINK_TYPE_WIFI || network.linkType === Ci.nsINetworkLinkService.LINK_TYPE_ETHERNET);
+}
+
+function getNowInSeconds() {
+  return Math.round(Date.now() / 1000);
+}
+
+function getLastSyncPrefName(datasetId) {
+  return PREF_STORAGE_LAST_SYNC_TIME_PREFIX + datasetId;
+}
+
+// Whether or not we've registered an update timer.
+var gTimerRegistered = false;
+
+// Map of datasetId -> { interval: <integer>, callback: <function> }
+var gSyncCallbacks = {};
+
+/**
+ * nsITimerCallback implementation. Checks to see if it's time to sync any registered datasets.
+ *
+ * @param timer The timer which has expired.
+ */
+function syncTimerCallback(timer) {
+  for (let datasetId in gSyncCallbacks) {
+    let lastSyncTime = 0;
+    try {
+      lastSyncTime = Services.prefs.getIntPref(getLastSyncPrefName(datasetId));
+    } catch(e) { }
+
+    let now = getNowInSeconds();
+    let { interval: interval, callback: callback } = gSyncCallbacks[datasetId];
+
+    if (lastSyncTime < now - interval) {
+      let success = HomeProvider.requestSync(datasetId, callback);
+      if (success) {
+        Services.prefs.setIntPref(getLastSyncPrefName(datasetId), now);
+      }
+    }
+  }
+}
+
 this.HomeProvider = Object.freeze({
   /**
    * Returns a storage associated with a given dataset identifer.
    *
    * @param datasetId
    *        (string) Unique identifier for the dataset.
    *
    * @return HomeStorage
    */
   getStorage: function(datasetId) {
     return new HomeStorage(datasetId);
+  },
+
+  /**
+   * Checks to see if it's an appropriate time to sync.
+   *
+   * @param datasetId Unique identifier for the dataset to sync.
+   * @param callback Function to call when it's time to sync, called with datasetId as a parameter.
+   *
+   * @return boolean Whether or not we were able to sync.
+   */
+  requestSync: function(datasetId, callback) {
+    // Make sure it's a good time to sync.
+    if (Services.prefs.getBoolPref(PREF_SYNC_WIFI_ONLY) && !isUsingWifi()) {
+      Cu.reportError("HomeProvider: Failed to sync because device is not on a local network");
+      return false;
+    }
+
+    callback(datasetId);
+    return true;
+  },
+
+  /**
+   * Specifies that a sync should be requested for the given dataset and update interval.
+   *
+   * @param datasetId Unique identifier for the dataset to sync.
+   * @param interval Update interval in seconds. By default, this is throttled to 3600 seconds (1 hour).
+   * @param callback Function to call when it's time to sync, called with datasetId as a parameter.
+   */
+  addPeriodicSync: function(datasetId, interval, callback) {
+    // Warn developers if they're expecting more frequent notifications that we allow.
+    if (interval < gSyncCheckIntervalSecs) {
+      Cu.reportError("HomeProvider: Warning for dataset " + datasetId +
+        " : Sync notifications are throttled to " + gSyncCheckIntervalSecs + " seconds");
+    }
+
+    gSyncCallbacks[datasetId] = {
+      interval: interval,
+      callback: callback
+    };
+
+    if (!gTimerRegistered) {
+      gUpdateTimerManager.registerTimer("home-provider-sync-timer", syncTimerCallback, gSyncCheckIntervalSecs);
+      gTimerRegistered = true;
+    }
+  },
+
+  /**
+   * Removes a periodic sync timer.
+   *
+   * @param datasetId Dataset to sync.
+   */
+  removePeriodicSync: function(datasetId) {
+    delete gSyncCallbacks[datasetId];
+    Services.prefs.clearUserPref(getLastSyncPrefName(datasetId));
+    // You can't unregister a update timer, so we don't try to do that.
   }
 });
 
+var gDatabaseEnsured = false;
+
+/**
+ * Opens a database connection and makes sure that the database schema version
+ * is correct, performing migrations if necessary. Consumers should be sure
+ * to close any database connections they open.
+ *
+ * @return Promise
+ * @resolves Handle on an opened SQLite database.
+ */
+function getDatabaseConnection() {
+  return Task.spawn(function get_database_connection_task() {
+    let db = yield Sqlite.openConnection({ path: DB_PATH });
+    if (gDatabaseEnsured) {
+      throw new Task.Result(db);
+    }
+
+    try {
+      // Check to see if we need to perform any migrations.
+      // XXX: We will need to add migration logic if we ever rev SCHEMA_VERSION.
+      let dbVersion = yield db.getSchemaVersion();
+      if (parseInt(dbVersion) < SCHEMA_VERSION) {
+        // For schema v1, create the items table and set the schema version.
+        yield db.execute(SQL.createItemsTable);
+        yield db.setSchemaVersion(SCHEMA_VERSION);
+      }
+    } catch(e) {
+      // Close the DB connection before passing the exception to the consumer.
+      yield db.close();
+      throw e;
+    }
+
+    gDatabaseEnsured = true;
+    throw new Task.Result(db);
+  });
+}
+
 this.HomeStorage = function(datasetId) {
   this.datasetId = datasetId;
 };
 
 HomeStorage.prototype = {
   /**
    * Saves data rows to the DB.
    *
    * @param data
    *        (array) JSON array of row items
    *
    * @return Promise
    * @resolves When the operation has completed.
    */
   save: function(data) {
     return Task.spawn(function save_task() {
-      let db = yield Sqlite.openConnection({ path: DB_PATH });
-
+      let db = yield getDatabaseConnection();
       try {
-        // XXX: Factor this out to some migration path.
-        if (!(yield db.tableExists("items"))) {
-          yield db.execute(SQL.createItemsTable);
-          yield db.setSchemaVersion(SCHEMA_VERSION);
-        }
-
         // Insert data into DB.
         for (let item of data) {
           // XXX: Directly pass item as params? More validation for item? Batch insert?
           let params = {
             dataset_id: this.datasetId,
             url: item.url,
             title: item.title,
             description: item.description,
@@ -102,20 +255,18 @@ HomeStorage.prototype = {
   /**
    * Deletes all rows associated with this storage.
    *
    * @return Promise
    * @resolves When the operation has completed.
    */
   deleteAll: function() {
     return Task.spawn(function delete_all_task() {
-      let db = yield Sqlite.openConnection({ path: DB_PATH });
-
+      let db = yield getDatabaseConnection();
       try {
-        // XXX: Check to make sure table exists.
         let params = { dataset_id: this.datasetId };
         yield db.executeCached(SQL.deleteFromDataset, params);
       } finally {
         yield db.close();
       }
     }.bind(this));
   }
 };
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Action.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import java.lang.ref.ReferenceQueue;
+import java.lang.ref.WeakReference;
+
+abstract class Action<T> {
+  static class RequestWeakReference<T> extends WeakReference<T> {
+    final Action action;
+
+    public RequestWeakReference(Action action, T referent, ReferenceQueue<? super T> q) {
+      super(referent, q);
+      this.action = action;
+    }
+  }
+
+  final Picasso picasso;
+  final Request data;
+  final WeakReference<T> target;
+  final boolean skipCache;
+  final boolean noFade;
+  final int errorResId;
+  final Drawable errorDrawable;
+  final String key;
+
+  boolean cancelled;
+
+  Action(Picasso picasso, T target, Request data, boolean skipCache, boolean noFade,
+      int errorResId, Drawable errorDrawable, String key) {
+    this.picasso = picasso;
+    this.data = data;
+    this.target = new RequestWeakReference<T>(this, target, picasso.referenceQueue);
+    this.skipCache = skipCache;
+    this.noFade = noFade;
+    this.errorResId = errorResId;
+    this.errorDrawable = errorDrawable;
+    this.key = key;
+  }
+
+  abstract void complete(Bitmap result, Picasso.LoadedFrom from);
+
+  abstract void error();
+
+  void cancel() {
+    cancelled = true;
+  }
+
+  Request getData() {
+    return data;
+  }
+
+  T getTarget() {
+    return target.get();
+  }
+
+  String getKey() {
+    return key;
+  }
+
+  boolean isCancelled() {
+    return cancelled;
+  }
+
+  Picasso getPicasso() {
+    return picasso;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/AssetBitmapHunter.java
@@ -0,0 +1,51 @@
+package com.squareup.picasso;
+
+import android.content.Context;
+import android.content.res.AssetManager;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import java.io.IOException;
+import java.io.InputStream;
+
+import static com.squareup.picasso.Picasso.LoadedFrom.DISK;
+
+class AssetBitmapHunter extends BitmapHunter {
+  private AssetManager assetManager;
+
+  public AssetBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache,
+      Stats stats, Action action) {
+    super(picasso, dispatcher, cache, stats, action);
+    assetManager = context.getAssets();
+  }
+
+  @Override Bitmap decode(Request data) throws IOException {
+    String filePath = data.uri.toString().substring(ASSET_PREFIX_LENGTH);
+    return decodeAsset(filePath);
+  }
+
+  @Override Picasso.LoadedFrom getLoadedFrom() {
+    return DISK;
+  }
+
+  Bitmap decodeAsset(String filePath) throws IOException {
+    BitmapFactory.Options options = null;
+    if (data.hasSize()) {
+      options = new BitmapFactory.Options();
+      options.inJustDecodeBounds = true;
+      InputStream is = null;
+      try {
+        is = assetManager.open(filePath);
+        BitmapFactory.decodeStream(is, null, options);
+      } finally {
+        Utils.closeQuietly(is);
+      }
+      calculateInSampleSize(data.targetWidth, data.targetHeight, options);
+    }
+    InputStream is = assetManager.open(filePath);
+    try {
+      return BitmapFactory.decodeStream(is, null, options);
+    } finally {
+      Utils.closeQuietly(is);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/BitmapHunter.java
@@ -0,0 +1,357 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Matrix;
+import android.net.NetworkInfo;
+import android.net.Uri;
+import android.provider.MediaStore;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+
+import static android.content.ContentResolver.SCHEME_ANDROID_RESOURCE;
+import static android.content.ContentResolver.SCHEME_CONTENT;
+import static android.content.ContentResolver.SCHEME_FILE;
+import static android.provider.ContactsContract.Contacts;
+import static com.squareup.picasso.Picasso.LoadedFrom.MEMORY;
+
+abstract class BitmapHunter implements Runnable {
+
+  /**
+   * Global lock for bitmap decoding to ensure that we are only are decoding one at a time. Since
+   * this will only ever happen in background threads we help avoid excessive memory thrashing as
+   * well as potential OOMs. Shamelessly stolen from Volley.
+   */
+  private static final Object DECODE_LOCK = new Object();
+  private static final String ANDROID_ASSET = "android_asset";
+  protected static final int ASSET_PREFIX_LENGTH =
+      (SCHEME_FILE + ":///" + ANDROID_ASSET + "/").length();
+
+  final Picasso picasso;
+  final Dispatcher dispatcher;
+  final Cache cache;
+  final Stats stats;
+  final String key;
+  final Request data;
+  final List<Action> actions;
+  final boolean skipMemoryCache;
+
+  Bitmap result;
+  Future<?> future;
+  Picasso.LoadedFrom loadedFrom;
+  Exception exception;
+  int exifRotation; // Determined during decoding of original resource.
+
+  BitmapHunter(Picasso picasso, Dispatcher dispatcher, Cache cache, Stats stats, Action action) {
+    this.picasso = picasso;
+    this.dispatcher = dispatcher;
+    this.cache = cache;
+    this.stats = stats;
+    this.key = action.getKey();
+    this.data = action.getData();
+    this.skipMemoryCache = action.skipCache;
+    this.actions = new ArrayList<Action>(4);
+    attach(action);
+  }
+
+  protected void setExifRotation(int exifRotation) {
+    this.exifRotation = exifRotation;
+  }
+
+  @Override public void run() {
+    try {
+      Thread.currentThread().setName(Utils.THREAD_PREFIX + data.getName());
+
+      result = hunt();
+
+      if (result == null) {
+        dispatcher.dispatchFailed(this);
+      } else {
+        dispatcher.dispatchComplete(this);
+      }
+    } catch (Downloader.ResponseException e) {
+      exception = e;
+      dispatcher.dispatchFailed(this);
+    } catch (IOException e) {
+      exception = e;
+      dispatcher.dispatchRetry(this);
+    } catch (OutOfMemoryError e) {
+      StringWriter writer = new StringWriter();
+      stats.createSnapshot().dump(new PrintWriter(writer));
+      exception = new RuntimeException(writer.toString(), e);
+      dispatcher.dispatchFailed(this);
+    } catch (Exception e) {
+      exception = e;
+      dispatcher.dispatchFailed(this);
+    } finally {
+      Thread.currentThread().setName(Utils.THREAD_IDLE_NAME);
+    }
+  }
+
+  abstract Bitmap decode(Request data) throws IOException;
+
+  Bitmap hunt() throws IOException {
+    Bitmap bitmap;
+
+    if (!skipMemoryCache) {
+      bitmap = cache.get(key);
+      if (bitmap != null) {
+        stats.dispatchCacheHit();
+        loadedFrom = MEMORY;
+        return bitmap;
+      }
+    }
+
+    bitmap = decode(data);
+
+    if (bitmap != null) {
+      stats.dispatchBitmapDecoded(bitmap);
+      if (data.needsTransformation() || exifRotation != 0) {
+        synchronized (DECODE_LOCK) {
+          if (data.needsMatrixTransform() || exifRotation != 0) {
+            bitmap = transformResult(data, bitmap, exifRotation);
+          }
+          if (data.hasCustomTransformations()) {
+            bitmap = applyCustomTransformations(data.transformations, bitmap);
+          }
+        }
+        stats.dispatchBitmapTransformed(bitmap);
+      }
+    }
+
+    return bitmap;
+  }
+
+  void attach(Action action) {
+    actions.add(action);
+  }
+
+  void detach(Action action) {
+    actions.remove(action);
+  }
+
+  boolean cancel() {
+    return actions.isEmpty() && future != null && future.cancel(false);
+  }
+
+  boolean isCancelled() {
+    return future != null && future.isCancelled();
+  }
+
+  boolean shouldSkipMemoryCache() {
+    return skipMemoryCache;
+  }
+
+  boolean shouldRetry(boolean airplaneMode, NetworkInfo info) {
+    return false;
+  }
+
+  Bitmap getResult() {
+    return result;
+  }
+
+  String getKey() {
+    return key;
+  }
+
+  Request getData() {
+    return data;
+  }
+
+  List<Action> getActions() {
+    return actions;
+  }
+
+  Exception getException() {
+    return exception;
+  }
+
+  Picasso.LoadedFrom getLoadedFrom() {
+    return loadedFrom;
+  }
+
+  static BitmapHunter forRequest(Context context, Picasso picasso, Dispatcher dispatcher,
+      Cache cache, Stats stats, Action action, Downloader downloader) {
+    if (action.getData().resourceId != 0) {
+      return new ResourceBitmapHunter(context, picasso, dispatcher, cache, stats, action);
+    }
+    Uri uri = action.getData().uri;
+    String scheme = uri.getScheme();
+    if (SCHEME_CONTENT.equals(scheme)) {
+      if (Contacts.CONTENT_URI.getHost().equals(uri.getHost()) //
+          && !uri.getPathSegments().contains(Contacts.Photo.CONTENT_DIRECTORY)) {
+        return new ContactsPhotoBitmapHunter(context, picasso, dispatcher, cache, stats, action);
+      } else if (MediaStore.AUTHORITY.equals(uri.getAuthority())) {
+        return new MediaStoreBitmapHunter(context, picasso, dispatcher, cache, stats, action);
+      } else {
+        return new ContentStreamBitmapHunter(context, picasso, dispatcher, cache, stats, action);
+      }
+    } else if (SCHEME_FILE.equals(scheme)) {
+      if (!uri.getPathSegments().isEmpty() && ANDROID_ASSET.equals(uri.getPathSegments().get(0))) {
+        return new AssetBitmapHunter(context, picasso, dispatcher, cache, stats, action);
+      }
+      return new FileBitmapHunter(context, picasso, dispatcher, cache, stats, action);
+    } else if (SCHEME_ANDROID_RESOURCE.equals(scheme)) {
+      return new ResourceBitmapHunter(context, picasso, dispatcher, cache, stats, action);
+    } else {
+      return new NetworkBitmapHunter(picasso, dispatcher, cache, stats, action, downloader);
+    }
+  }
+
+  static void calculateInSampleSize(int reqWidth, int reqHeight, BitmapFactory.Options options) {
+    calculateInSampleSize(reqWidth, reqHeight, options.outWidth, options.outHeight, options);
+  }
+
+  static void calculateInSampleSize(int reqWidth, int reqHeight, int width, int height,
+      BitmapFactory.Options options) {
+    int sampleSize = 1;
+    if (height > reqHeight || width > reqWidth) {
+      final int heightRatio = Math.round((float) height / (float) reqHeight);
+      final int widthRatio = Math.round((float) width / (float) reqWidth);
+      sampleSize = heightRatio < widthRatio ? heightRatio : widthRatio;
+    }
+
+    options.inSampleSize = sampleSize;
+    options.inJustDecodeBounds = false;
+  }
+
+  static Bitmap applyCustomTransformations(List<Transformation> transformations, Bitmap result) {
+    for (int i = 0, count = transformations.size(); i < count; i++) {
+      final Transformation transformation = transformations.get(i);
+      Bitmap newResult = transformation.transform(result);
+
+      if (newResult == null) {
+        final StringBuilder builder = new StringBuilder() //
+            .append("Transformation ")
+            .append(transformation.key())
+            .append(" returned null after ")
+            .append(i)
+            .append(" previous transformation(s).\n\nTransformation list:\n");
+        for (Transformation t : transformations) {
+          builder.append(t.key()).append('\n');
+        }
+        Picasso.HANDLER.post(new Runnable() {
+          @Override public void run() {
+            throw new NullPointerException(builder.toString());
+          }
+        });
+        return null;
+      }
+
+      if (newResult == result && result.isRecycled()) {
+        Picasso.HANDLER.post(new Runnable() {
+          @Override public void run() {
+            throw new IllegalStateException("Transformation "
+                + transformation.key()
+                + " returned input Bitmap but recycled it.");
+          }
+        });
+        return null;
+      }
+
+      // If the transformation returned a new bitmap ensure they recycled the original.
+      if (newResult != result && !result.isRecycled()) {
+        Picasso.HANDLER.post(new Runnable() {
+          @Override public void run() {
+            throw new IllegalStateException("Transformation "
+                + transformation.key()
+                + " mutated input Bitmap but failed to recycle the original.");
+          }
+        });
+        return null;
+      }
+
+      result = newResult;
+    }
+    return result;
+  }
+
+  static Bitmap transformResult(Request data, Bitmap result, int exifRotation) {
+    int inWidth = result.getWidth();
+    int inHeight = result.getHeight();
+
+    int drawX = 0;
+    int drawY = 0;
+    int drawWidth = inWidth;
+    int drawHeight = inHeight;
+
+    Matrix matrix = new Matrix();
+
+    if (data.needsMatrixTransform()) {
+      int targetWidth = data.targetWidth;
+      int targetHeight = data.targetHeight;
+
+      float targetRotation = data.rotationDegrees;
+      if (targetRotation != 0) {
+        if (data.hasRotationPivot) {
+          matrix.setRotate(targetRotation, data.rotationPivotX, data.rotationPivotY);
+        } else {
+          matrix.setRotate(targetRotation);
+        }
+      }
+
+      if (data.centerCrop) {
+        float widthRatio = targetWidth / (float) inWidth;
+        float heightRatio = targetHeight / (float) inHeight;
+        float scale;
+        if (widthRatio > heightRatio) {
+          scale = widthRatio;
+          int newSize = (int) Math.ceil(inHeight * (heightRatio / widthRatio));
+          drawY = (inHeight - newSize) / 2;
+          drawHeight = newSize;
+        } else {
+          scale = heightRatio;
+          int newSize = (int) Math.ceil(inWidth * (widthRatio / heightRatio));
+          drawX = (inWidth - newSize) / 2;
+          drawWidth = newSize;
+        }
+        matrix.preScale(scale, scale);
+      } else if (data.centerInside) {
+        float widthRatio = targetWidth / (float) inWidth;
+        float heightRatio = targetHeight / (float) inHeight;
+        float scale = widthRatio < heightRatio ? widthRatio : heightRatio;
+        matrix.preScale(scale, scale);
+      } else if (targetWidth != 0 && targetHeight != 0 //
+          && (targetWidth != inWidth || targetHeight != inHeight)) {
+        // If an explicit target size has been specified and they do not match the results bounds,
+        // pre-scale the existing matrix appropriately.
+        float sx = targetWidth / (float) inWidth;
+        float sy = targetHeight / (float) inHeight;
+        matrix.preScale(sx, sy);
+      }
+    }
+
+    if (exifRotation != 0) {
+      matrix.preRotate(exifRotation);
+    }
+
+    Bitmap newResult =
+        Bitmap.createBitmap(result, drawX, drawY, drawWidth, drawHeight, matrix, true);
+    if (newResult != result) {
+      result.recycle();
+      result = newResult;
+    }
+
+    return result;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Cache.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.graphics.Bitmap;
+
+/**
+ * A memory cache for storing the most recently used images.
+ * <p/>
+ * <em>Note:</em> The {@link Cache} is accessed by multiple threads. You must ensure
+ * your {@link Cache} implementation is thread safe when {@link Cache#get(String)} or {@link
+ * Cache#set(String, android.graphics.Bitmap)} is called.
+ */
+public interface Cache {
+  /** Retrieve an image for the specified {@code key} or {@code null}. */
+  Bitmap get(String key);
+
+  /** Store an image in the cache for the specified {@code key}. */
+  void set(String key, Bitmap bitmap);
+
+  /** Returns the current size of the cache in bytes. */
+  int size();
+
+  /** Returns the maximum size in bytes that the cache can hold. */
+  int maxSize();
+
+  /** Clears the cache. */
+  void clear();
+
+  /** A cache which does not store any values. */
+  Cache NONE = new Cache() {
+    @Override public Bitmap get(String key) {
+      return null;
+    }
+
+    @Override public void set(String key, Bitmap bitmap) {
+      // Ignore.
+    }
+
+    @Override public int size() {
+      return 0;
+    }
+
+    @Override public int maxSize() {
+      return 0;
+    }
+
+    @Override public void clear() {
+    }
+  };
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Callback.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+public interface Callback {
+  void onSuccess();
+
+  void onError();
+
+  public static class EmptyCallback implements Callback {
+
+    @Override public void onSuccess() {
+    }
+
+    @Override public void onError() {
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/ContactsPhotoBitmapHunter.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.annotation.TargetApi;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.UriMatcher;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.provider.ContactsContract;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import static android.os.Build.VERSION.SDK_INT;
+import static android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH;
+import static android.provider.ContactsContract.Contacts.openContactPhotoInputStream;
+import static com.squareup.picasso.Picasso.LoadedFrom.DISK;
+
+class ContactsPhotoBitmapHunter extends BitmapHunter {
+  /** A lookup uri (e.g. content://com.android.contacts/contacts/lookup/3570i61d948d30808e537) */
+  private static final int ID_LOOKUP = 1;
+  /** A contact thumbnail uri (e.g. content://com.android.contacts/contacts/38/photo) */
+  private static final int ID_THUMBNAIL = 2;
+  /** A contact uri (e.g. content://com.android.contacts/contacts/38) */
+  private static final int ID_CONTACT = 3;
+  /**
+   * A contact display photo (high resolution) uri
+   * (e.g. content://com.android.contacts/display_photo/5)
+   */
+  private static final int ID_DISPLAY_PHOTO = 4;
+
+  private static final UriMatcher matcher;
+
+  static {
+    matcher = new UriMatcher(UriMatcher.NO_MATCH);
+    matcher.addURI(ContactsContract.AUTHORITY, "contacts/lookup/*/#", ID_LOOKUP);
+    matcher.addURI(ContactsContract.AUTHORITY, "contacts/lookup/*", ID_LOOKUP);
+    matcher.addURI(ContactsContract.AUTHORITY, "contacts/#/photo", ID_THUMBNAIL);
+    matcher.addURI(ContactsContract.AUTHORITY, "contacts/#", ID_CONTACT);
+    matcher.addURI(ContactsContract.AUTHORITY, "display_photo/#", ID_DISPLAY_PHOTO);
+  }
+
+  final Context context;
+
+  ContactsPhotoBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache,
+      Stats stats, Action action) {
+    super(picasso, dispatcher, cache, stats, action);
+    this.context = context;
+  }
+
+  @Override Bitmap decode(Request data) throws IOException {
+    InputStream is = null;
+    try {
+      is = getInputStream();
+      return decodeStream(is, data);
+    } finally {
+      Utils.closeQuietly(is);
+    }
+  }
+
+  @Override Picasso.LoadedFrom getLoadedFrom() {
+    return DISK;
+  }
+
+  private InputStream getInputStream() throws IOException {
+    ContentResolver contentResolver = context.getContentResolver();
+    Uri uri = getData().uri;
+    switch (matcher.match(uri)) {
+      case ID_LOOKUP:
+        uri = ContactsContract.Contacts.lookupContact(contentResolver, uri);
+        if (uri == null) {
+          return null;
+        }
+        // Resolved the uri to a contact uri, intentionally fall through to process the resolved uri
+      case ID_CONTACT:
+        if (SDK_INT < ICE_CREAM_SANDWICH) {
+          return openContactPhotoInputStream(contentResolver, uri);
+        } else {
+          return ContactPhotoStreamIcs.get(contentResolver, uri);
+        }
+      case ID_THUMBNAIL:
+      case ID_DISPLAY_PHOTO:
+        return contentResolver.openInputStream(uri);
+      default:
+        throw new IllegalStateException("Invalid uri: " + uri);
+    }
+  }
+
+  private Bitmap decodeStream(InputStream stream, Request data) throws IOException {
+    if (stream == null) {
+      return null;
+    }
+    BitmapFactory.Options options = null;
+    if (data.hasSize()) {
+      options = new BitmapFactory.Options();
+      options.inJustDecodeBounds = true;
+      InputStream is = getInputStream();
+      try {
+        BitmapFactory.decodeStream(is, null, options);
+      } finally {
+        Utils.closeQuietly(is);
+      }
+      calculateInSampleSize(data.targetWidth, data.targetHeight, options);
+    }
+    return BitmapFactory.decodeStream(stream, null, options);
+  }
+
+  @TargetApi(ICE_CREAM_SANDWICH)
+  private static class ContactPhotoStreamIcs {
+    static InputStream get(ContentResolver contentResolver, Uri uri) {
+      return openContactPhotoInputStream(contentResolver, uri, true);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/ContentStreamBitmapHunter.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import java.io.IOException;
+import java.io.InputStream;
+
+import static com.squareup.picasso.Picasso.LoadedFrom.DISK;
+
+class ContentStreamBitmapHunter extends BitmapHunter {
+  final Context context;
+
+  ContentStreamBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache,
+      Stats stats, Action action) {
+    super(picasso, dispatcher, cache, stats, action);
+    this.context = context;
+  }
+
+  @Override Bitmap decode(Request data)
+      throws IOException {
+    return decodeContentStream(data);
+  }
+
+  @Override Picasso.LoadedFrom getLoadedFrom() {
+    return DISK;
+  }
+
+  protected Bitmap decodeContentStream(Request data) throws IOException {
+    ContentResolver contentResolver = context.getContentResolver();
+    BitmapFactory.Options options = null;
+    if (data.hasSize()) {
+      options = new BitmapFactory.Options();
+      options.inJustDecodeBounds = true;
+      InputStream is = null;
+      try {
+        is = contentResolver.openInputStream(data.uri);
+        BitmapFactory.decodeStream(is, null, options);
+      } finally {
+        Utils.closeQuietly(is);
+      }
+      calculateInSampleSize(data.targetWidth, data.targetHeight, options);
+    }
+    InputStream is = contentResolver.openInputStream(data.uri);
+    try {
+      return BitmapFactory.decodeStream(is, null, options);
+    } finally {
+      Utils.closeQuietly(is);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/DeferredRequestCreator.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.view.ViewTreeObserver;
+import android.widget.ImageView;
+import java.lang.ref.WeakReference;
+
+class DeferredRequestCreator implements ViewTreeObserver.OnPreDrawListener {
+
+  final RequestCreator creator;
+  final WeakReference<ImageView> target;
+  Callback callback;
+
+  DeferredRequestCreator(RequestCreator creator, ImageView target, Callback callback) {
+    this.creator = creator;
+    this.target = new WeakReference<ImageView>(target);
+    this.callback = callback;
+    target.getViewTreeObserver().addOnPreDrawListener(this);
+  }
+
+  @Override public boolean onPreDraw() {
+    ImageView target = this.target.get();
+    if (target == null) {
+      return true;
+    }
+    ViewTreeObserver vto = target.getViewTreeObserver();
+    if (!vto.isAlive()) {
+      return true;
+    }
+
+    int width = target.getMeasuredWidth();
+    int height = target.getMeasuredHeight();
+
+    if (width <= 0 || height <= 0) {
+      return true;
+    }
+
+    vto.removeOnPreDrawListener(this);
+
+    this.creator.unfit().resize(width, height).into(target, callback);
+    return true;
+  }
+
+  void cancel() {
+    callback = null;
+    ImageView target = this.target.get();
+    if (target == null) {
+      return;
+    }
+    ViewTreeObserver vto = target.getViewTreeObserver();
+    if (!vto.isAlive()) {
+      return;
+    }
+    vto.removeOnPreDrawListener(this);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Dispatcher.java
@@ -0,0 +1,315 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.Manifest;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+
+import static android.content.Context.CONNECTIVITY_SERVICE;
+import static android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED;
+import static android.net.ConnectivityManager.CONNECTIVITY_ACTION;
+import static android.os.Process.THREAD_PRIORITY_BACKGROUND;
+import static com.squareup.picasso.BitmapHunter.forRequest;
+
+class Dispatcher {
+  private static final int RETRY_DELAY = 500;
+  private static final int AIRPLANE_MODE_ON = 1;
+  private static final int AIRPLANE_MODE_OFF = 0;
+
+  static final int REQUEST_SUBMIT = 1;
+  static final int REQUEST_CANCEL = 2;
+  static final int REQUEST_GCED = 3;
+  static final int HUNTER_COMPLETE = 4;
+  static final int HUNTER_RETRY = 5;
+  static final int HUNTER_DECODE_FAILED = 6;
+  static final int HUNTER_DELAY_NEXT_BATCH = 7;
+  static final int HUNTER_BATCH_COMPLETE = 8;
+  static final int NETWORK_STATE_CHANGE = 9;
+  static final int AIRPLANE_MODE_CHANGE = 10;
+
+  private static final String DISPATCHER_THREAD_NAME = "Dispatcher";
+  private static final int BATCH_DELAY = 200; // ms
+
+  final DispatcherThread dispatcherThread;
+  final Context context;
+  final ExecutorService service;
+  final Downloader downloader;
+  final Map<String, BitmapHunter> hunterMap;
+  final Handler handler;
+  final Handler mainThreadHandler;
+  final Cache cache;
+  final Stats stats;
+  final List<BitmapHunter> batch;
+  final NetworkBroadcastReceiver receiver;
+
+  NetworkInfo networkInfo;
+  boolean airplaneMode;
+
+  Dispatcher(Context context, ExecutorService service, Handler mainThreadHandler,
+      Downloader downloader, Cache cache, Stats stats) {
+    this.dispatcherThread = new DispatcherThread();
+    this.dispatcherThread.start();
+    this.context = context;
+    this.service = service;
+    this.hunterMap = new LinkedHashMap<String, BitmapHunter>();
+    this.handler = new DispatcherHandler(dispatcherThread.getLooper(), this);
+    this.downloader = downloader;
+    this.mainThreadHandler = mainThreadHandler;
+    this.cache = cache;
+    this.stats = stats;
+    this.batch = new ArrayList<BitmapHunter>(4);
+    this.airplaneMode = Utils.isAirplaneModeOn(this.context);
+    this.receiver = new NetworkBroadcastReceiver(this.context);
+    receiver.register();
+  }
+
+  void shutdown() {
+    service.shutdown();
+    dispatcherThread.quit();
+    receiver.unregister();
+  }
+
+  void dispatchSubmit(Action action) {
+    handler.sendMessage(handler.obtainMessage(REQUEST_SUBMIT, action));
+  }
+
+  void dispatchCancel(Action action) {
+    handler.sendMessage(handler.obtainMessage(REQUEST_CANCEL, action));
+  }
+
+  void dispatchComplete(BitmapHunter hunter) {
+    handler.sendMessage(handler.obtainMessage(HUNTER_COMPLETE, hunter));
+  }
+
+  void dispatchRetry(BitmapHunter hunter) {
+    handler.sendMessageDelayed(handler.obtainMessage(HUNTER_RETRY, hunter), RETRY_DELAY);
+  }
+
+  void dispatchFailed(BitmapHunter hunter) {
+    handler.sendMessage(handler.obtainMessage(HUNTER_DECODE_FAILED, hunter));
+  }
+
+  void dispatchNetworkStateChange(NetworkInfo info) {
+    handler.sendMessage(handler.obtainMessage(NETWORK_STATE_CHANGE, info));
+  }
+
+  void dispatchAirplaneModeChange(boolean airplaneMode) {
+    handler.sendMessage(handler.obtainMessage(AIRPLANE_MODE_CHANGE,
+        airplaneMode ? AIRPLANE_MODE_ON : AIRPLANE_MODE_OFF, 0));
+  }
+
+  void performSubmit(Action action) {
+    BitmapHunter hunter = hunterMap.get(action.getKey());
+    if (hunter != null) {
+      hunter.attach(action);
+      return;
+    }
+
+    if (service.isShutdown()) {
+      return;
+    }
+
+    hunter = forRequest(context, action.getPicasso(), this, cache, stats, action, downloader);
+    hunter.future = service.submit(hunter);
+    hunterMap.put(action.getKey(), hunter);
+  }
+
+  void performCancel(Action action) {
+    String key = action.getKey();
+    BitmapHunter hunter = hunterMap.get(key);
+    if (hunter != null) {
+      hunter.detach(action);
+      if (hunter.cancel()) {
+        hunterMap.remove(key);
+      }
+    }
+  }
+
+  void performRetry(BitmapHunter hunter) {
+    if (hunter.isCancelled()) return;
+
+    if (service.isShutdown()) {
+      performError(hunter);
+      return;
+    }
+
+    if (hunter.shouldRetry(airplaneMode, networkInfo)) {
+      hunter.future = service.submit(hunter);
+    } else {
+      performError(hunter);
+    }
+  }
+
+  void performComplete(BitmapHunter hunter) {
+    if (!hunter.shouldSkipMemoryCache()) {
+      cache.set(hunter.getKey(), hunter.getResult());
+    }
+    hunterMap.remove(hunter.getKey());
+    batch(hunter);
+  }
+
+  void performBatchComplete() {
+    List<BitmapHunter> copy = new ArrayList<BitmapHunter>(batch);
+    batch.clear();
+    mainThreadHandler.sendMessage(mainThreadHandler.obtainMessage(HUNTER_BATCH_COMPLETE, copy));
+  }
+
+  void performError(BitmapHunter hunter) {
+    hunterMap.remove(hunter.getKey());
+    batch(hunter);
+  }
+
+  void performAirplaneModeChange(boolean airplaneMode) {
+    this.airplaneMode = airplaneMode;
+  }
+
+  void performNetworkStateChange(NetworkInfo info) {
+    networkInfo = info;
+    if (service instanceof PicassoExecutorService) {
+      ((PicassoExecutorService) service).adjustThreadCount(info);
+    }
+  }
+
+  private void batch(BitmapHunter hunter) {
+    if (hunter.isCancelled()) {
+      return;
+    }
+    batch.add(hunter);
+    if (!handler.hasMessages(HUNTER_DELAY_NEXT_BATCH)) {
+      handler.sendEmptyMessageDelayed(HUNTER_DELAY_NEXT_BATCH, BATCH_DELAY);
+    }
+  }
+
+  private static class DispatcherHandler extends Handler {
+    private final Dispatcher dispatcher;
+
+    public DispatcherHandler(Looper looper, Dispatcher dispatcher) {
+      super(looper);
+      this.dispatcher = dispatcher;
+    }
+
+    @Override public void handleMessage(final Message msg) {
+      switch (msg.what) {
+        case REQUEST_SUBMIT: {
+          Action action = (Action) msg.obj;
+          dispatcher.performSubmit(action);
+          break;
+        }
+        case REQUEST_CANCEL: {
+          Action action = (Action) msg.obj;
+          dispatcher.performCancel(action);
+          break;
+        }
+        case HUNTER_COMPLETE: {
+          BitmapHunter hunter = (BitmapHunter) msg.obj;
+          dispatcher.performComplete(hunter);
+          break;
+        }
+        case HUNTER_RETRY: {
+          BitmapHunter hunter = (BitmapHunter) msg.obj;
+          dispatcher.performRetry(hunter);
+          break;
+        }
+        case HUNTER_DECODE_FAILED: {
+          BitmapHunter hunter = (BitmapHunter) msg.obj;
+          dispatcher.performError(hunter);
+          break;
+        }
+        case HUNTER_DELAY_NEXT_BATCH: {
+          dispatcher.performBatchComplete();
+          break;
+        }
+        case NETWORK_STATE_CHANGE: {
+          NetworkInfo info = (NetworkInfo) msg.obj;
+          dispatcher.performNetworkStateChange(info);
+          break;
+        }
+        case AIRPLANE_MODE_CHANGE: {
+          dispatcher.performAirplaneModeChange(msg.arg1 == AIRPLANE_MODE_ON);
+          break;
+        }
+        default:
+          Picasso.HANDLER.post(new Runnable() {
+            @Override public void run() {
+              throw new AssertionError("Unknown handler message received: " + msg.what);
+            }
+          });
+      }
+    }
+  }
+
+  static class DispatcherThread extends HandlerThread {
+    DispatcherThread() {
+      super(Utils.THREAD_PREFIX + DISPATCHER_THREAD_NAME, THREAD_PRIORITY_BACKGROUND);
+    }
+  }
+
+  private class NetworkBroadcastReceiver extends BroadcastReceiver {
+    private static final String EXTRA_AIRPLANE_STATE = "state";
+
+    private final ConnectivityManager connectivityManager;
+
+    NetworkBroadcastReceiver(Context context) {
+      connectivityManager = (ConnectivityManager) context.getSystemService(CONNECTIVITY_SERVICE);
+    }
+
+    void register() {
+      boolean shouldScanState = service instanceof PicassoExecutorService && //
+          Utils.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE);
+      IntentFilter filter = new IntentFilter();
+      filter.addAction(ACTION_AIRPLANE_MODE_CHANGED);
+      if (shouldScanState) {
+        filter.addAction(CONNECTIVITY_ACTION);
+      }
+      context.registerReceiver(this, filter);
+    }
+
+    void unregister() {
+      context.unregisterReceiver(this);
+    }
+
+    @Override public void onReceive(Context context, Intent intent) {
+      // On some versions of Android this may be called with a null Intent
+      if (null == intent) {
+        return;
+      }
+
+      String action = intent.getAction();
+      Bundle extras = intent.getExtras();
+
+      if (ACTION_AIRPLANE_MODE_CHANGED.equals(action)) {
+        dispatchAirplaneModeChange(extras.getBoolean(EXTRA_AIRPLANE_STATE, false));
+      } else if (CONNECTIVITY_ACTION.equals(action)) {
+        dispatchNetworkStateChange(connectivityManager.getActiveNetworkInfo());
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Downloader.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.graphics.Bitmap;
+import android.net.Uri;
+import java.io.IOException;
+import java.io.InputStream;
+
+/** A mechanism to load images from external resources such as a disk cache and/or the internet. */
+public interface Downloader {
+  /**
+   * Download the specified image {@code url} from the internet.
+   *
+   * @param uri Remote image URL.
+   * @param localCacheOnly If {@code true} the URL should only be loaded if available in a local
+   * disk cache.
+   * @return {@link Response} containing either a {@link Bitmap} representation of the request or an
+   * {@link InputStream} for the image data. {@code null} can be returned to indicate a problem
+   * loading the bitmap.
+   * @throws IOException if the requested URL cannot successfully be loaded.
+   */
+  Response load(Uri uri, boolean localCacheOnly) throws IOException;
+
+  /** Thrown for non-2XX responses. */
+  class ResponseException extends IOException {
+    public ResponseException(String message) {
+      super(message);
+    }
+  }
+
+  /** Response stream or bitmap and info. */
+  class Response {
+    final InputStream stream;
+    final Bitmap bitmap;
+    final boolean cached;
+
+    /**
+     * Response image and info.
+     *
+     * @param bitmap Image.
+     * @param loadedFromCache {@code true} if the source of the image is from a local disk cache.
+     */
+    public Response(Bitmap bitmap, boolean loadedFromCache) {
+      if (bitmap == null) {
+        throw new IllegalArgumentException("Bitmap may not be null.");
+      }
+      this.stream = null;
+      this.bitmap = bitmap;
+      this.cached = loadedFromCache;
+    }
+
+    /**
+     * Response stream and info.
+     *
+     * @param stream Image data stream.
+     * @param loadedFromCache {@code true} if the source of the stream is from a local disk cache.
+     */
+    public Response(InputStream stream, boolean loadedFromCache) {
+      if (stream == null) {
+        throw new IllegalArgumentException("Stream may not be null.");
+      }
+      this.stream = stream;
+      this.bitmap = null;
+      this.cached = loadedFromCache;
+    }
+
+    /**
+     * Input stream containing image data.
+     * <p>
+     * If this returns {@code null}, image data will be available via {@link #getBitmap()}.
+     */
+    public InputStream getInputStream() {
+      return stream;
+    }
+
+    /**
+     * Bitmap representing the image.
+     * <p>
+     * If this returns {@code null}, image data will be available via {@link #getInputStream()}.
+     */
+    public Bitmap getBitmap() {
+      return bitmap;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/FetchAction.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.graphics.Bitmap;
+
+class FetchAction extends Action<Void> {
+  FetchAction(Picasso picasso, Request data, boolean skipCache, String key) {
+    super(picasso, null, data, skipCache, false, 0, null, key);
+  }
+
+  @Override void complete(Bitmap result, Picasso.LoadedFrom from) {
+  }
+
+  @Override public void error() {
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/FileBitmapHunter.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.media.ExifInterface;
+import android.net.Uri;
+import java.io.IOException;
+
+import static android.media.ExifInterface.ORIENTATION_NORMAL;
+import static android.media.ExifInterface.ORIENTATION_ROTATE_180;
+import static android.media.ExifInterface.ORIENTATION_ROTATE_270;
+import static android.media.ExifInterface.ORIENTATION_ROTATE_90;
+import static android.media.ExifInterface.TAG_ORIENTATION;
+
+class FileBitmapHunter extends ContentStreamBitmapHunter {
+
+  FileBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache,
+      Stats stats, Action action) {
+    super(context, picasso, dispatcher, cache, stats, action);
+  }
+
+  @Override Bitmap decode(Request data)
+      throws IOException {
+    setExifRotation(getFileExifRotation(data.uri));
+    return super.decode(data);
+  }
+
+  static int getFileExifRotation(Uri uri) throws IOException {
+    ExifInterface exifInterface = new ExifInterface(uri.getPath());
+    int orientation = exifInterface.getAttributeInt(TAG_ORIENTATION, ORIENTATION_NORMAL);
+    switch (orientation) {
+      case ORIENTATION_ROTATE_90:
+        return 90;
+      case ORIENTATION_ROTATE_180:
+        return 180;
+      case ORIENTATION_ROTATE_270:
+        return 270;
+      default:
+        return 0;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/GetAction.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.graphics.Bitmap;
+
+class GetAction extends Action<Void> {
+  GetAction(Picasso picasso, Request data, boolean skipCache, String key) {
+    super(picasso, null, data, skipCache, false, 0, null, key);
+  }
+
+  @Override void complete(Bitmap result, Picasso.LoadedFrom from) {
+  }
+
+  @Override public void error() {
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/ImageViewAction.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.widget.ImageView;
+
+class ImageViewAction extends Action<ImageView> {
+
+  Callback callback;
+
+  ImageViewAction(Picasso picasso, ImageView imageView, Request data, boolean skipCache,
+      boolean noFade, int errorResId, Drawable errorDrawable, String key, Callback callback) {
+    super(picasso, imageView, data, skipCache, noFade, errorResId, errorDrawable, key);
+    this.callback = callback;
+  }
+
+  @Override public void complete(Bitmap result, Picasso.LoadedFrom from) {
+    if (result == null) {
+      throw new AssertionError(
+          String.format("Attempted to complete action with no result!\n%s", this));
+    }
+
+    ImageView target = this.target.get();
+    if (target == null) {
+      return;
+    }
+
+    Context context = picasso.context;
+    boolean debugging = picasso.debugging;
+    PicassoDrawable.setBitmap(target, context, result, from, noFade, debugging);
+
+    if (callback != null) {
+      callback.onSuccess();
+    }
+  }
+
+  @Override public void error() {
+    ImageView target = this.target.get();
+    if (target == null) {
+      return;
+    }
+    if (errorResId != 0) {
+      target.setImageResource(errorResId);
+    } else if (errorDrawable != null) {
+      target.setImageDrawable(errorDrawable);
+    }
+
+    if (callback != null) {
+      callback.onError();
+    }
+  }
+
+  @Override void cancel() {
+    super.cancel();
+    if (callback != null) {
+      callback = null;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/LruCache.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** A memory cache which uses a least-recently used eviction policy. */
+public class LruCache implements Cache {
+  final LinkedHashMap<String, Bitmap> map;
+  private final int maxSize;
+
+  private int size;
+  private int putCount;
+  private int evictionCount;
+  private int hitCount;
+  private int missCount;
+
+  /** Create a cache using an appropriate portion of the available RAM as the maximum size. */
+  public LruCache(Context context) {
+    this(Utils.calculateMemoryCacheSize(context));
+  }
+
+  /** Create a cache with a given maximum size in bytes. */
+  public LruCache(int maxSize) {
+    if (maxSize <= 0) {
+      throw new IllegalArgumentException("Max size must be positive.");
+    }
+    this.maxSize = maxSize;
+    this.map = new LinkedHashMap<String, Bitmap>(0, 0.75f, true);
+  }
+
+  @Override public Bitmap get(String key) {
+    if (key == null) {
+      throw new NullPointerException("key == null");
+    }
+
+    Bitmap mapValue;
+    synchronized (this) {
+      mapValue = map.get(key);
+      if (mapValue != null) {
+        hitCount++;
+        return mapValue;
+      }
+      missCount++;
+    }
+
+    return null;
+  }
+
+  @Override public void set(String key, Bitmap bitmap) {
+    if (key == null || bitmap == null) {
+      throw new NullPointerException("key == null || bitmap == null");
+    }
+
+    Bitmap previous;
+    synchronized (this) {
+      putCount++;
+      size += Utils.getBitmapBytes(bitmap);
+      previous = map.put(key, bitmap);
+      if (previous != null) {
+        size -= Utils.getBitmapBytes(previous);
+      }
+    }
+
+    trimToSize(maxSize);
+  }
+
+  private void trimToSize(int maxSize) {
+    while (true) {
+      String key;
+      Bitmap value;
+      synchronized (this) {
+        if (size < 0 || (map.isEmpty() && size != 0)) {
+          throw new IllegalStateException(
+              getClass().getName() + ".sizeOf() is reporting inconsistent results!");
+        }
+
+        if (size <= maxSize || map.isEmpty()) {
+          break;
+        }
+
+        Map.Entry<String, Bitmap> toEvict = map.entrySet().iterator().next();
+        key = toEvict.getKey();
+        value = toEvict.getValue();
+        map.remove(key);
+        size -= Utils.getBitmapBytes(value);
+        evictionCount++;
+      }
+    }
+  }
+
+  /** Clear the cache. */
+  public final void evictAll() {
+    trimToSize(-1); // -1 will evict 0-sized elements
+  }
+
+  /** Returns the sum of the sizes of the entries in this cache. */
+  public final synchronized int size() {
+    return size;
+  }
+
+  /** Returns the maximum sum of the sizes of the entries in this cache. */
+  public final synchronized int maxSize() {
+    return maxSize;
+  }
+
+  public final synchronized void clear() {
+    evictAll();
+  }
+
+  /** Returns the number of times {@link #get} returned a value. */
+  public final synchronized int hitCount() {
+    return hitCount;
+  }
+
+  /** Returns the number of times {@link #get} returned {@code null}. */
+  public final synchronized int missCount() {
+    return missCount;
+  }
+
+  /** Returns the number of times {@link #set(String, Bitmap)} was called. */
+  public final synchronized int putCount() {
+    return putCount;
+  }
+
+  /** Returns the number of values that have been evicted. */
+  public final synchronized int evictionCount() {
+    return evictionCount;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/MarkableInputStream.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * An input stream wrapper that supports unlimited independent cursors for
+ * marking and resetting. Each cursor is a token, and it's the caller's
+ * responsibility to keep track of these.
+ */
+final class MarkableInputStream extends InputStream {
+  private final InputStream in;
+
+  private long offset;
+  private long reset;
+  private long limit;
+
+  private long defaultMark = -1;
+
+  public MarkableInputStream(InputStream in) {
+    if (!in.markSupported()) {
+      in = new BufferedInputStream(in);
+    }
+    this.in = in;
+  }
+
+  /** Marks this place in the stream so we can reset back to it later. */
+  @Override public void mark(int readLimit) {
+    defaultMark = savePosition(readLimit);
+  }
+
+  /**
+   * Returns an opaque token representing the current position in the stream.
+   * Call {@link #reset(long)} to return to this position in the stream later.
+   * It is an error to call {@link #reset(long)} after consuming more than
+   * {@code readLimit} bytes from this stream.
+   */
+  public long savePosition(int readLimit) {
+    long offsetLimit = offset + readLimit;
+    if (limit < offsetLimit) {
+      setLimit(offsetLimit);
+    }
+    return offset;
+  }
+
+  /**
+   * Makes sure that the underlying stream can backtrack the full range from
+   * {@code reset} thru {@code limit}. Since we can't call {@code mark()}
+   * without also adjusting the reset-to-position on the underlying stream this
+   * method resets first and then marks the union of the two byte ranges. On
+   * buffered streams this additional cursor motion shouldn't result in any
+   * additional I/O.
+   */
+  private void setLimit(long limit) {
+    try {
+      if (reset < offset && offset <= this.limit) {
+        in.reset();
+        in.mark((int) (limit - reset));
+        skip(reset, offset);
+      } else {
+        reset = offset;
+        in.mark((int) (limit - offset));
+      }
+      this.limit = limit;
+    } catch (IOException e) {
+      throw new IllegalStateException("Unable to mark: " + e);
+    }
+  }
+
+  /** Resets the stream to the most recent {@link #mark mark}. */
+  @Override public void reset() throws IOException {
+    reset(defaultMark);
+  }
+
+  /** Resets the stream to the position recorded by {@code token}. */
+  public void reset(long token) throws IOException {
+    if (offset > limit || token < reset) {
+      throw new IOException("Cannot reset");
+    }
+    in.reset();
+    skip(reset, token);
+    offset = token;
+  }
+
+  /** Skips {@code target - current} bytes and returns. */
+  private void skip(long current, long target) throws IOException {
+    while (current < target) {
+      long skipped = in.skip(target - current);
+      if (skipped == 0) {
+        if (read() == -1) {
+          break; // EOF
+        } else {
+          skipped = 1;
+        }
+      }
+      current += skipped;
+    }
+  }
+
+  @Override public int read() throws IOException {
+    int result = in.read();
+    if (result != -1) {
+      offset++;
+    }
+    return result;
+  }
+
+  @Override public int read(byte[] buffer) throws IOException {
+    int count = in.read(buffer);
+    if (count != -1) {
+      offset += count;
+    }
+    return count;
+  }
+
+  @Override public int read(byte[] buffer, int offset, int length) throws IOException {
+    int count = in.read(buffer, offset, length);
+    if (count != -1) {
+      this.offset += count;
+    }
+    return count;
+  }
+
+  @Override public long skip(long byteCount) throws IOException {
+    long skipped = in.skip(byteCount);
+    offset += skipped;
+    return skipped;
+  }
+
+  @Override public int available() throws IOException {
+    return in.available();
+  }
+
+  @Override public void close() throws IOException {
+    in.close();
+  }
+
+  @Override public boolean markSupported() {
+    return in.markSupported();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/MediaStoreBitmapHunter.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2014 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.provider.MediaStore;
+import java.io.IOException;
+
+import static android.content.ContentUris.parseId;
+import static android.provider.MediaStore.Images.Thumbnails.FULL_SCREEN_KIND;
+import static android.provider.MediaStore.Images.Thumbnails.MICRO_KIND;
+import static android.provider.MediaStore.Images.Thumbnails.MINI_KIND;
+import static android.provider.MediaStore.Images.Thumbnails.getThumbnail;
+import static com.squareup.picasso.MediaStoreBitmapHunter.PicassoKind.FULL;
+import static com.squareup.picasso.MediaStoreBitmapHunter.PicassoKind.MICRO;
+import static com.squareup.picasso.MediaStoreBitmapHunter.PicassoKind.MINI;
+
+class MediaStoreBitmapHunter extends ContentStreamBitmapHunter {
+  private static final String[] CONTENT_ORIENTATION = new String[] {
+      MediaStore.Images.ImageColumns.ORIENTATION
+  };
+
+  MediaStoreBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache,
+      Stats stats, Action action) {
+    super(context, picasso, dispatcher, cache, stats, action);
+  }
+
+  @Override Bitmap decode(Request data) throws IOException {
+    ContentResolver contentResolver = context.getContentResolver();
+    setExifRotation(getExitOrientation(contentResolver, data.uri));
+
+    if (data.hasSize()) {
+      PicassoKind picassoKind = getPicassoKind(data.targetWidth, data.targetHeight);
+      if (picassoKind == FULL) {
+        return super.decode(data);
+      }
+
+      long id = parseId(data.uri);
+
+      BitmapFactory.Options options = new BitmapFactory.Options();
+      options.inJustDecodeBounds = true;
+
+      calculateInSampleSize(data.targetWidth, data.targetHeight, picassoKind.width,
+          picassoKind.height, options);
+
+      Bitmap result = getThumbnail(contentResolver, id, picassoKind.androidKind, options);
+
+      if (result != null) {
+        return result;
+      }
+    }
+
+    return super.decode(data);
+  }
+
+  static PicassoKind getPicassoKind(int targetWidth, int targetHeight) {
+    if (targetWidth <= MICRO.width && targetHeight <= MICRO.height) {
+      return MICRO;
+    } else if (targetWidth <= MINI.width && targetHeight <= MINI.height) {
+      return MINI;
+    }
+    return FULL;
+  }
+
+  static int getExitOrientation(ContentResolver contentResolver, Uri uri) {
+    Cursor cursor = null;
+    try {
+      cursor = contentResolver.query(uri, CONTENT_ORIENTATION, null, null, null);
+      if (cursor == null || !cursor.moveToFirst()) {
+        return 0;
+      }
+      return cursor.getInt(0);
+    } catch (RuntimeException ignored) {
+      // If the orientation column doesn't exist, assume no rotation.
+      return 0;
+    } finally {
+      if (cursor != null) {
+        cursor.close();
+      }
+    }
+  }
+
+  enum PicassoKind {
+    MICRO(MICRO_KIND, 96, 96),
+    MINI(MINI_KIND, 512, 384),
+    FULL(FULL_SCREEN_KIND, -1, -1);
+
+    final int androidKind;
+    final int width;
+    final int height;
+
+    PicassoKind(int androidKind, int width, int height) {
+      this.androidKind = androidKind;
+      this.width = width;
+      this.height = height;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/NetworkBitmapHunter.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.NetworkInfo;
+import java.io.IOException;
+import java.io.InputStream;
+
+import static com.squareup.picasso.Downloader.Response;
+import static com.squareup.picasso.Picasso.LoadedFrom.DISK;
+import static com.squareup.picasso.Picasso.LoadedFrom.NETWORK;
+
+class NetworkBitmapHunter extends BitmapHunter {
+  static final int DEFAULT_RETRY_COUNT = 2;
+  private static final int MARKER = 65536;
+
+  private final Downloader downloader;
+
+  int retryCount;
+
+  public NetworkBitmapHunter(Picasso picasso, Dispatcher dispatcher, Cache cache, Stats stats,
+      Action action, Downloader downloader) {
+    super(picasso, dispatcher, cache, stats, action);
+    this.downloader = downloader;
+    this.retryCount = DEFAULT_RETRY_COUNT;
+  }
+
+  @Override Bitmap decode(Request data) throws IOException {
+    boolean loadFromLocalCacheOnly = retryCount == 0;
+
+    Response response = downloader.load(data.uri, loadFromLocalCacheOnly);
+    if (response == null) {
+      return null;
+    }
+
+    loadedFrom = response.cached ? DISK : NETWORK;
+
+    Bitmap result = response.getBitmap();
+    if (result != null) {
+      return result;
+    }
+
+    InputStream is = response.getInputStream();
+    try {
+      return decodeStream(is, data);
+    } finally {
+      Utils.closeQuietly(is);
+    }
+  }
+
+  @Override boolean shouldRetry(boolean airplaneMode, NetworkInfo info) {
+    boolean hasRetries = retryCount > 0;
+    if (!hasRetries) {
+      return false;
+    }
+    retryCount--;
+    return info == null || info.isConnectedOrConnecting();
+  }
+
+  private Bitmap decodeStream(InputStream stream, Request data) throws IOException {
+    if (stream == null) {
+      return null;
+    }
+    MarkableInputStream markStream = new MarkableInputStream(stream);
+    stream = markStream;
+
+    long mark = markStream.savePosition(MARKER);
+
+    boolean isWebPFile = Utils.isWebPFile(stream);
+    markStream.reset(mark);
+    // When decode WebP network stream, BitmapFactory throw JNI Exception and make app crash.
+    // Decode byte array instead
+    if (isWebPFile) {
+      byte[] bytes = Utils.toByteArray(stream);
+      BitmapFactory.Options options = null;
+      if (data.hasSize()) {
+        options = new BitmapFactory.Options();
+        options.inJustDecodeBounds = true;
+
+        BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
+        calculateInSampleSize(data.targetWidth, data.targetHeight, options);
+      }
+      return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
+    } else {
+      BitmapFactory.Options options = null;
+      if (data.hasSize()) {
+        options = new BitmapFactory.Options();
+        options.inJustDecodeBounds = true;
+
+        BitmapFactory.decodeStream(stream, null, options);
+        calculateInSampleSize(data.targetWidth, data.targetHeight, options);
+
+        markStream.reset(mark);
+      }
+      return BitmapFactory.decodeStream(stream, null, options);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Picasso.java
@@ -0,0 +1,522 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Process;
+import android.widget.ImageView;
+import java.io.File;
+import java.lang.ref.ReferenceQueue;
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.concurrent.ExecutorService;
+
+import static android.os.Process.THREAD_PRIORITY_BACKGROUND;
+import static com.squareup.picasso.Action.RequestWeakReference;
+import static com.squareup.picasso.Dispatcher.HUNTER_BATCH_COMPLETE;
+import static com.squareup.picasso.Dispatcher.REQUEST_GCED;
+import static com.squareup.picasso.Utils.THREAD_PREFIX;
+
+/**
+ * Image downloading, transformation, and caching manager.
+ * <p/>
+ * Use {@link #with(android.content.Context)} for the global singleton instance or construct your
+ * own instance with {@link Builder}.
+ */
+public class Picasso {
+
+  /** Callbacks for Picasso events. */
+  public interface Listener {
+    /**
+     * Invoked when an image has failed to load. This is useful for reporting image failures to a
+     * remote analytics service, for example.
+     */
+    void onImageLoadFailed(Picasso picasso, Uri uri, Exception exception);
+  }
+
+  /**
+   * A transformer that is called immediately before every request is submitted. This can be used to
+   * modify any information about a request.
+   * <p>
+   * For example, if you use a CDN you can change the hostname for the image based on the current
+   * location of the user in order to get faster download speeds.
+   * <p>
+   * <b>NOTE:</b> This is a beta feature. The API is subject to change in a backwards incompatible
+   * way at any time.
+   */
+  public interface RequestTransformer {
+    /**
+     * Transform a request before it is submitted to be processed.
+     *
+     * @return The original request or a new request to replace it. Must not be null.
+     */
+    Request transformRequest(Request request);
+
+    /** A {@link RequestTransformer} which returns the original request. */
+    RequestTransformer IDENTITY = new RequestTransformer() {
+      @Override public Request transformRequest(Request request) {
+        return request;
+      }
+    };
+  }
+
+  static final Handler HANDLER = new Handler(Looper.getMainLooper()) {
+    @Override public void handleMessage(Message msg) {
+      switch (msg.what) {
+        case HUNTER_BATCH_COMPLETE: {
+          @SuppressWarnings("unchecked") List<BitmapHunter> batch = (List<BitmapHunter>) msg.obj;
+          for (BitmapHunter hunter : batch) {
+            hunter.picasso.complete(hunter);
+          }
+          break;
+        }
+        case REQUEST_GCED: {
+          Action action = (Action) msg.obj;
+          action.picasso.cancelExistingRequest(action.getTarget());
+          break;
+        }
+        default:
+          throw new AssertionError("Unknown handler message received: " + msg.what);
+      }
+    }
+  };
+
+  static Picasso singleton = null;
+
+  private final Listener listener;
+  private final RequestTransformer requestTransformer;
+  private final CleanupThread cleanupThread;
+
+  final Context context;
+  final Dispatcher dispatcher;
+  final Cache cache;
+  final Stats stats;
+  final Map<Object, Action> targetToAction;
+  final Map<ImageView, DeferredRequestCreator> targetToDeferredRequestCreator;
+  final ReferenceQueue<Object> referenceQueue;
+
+  boolean debugging;
+  boolean shutdown;
+
+  Picasso(Context context, Dispatcher dispatcher, Cache cache, Listener listener,
+      RequestTransformer requestTransformer, Stats stats, boolean debugging) {
+    this.context = context;
+    this.dispatcher = dispatcher;
+    this.cache = cache;
+    this.listener = listener;
+    this.requestTransformer = requestTransformer;
+    this.stats = stats;
+    this.targetToAction = new WeakHashMap<Object, Action>();
+    this.targetToDeferredRequestCreator = new WeakHashMap<ImageView, DeferredRequestCreator>();
+    this.debugging = debugging;
+    this.referenceQueue = new ReferenceQueue<Object>();
+    this.cleanupThread = new CleanupThread(referenceQueue, HANDLER);
+    this.cleanupThread.start();
+  }
+
+  /** Cancel any existing requests for the specified target {@link ImageView}. */
+  public void cancelRequest(ImageView view) {
+    cancelExistingRequest(view);
+  }
+
+  /** Cancel any existing requests for the specified {@link Target} instance. */
+  public void cancelRequest(Target target) {
+    cancelExistingRequest(target);
+  }
+
+  /**
+   * Start an image request using the specified URI.
+   * <p>
+   * Passing {@code null} as a {@code uri} will not trigger any request but will set a placeholder,
+   * if one is specified.
+   *
+   * @see #load(File)
+   * @see #load(String)
+   * @see #load(int)
+   */
+  public RequestCreator load(Uri uri) {
+    return new RequestCreator(this, uri, 0);
+  }
+
+  /**
+   * Start an image request using the specified path. This is a convenience method for calling
+   * {@link #load(Uri)}.
+   * <p>
+   * This path may be a remote URL, file resource (prefixed with {@code file:}), content resource
+   * (prefixed with {@code content:}), or android resource (prefixed with {@code
+   * android.resource:}.
+   * <p>
+   * Passing {@code null} as a {@code path} will not trigger any request but will set a
+   * placeholder, if one is specified.
+   *
+   * @see #load(Uri)
+   * @see #load(File)
+   * @see #load(int)
+   */
+  public RequestCreator load(String path) {
+    if (path == null) {
+      return new RequestCreator(this, null, 0);
+    }
+    if (path.trim().length() == 0) {
+      throw new IllegalArgumentException("Path must not be empty.");
+    }
+    return load(Uri.parse(path));
+  }
+
+  /**
+   * Start an image request using the specified image file. This is a convenience method for
+   * calling {@link #load(Uri)}.
+   * <p>
+   * Passing {@code null} as a {@code file} will not trigger any request but will set a
+   * placeholder, if one is specified.
+   *
+   * @see #load(Uri)
+   * @see #load(String)
+   * @see #load(int)
+   */
+  public RequestCreator load(File file) {
+    if (file == null) {
+      return new RequestCreator(this, null, 0);
+    }
+    return load(Uri.fromFile(file));
+  }
+
+  /**
+   * Start an image request using the specified drawable resource ID.
+   *
+   * @see #load(Uri)
+   * @see #load(String)
+   * @see #load(File)
+   */
+  public RequestCreator load(int resourceId) {
+    if (resourceId == 0) {
+      throw new IllegalArgumentException("Resource ID must not be zero.");
+    }
+    return new RequestCreator(this, null, resourceId);
+  }
+
+  /** {@code true} if debug display, logging, and statistics are enabled. */
+  @SuppressWarnings("UnusedDeclaration") public boolean isDebugging() {
+    return debugging;
+  }
+
+  /** Toggle whether debug display, logging, and statistics are enabled. */
+  @SuppressWarnings("UnusedDeclaration") public void setDebugging(boolean debugging) {
+    this.debugging = debugging;
+  }
+
+  /** Creates a {@link StatsSnapshot} of the current stats for this instance. */
+  @SuppressWarnings("UnusedDeclaration") public StatsSnapshot getSnapshot() {
+    return stats.createSnapshot();
+  }
+
+  /** Stops this instance from accepting further requests. */
+  public void shutdown() {
+    if (this == singleton) {
+      throw new UnsupportedOperationException("Default singleton instance cannot be shutdown.");
+    }
+    if (shutdown) {
+      return;
+    }
+    cache.clear();
+    cleanupThread.shutdown();
+    stats.shutdown();
+    dispatcher.shutdown();
+    for (DeferredRequestCreator deferredRequestCreator : targetToDeferredRequestCreator.values()) {
+      deferredRequestCreator.cancel();
+    }
+    targetToDeferredRequestCreator.clear();
+    shutdown = true;
+  }
+
+  Request transformRequest(Request request) {
+    Request transformed = requestTransformer.transformRequest(request);
+    if (transformed == null) {
+      throw new IllegalStateException("Request transformer "
+          + requestTransformer.getClass().getCanonicalName()
+          + " returned null for "
+          + request);
+    }
+    return transformed;
+  }
+
+  void defer(ImageView view, DeferredRequestCreator request) {
+    targetToDeferredRequestCreator.put(view, request);
+  }
+
+  void enqueueAndSubmit(Action action) {
+    Object target = action.getTarget();
+    if (target != null) {
+      cancelExistingRequest(target);
+      targetToAction.put(target, action);
+    }
+    submit(action);
+  }
+
+  void submit(Action action) {
+    dispatcher.dispatchSubmit(action);
+  }
+
+  Bitmap quickMemoryCacheCheck(String key) {
+    Bitmap cached = cache.get(key);
+    if (cached != null) {
+      stats.dispatchCacheHit();
+    } else {
+      stats.dispatchCacheMiss();
+    }
+    return cached;
+  }
+
+  void complete(BitmapHunter hunter) {
+    List<Action> joined = hunter.getActions();
+    if (joined.isEmpty()) {
+      return;
+    }
+
+    Uri uri = hunter.getData().uri;
+    Exception exception = hunter.getException();
+    Bitmap result = hunter.getResult();
+    LoadedFrom from = hunter.getLoadedFrom();
+
+    for (Action join : joined) {
+      if (join.isCancelled()) {
+        continue;
+      }
+      targetToAction.remove(join.getTarget());
+      if (result != null) {
+        if (from == null) {
+          throw new AssertionError("LoadedFrom cannot be null.");
+        }
+        join.complete(result, from);
+      } else {
+        join.error();
+      }
+    }
+
+    if (listener != null && exception != null) {
+      listener.onImageLoadFailed(this, uri, exception);
+    }
+  }
+
+  private void cancelExistingRequest(Object target) {
+    Action action = targetToAction.remove(target);
+    if (action != null) {
+      action.cancel();
+      dispatcher.dispatchCancel(action);
+    }
+    if (target instanceof ImageView) {
+      ImageView targetImageView = (ImageView) target;
+      DeferredRequestCreator deferredRequestCreator =
+          targetToDeferredRequestCreator.remove(targetImageView);
+      if (deferredRequestCreator != null) {
+        deferredRequestCreator.cancel();
+      }
+    }
+  }
+
+  private static class CleanupThread extends Thread {
+    private final ReferenceQueue<?> referenceQueue;
+    private final Handler handler;
+
+    CleanupThread(ReferenceQueue<?> referenceQueue, Handler handler) {
+      this.referenceQueue = referenceQueue;
+      this.handler = handler;
+      setDaemon(true);
+      setName(THREAD_PREFIX + "refQueue");
+    }
+
+    @Override public void run() {
+      Process.setThreadPriority(THREAD_PRIORITY_BACKGROUND);
+      while (true) {
+        try {
+          RequestWeakReference<?> remove = (RequestWeakReference<?>) referenceQueue.remove();
+          handler.sendMessage(handler.obtainMessage(REQUEST_GCED, remove.action));
+        } catch (InterruptedException e) {
+          break;
+        } catch (final Exception e) {
+          handler.post(new Runnable() {
+            @Override public void run() {
+              throw new RuntimeException(e);
+            }
+          });
+          break;
+        }
+      }
+    }
+
+    void shutdown() {
+      interrupt();
+    }
+  }
+
+  /**
+   * The global default {@link Picasso} instance.
+   * <p>
+   * This instance is automatically initialized with defaults that are suitable to most
+   * implementations.
+   * <ul>
+   * <li>LRU memory cache of 15% the available application RAM</li>
+   * <li>Disk cache of 2% storage space up to 50MB but no less than 5MB. (Note: this is only
+   * available on API 14+ <em>or</em> if you are using a standalone library that provides a disk
+   * cache on all API levels like OkHttp)</li>
+   * <li>Three download threads for disk and network access.</li>
+   * </ul>
+   * <p>
+   * If these settings do not meet the requirements of your application you can construct your own
+   * instance with full control over the configuration by using {@link Picasso.Builder}.
+   */
+  public static Picasso with(Context context) {
+    if (singleton == null) {
+      singleton = new Builder(context).build();
+    }
+    return singleton;
+  }
+
+  /** Fluent API for creating {@link Picasso} instances. */
+  @SuppressWarnings("UnusedDeclaration") // Public API.
+  public static class Builder {
+    private final Context context;
+    private Downloader downloader;
+    private ExecutorService service;
+    private Cache cache;
+    private Listener listener;
+    private RequestTransformer transformer;
+    private boolean debugging;
+
+    /** Start building a new {@link Picasso} instance. */
+    public Builder(Context context) {
+      if (context == null) {
+        throw new IllegalArgumentException("Context must not be null.");
+      }
+      this.context = context.getApplicationContext();
+    }
+
+    /** Specify the {@link Downloader} that will be used for downloading images. */
+    public Builder downloader(Downloader downloader) {
+      if (downloader == null) {
+        throw new IllegalArgumentException("Downloader must not be null.");
+      }
+      if (this.downloader != null) {
+        throw new IllegalStateException("Downloader already set.");
+      }
+      this.downloader = downloader;
+      return this;
+    }
+
+    /** Specify the executor service for loading images in the background. */
+    public Builder executor(ExecutorService executorService) {
+      if (executorService == null) {
+        throw new IllegalArgumentException("Executor service must not be null.");
+      }
+      if (this.service != null) {
+        throw new IllegalStateException("Executor service already set.");
+      }
+      this.service = executorService;
+      return this;
+    }
+
+    /** Specify the memory cache used for the most recent images. */
+    public Builder memoryCache(Cache memoryCache) {
+      if (memoryCache == null) {
+        throw new IllegalArgumentException("Memory cache must not be null.");
+      }
+      if (this.cache != null) {
+        throw new IllegalStateException("Memory cache already set.");
+      }
+      this.cache = memoryCache;
+      return this;
+    }
+
+    /** Specify a listener for interesting events. */
+    public Builder listener(Listener listener) {
+      if (listener == null) {
+        throw new IllegalArgumentException("Listener must not be null.");
+      }
+      if (this.listener != null) {
+        throw new IllegalStateException("Listener already set.");
+      }
+      this.listener = listener;
+      return this;
+    }
+
+    /**
+     * Specify a transformer for all incoming requests.
+     * <p>
+     * <b>NOTE:</b> This is a beta feature. The API is subject to change in a backwards incompatible
+     * way at any time.
+     */
+    public Builder requestTransformer(RequestTransformer transformer) {
+      if (transformer == null) {
+        throw new IllegalArgumentException("Transformer must not be null.");
+      }
+      if (this.transformer != null) {
+        throw new IllegalStateException("Transformer already set.");
+      }
+      this.transformer = transformer;
+      return this;
+    }
+
+    /** Whether debugging is enabled or not. */
+    public Builder debugging(boolean debugging) {
+      this.debugging = debugging;
+      return this;
+    }
+
+    /** Create the {@link Picasso} instance. */
+    public Picasso build() {
+      Context context = this.context;
+
+      if (downloader == null) {
+        downloader = Utils.createDefaultDownloader(context);
+      }
+      if (cache == null) {
+        cache = new LruCache(context);
+      }
+      if (service == null) {
+        service = new PicassoExecutorService();
+      }
+      if (transformer == null) {
+        transformer = RequestTransformer.IDENTITY;
+      }
+
+      Stats stats = new Stats(cache);
+
+      Dispatcher dispatcher = new Dispatcher(context, service, HANDLER, downloader, cache, stats);
+
+      return new Picasso(context, dispatcher, cache, listener, transformer, stats, debugging);
+    }
+  }
+
+  /** Describes where the image was loaded from. */
+  public enum LoadedFrom {
+    MEMORY(Color.GREEN),
+    DISK(Color.YELLOW),
+    NETWORK(Color.RED);
+
+    final int debugColor;
+
+    private LoadedFrom(int debugColor) {
+      this.debugColor = debugColor;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/PicassoDrawable.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.drawable.AnimationDrawable;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.SystemClock;
+import android.widget.ImageView;
+
+import static android.graphics.Color.WHITE;
+import static com.squareup.picasso.Picasso.LoadedFrom.MEMORY;
+
+final class PicassoDrawable extends Drawable {
+  // Only accessed from main thread.
+  private static final Paint DEBUG_PAINT = new Paint();
+
+  private static final float FADE_DURATION = 200f; //ms
+
+  /**
+   * Create or update the drawable on the target {@link ImageView} to display the supplied bitmap
+   * image.
+   */
+  static void setBitmap(ImageView target, Context context, Bitmap bitmap,
+      Picasso.LoadedFrom loadedFrom, boolean noFade, boolean debugging) {
+    Drawable placeholder = target.getDrawable();
+    if (placeholder instanceof AnimationDrawable) {
+      ((AnimationDrawable) placeholder).stop();
+    }
+    PicassoDrawable drawable =
+        new PicassoDrawable(context, placeholder, bitmap, loadedFrom, noFade, debugging);
+    target.setImageDrawable(drawable);
+  }
+
+  /**
+   * Create or update the drawable on the target {@link ImageView} to display the supplied
+   * placeholder image.
+   */
+  static void setPlaceholder(ImageView target, int placeholderResId, Drawable placeholderDrawable) {
+    if (placeholderResId != 0) {
+      target.setImageResource(placeholderResId);
+    } else {
+      target.setImageDrawable(placeholderDrawable);
+    }
+    if (target.getDrawable() instanceof AnimationDrawable) {
+      ((AnimationDrawable) target.getDrawable()).start();
+    }
+  }
+
+  private final boolean debugging;
+  private final float density;
+  private final Picasso.LoadedFrom loadedFrom;
+  final BitmapDrawable image;
+
+  Drawable placeholder;
+
+  long startTimeMillis;
+  boolean animating;
+  int alpha = 0xFF;
+
+  PicassoDrawable(Context context, Drawable placeholder, Bitmap bitmap,
+      Picasso.LoadedFrom loadedFrom, boolean noFade, boolean debugging) {
+    Resources res = context.getResources();
+
+    this.debugging = debugging;
+    this.density = res.getDisplayMetrics().density;
+
+    this.loadedFrom = loadedFrom;
+
+    this.image = new BitmapDrawable(res, bitmap);
+
+    boolean fade = loadedFrom != MEMORY && !noFade;
+    if (fade) {
+      this.placeholder = placeholder;
+      animating = true;
+      startTimeMillis = SystemClock.uptimeMillis();
+    }
+  }
+
+  @Override public void draw(Canvas canvas) {
+    if (!animating) {
+      image.draw(canvas);
+    } else {
+      float normalized = (SystemClock.uptimeMillis() - startTimeMillis) / FADE_DURATION;
+      if (normalized >= 1f) {
+        animating = false;
+        placeholder = null;
+        image.draw(canvas);
+      } else {
+        if (placeholder != null) {
+          placeholder.draw(canvas);
+        }
+
+        int partialAlpha = (int) (alpha * normalized);
+        image.setAlpha(partialAlpha);
+        image.draw(canvas);
+        image.setAlpha(alpha);
+        invalidateSelf();
+      }
+    }
+
+    if (debugging) {
+      drawDebugIndicator(canvas);
+    }
+  }
+
+  @Override public int getIntrinsicWidth() {
+    return image.getIntrinsicWidth();
+  }
+
+  @Override public int getIntrinsicHeight() {
+    return image.getIntrinsicHeight();
+  }
+
+  @Override public void setAlpha(int alpha) {
+    this.alpha = alpha;
+    if (placeholder != null) {
+      placeholder.setAlpha(alpha);
+    }
+    image.setAlpha(alpha);
+  }
+
+  @Override public void setColorFilter(ColorFilter cf) {
+    if (placeholder != null) {
+      placeholder.setColorFilter(cf);
+    }
+    image.setColorFilter(cf);
+  }
+
+  @Override public int getOpacity() {
+    return image.getOpacity();
+  }
+
+  @Override protected void onBoundsChange(Rect bounds) {
+    super.onBoundsChange(bounds);
+
+    image.setBounds(bounds);
+    if (placeholder != null) {
+      placeholder.setBounds(bounds);
+    }
+  }
+
+  private void drawDebugIndicator(Canvas canvas) {
+    DEBUG_PAINT.setColor(WHITE);
+    Path path = getTrianglePath(new Point(0, 0), (int) (16 * density));
+    canvas.drawPath(path, DEBUG_PAINT);
+
+    DEBUG_PAINT.setColor(loadedFrom.debugColor);
+    path = getTrianglePath(new Point(0, 0), (int) (15 * density));
+    canvas.drawPath(path, DEBUG_PAINT);
+  }
+
+  private static Path getTrianglePath(Point p1, int width) {
+    Point p2 = new Point(p1.x + width, p1.y);
+    Point p3 = new Point(p1.x, p1.y + width);
+
+    Path path = new Path();
+    path.moveTo(p1.x, p1.y);
+    path.lineTo(p2.x, p2.y);
+    path.lineTo(p3.x, p3.y);
+
+    return path;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/PicassoExecutorService.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.telephony.TelephonyManager;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * The default {@link java.util.concurrent.ExecutorService} used for new {@link Picasso} instances.
+ * <p/>
+ * Exists as a custom type so that we can differentiate the use of defaults versus a user-supplied
+ * instance.
+ */
+class PicassoExecutorService extends ThreadPoolExecutor {
+  private static final int DEFAULT_THREAD_COUNT = 3;
+
+  PicassoExecutorService() {
+    super(DEFAULT_THREAD_COUNT, DEFAULT_THREAD_COUNT, 0, TimeUnit.MILLISECONDS,
+        new LinkedBlockingQueue<Runnable>(), new Utils.PicassoThreadFactory());
+  }
+
+  void adjustThreadCount(NetworkInfo info) {
+    if (info == null || !info.isConnectedOrConnecting()) {
+      setThreadCount(DEFAULT_THREAD_COUNT);
+      return;
+    }
+    switch (info.getType()) {
+      case ConnectivityManager.TYPE_WIFI:
+      case ConnectivityManager.TYPE_WIMAX:
+      case ConnectivityManager.TYPE_ETHERNET:
+        setThreadCount(4);
+        break;
+      case ConnectivityManager.TYPE_MOBILE:
+        switch (info.getSubtype()) {
+          case TelephonyManager.NETWORK_TYPE_LTE:  // 4G
+          case TelephonyManager.NETWORK_TYPE_HSPAP:
+          case TelephonyManager.NETWORK_TYPE_EHRPD:
+            setThreadCount(3);
+            break;
+          case TelephonyManager.NETWORK_TYPE_UMTS: // 3G
+          case TelephonyManager.NETWORK_TYPE_CDMA:
+          case TelephonyManager.NETWORK_TYPE_EVDO_0:
+          case TelephonyManager.NETWORK_TYPE_EVDO_A:
+          case TelephonyManager.NETWORK_TYPE_EVDO_B:
+            setThreadCount(2);
+            break;
+          case TelephonyManager.NETWORK_TYPE_GPRS: // 2G
+          case TelephonyManager.NETWORK_TYPE_EDGE:
+            setThreadCount(1);
+            break;
+          default:
+            setThreadCount(DEFAULT_THREAD_COUNT);
+        }
+        break;
+      default:
+        setThreadCount(DEFAULT_THREAD_COUNT);
+    }
+  }
+
+  private void setThreadCount(int threadCount) {
+    setCorePoolSize(threadCount);
+    setMaximumPoolSize(threadCount);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Request.java
@@ -0,0 +1,307 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.net.Uri;
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.util.Collections.unmodifiableList;
+
+/** Immutable data about an image and the transformations that will be applied to it. */
+public final class Request {
+  /**
+   * The image URI.
+   * <p>
+   * This is mutually exclusive with {@link #resourceId}.
+   */
+  public final Uri uri;
+  /**
+   * The image resource ID.
+   * <p>
+   * This is mutually exclusive with {@link #uri}.
+   */
+  public final int resourceId;
+  /** List of custom transformations to be applied after the built-in transformations. */
+  public final List<Transformation> transformations;
+  /** Target image width for resizing. */
+  public final int targetWidth;
+  /** Target image height for resizing. */
+  public final int targetHeight;
+  /**
+   * True if the final image should use the 'centerCrop' scale technique.
+   * <p>
+   * This is mutually exclusive with {@link #centerInside}.
+   */
+  public final boolean centerCrop;
+  /**
+   * True if the final image should use the 'centerInside' scale technique.
+   * <p>
+   * This is mutually exclusive with {@link #centerCrop}.
+   */
+  public final boolean centerInside;
+  /** Amount to rotate the image in degrees. */
+  public final float rotationDegrees;
+  /** Rotation pivot on the X axis. */
+  public final float rotationPivotX;
+  /** Rotation pivot on the Y axis. */
+  public final float rotationPivotY;
+  /** Whether or not {@link #rotationPivotX} and {@link #rotationPivotY} are set. */
+  public final boolean hasRotationPivot;
+
+  private Request(Uri uri, int resourceId, List<Transformation> transformations, int targetWidth,
+      int targetHeight, boolean centerCrop, boolean centerInside, float rotationDegrees,
+      float rotationPivotX, float rotationPivotY, boolean hasRotationPivot) {
+    this.uri = uri;
+    this.resourceId = resourceId;
+    if (transformations == null) {
+      this.transformations = null;
+    } else {
+      this.transformations = unmodifiableList(transformations);
+    }
+    this.targetWidth = targetWidth;
+    this.targetHeight = targetHeight;
+    this.centerCrop = centerCrop;
+    this.centerInside = centerInside;
+    this.rotationDegrees = rotationDegrees;
+    this.rotationPivotX = rotationPivotX;
+    this.rotationPivotY = rotationPivotY;
+    this.hasRotationPivot = hasRotationPivot;
+  }
+
+  String getName() {
+    if (uri != null) {
+      return uri.getPath();
+    }
+    return Integer.toHexString(resourceId);
+  }
+
+  public boolean hasSize() {
+    return targetWidth != 0;
+  }
+
+  boolean needsTransformation() {
+    return needsMatrixTransform() || hasCustomTransformations();
+  }
+
+  boolean needsMatrixTransform() {
+    return targetWidth != 0 || rotationDegrees != 0;
+  }
+
+  boolean hasCustomTransformations() {
+    return transformations != null;
+  }
+
+  public Builder buildUpon() {
+    return new Builder(this);
+  }
+
+  /** Builder for creating {@link Request} instances. */
+  public static final class Builder {
+    private Uri uri;
+    private int resourceId;
+    private int targetWidth;
+    private int targetHeight;
+    private boolean centerCrop;
+    private boolean centerInside;
+    private float rotationDegrees;
+    private float rotationPivotX;
+    private float rotationPivotY;
+    private boolean hasRotationPivot;
+    private List<Transformation> transformations;
+
+    /** Start building a request using the specified {@link Uri}. */
+    public Builder(Uri uri) {
+      setUri(uri);
+    }
+
+    /** Start building a request using the specified resource ID. */
+    public Builder(int resourceId) {
+      setResourceId(resourceId);
+    }
+
+    Builder(Uri uri, int resourceId) {
+      this.uri = uri;
+      this.resourceId = resourceId;
+    }
+
+    private Builder(Request request) {
+      uri = request.uri;
+      resourceId = request.resourceId;
+      targetWidth = request.targetWidth;
+      targetHeight = request.targetHeight;
+      centerCrop = request.centerCrop;
+      centerInside = request.centerInside;
+      rotationDegrees = request.rotationDegrees;
+      rotationPivotX = request.rotationPivotX;
+      rotationPivotY = request.rotationPivotY;
+      hasRotationPivot = request.hasRotationPivot;
+      if (request.transformations != null) {
+        transformations = new ArrayList<Transformation>(request.transformations);
+      }
+    }
+
+    boolean hasImage() {
+      return uri != null || resourceId != 0;
+    }
+
+    boolean hasSize() {
+      return targetWidth != 0;
+    }
+
+    /**
+     * Set the target image Uri.
+     * <p>
+     * This will clear an image resource ID if one is set.
+     */
+    public Builder setUri(Uri uri) {
+      if (uri == null) {
+        throw new IllegalArgumentException("Image URI may not be null.");
+      }
+      this.uri = uri;
+      this.resourceId = 0;
+      return this;
+    }
+
+    /**
+     * Set the target image resource ID.
+     * <p>
+     * This will clear an image Uri if one is set.
+     */
+    public Builder setResourceId(int resourceId) {
+      if (resourceId == 0) {
+        throw new IllegalArgumentException("Image resource ID may not be 0.");
+      }
+      this.resourceId = resourceId;
+      this.uri = null;
+      return this;
+    }
+
+    /** Resize the image to the specified size in pixels. */
+    public Builder resize(int targetWidth, int targetHeight) {
+      if (targetWidth <= 0) {
+        throw new IllegalArgumentException("Width must be positive number.");
+      }
+      if (targetHeight <= 0) {
+        throw new IllegalArgumentException("Height must be positive number.");
+      }
+      this.targetWidth = targetWidth;
+      this.targetHeight = targetHeight;
+      return this;
+    }
+
+    /** Clear the resize transformation, if any. This will also clear center crop/inside if set. */
+    public Builder clearResize() {
+      targetWidth = 0;
+      targetHeight = 0;
+      centerCrop = false;
+      centerInside = false;
+      return this;
+    }
+
+    /**
+     * Crops an image inside of the bounds specified by {@link #resize(int, int)} rather than
+     * distorting the aspect ratio. This cropping technique scales the image so that it fills the
+     * requested bounds and then crops the extra.
+     */
+    public Builder centerCrop() {
+      if (centerInside) {
+        throw new IllegalStateException("Center crop can not be used after calling centerInside");
+      }
+      centerCrop = true;
+      return this;
+    }
+
+    /** Clear the center crop transformation flag, if set. */
+    public Builder clearCenterCrop() {
+      centerCrop = false;
+      return this;
+    }
+
+    /**
+     * Centers an image inside of the bounds specified by {@link #resize(int, int)}. This scales
+     * the image so that both dimensions are equal to or less than the requested bounds.
+     */
+    public Builder centerInside() {
+      if (centerCrop) {
+        throw new IllegalStateException("Center inside can not be used after calling centerCrop");
+      }
+      centerInside = true;
+      return this;
+    }
+
+    /** Clear the center inside transformation flag, if set. */
+    public Builder clearCenterInside() {
+      centerInside = false;
+      return this;
+    }
+
+    /** Rotate the image by the specified degrees. */
+    public Builder rotate(float degrees) {
+      rotationDegrees = degrees;
+      return this;
+    }
+
+    /** Rotate the image by the specified degrees around a pivot point. */
+    public Builder rotate(float degrees, float pivotX, float pivotY) {
+      rotationDegrees = degrees;
+      rotationPivotX = pivotX;
+      rotationPivotY = pivotY;
+      hasRotationPivot = true;
+      return this;
+    }
+
+    /** Clear the rotation transformation, if any. */
+    public Builder clearRotation() {
+      rotationDegrees = 0;
+      rotationPivotX = 0;
+      rotationPivotY = 0;
+      hasRotationPivot = false;
+      return this;
+    }
+
+    /**
+     * Add a custom transformation to be applied to the image.
+     * <p/>
+     * Custom transformations will always be run after the built-in transformations.
+     */
+    public Builder transform(Transformation transformation) {
+      if (transformation == null) {
+        throw new IllegalArgumentException("Transformation must not be null.");
+      }
+      if (transformations == null) {
+        transformations = new ArrayList<Transformation>(2);
+      }
+      transformations.add(transformation);
+      return this;
+    }
+
+    /** Create the immutable {@link Request} object. */
+    public Request build() {
+      if (centerInside && centerCrop) {
+        throw new IllegalStateException("Center crop and center inside can not be used together.");
+      }
+      if (centerCrop && targetWidth == 0) {
+        throw new IllegalStateException("Center crop requires calling resize.");
+      }
+      if (centerInside && targetWidth == 0) {
+        throw new IllegalStateException("Center inside requires calling resize.");
+      }
+      return new Request(uri, resourceId, transformations, targetWidth, targetHeight, centerCrop,
+          centerInside, rotationDegrees, rotationPivotX, rotationPivotY, hasRotationPivot);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/RequestCreator.java
@@ -0,0 +1,374 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.widget.ImageView;
+import java.io.IOException;
+
+import static com.squareup.picasso.BitmapHunter.forRequest;
+import static com.squareup.picasso.Picasso.LoadedFrom.MEMORY;
+import static com.squareup.picasso.Utils.checkNotMain;
+import static com.squareup.picasso.Utils.createKey;
+
+/** Fluent API for building an image download request. */
+@SuppressWarnings("UnusedDeclaration") // Public API.
+public class RequestCreator {
+  private final Picasso picasso;
+  private final Request.Builder data;
+
+  private boolean skipMemoryCache;
+  private boolean noFade;
+  private boolean deferred;
+  private int placeholderResId;
+  private Drawable placeholderDrawable;
+  private int errorResId;
+  private Drawable errorDrawable;
+
+  RequestCreator(Picasso picasso, Uri uri, int resourceId) {
+    if (picasso.shutdown) {
+      throw new IllegalStateException(
+          "Picasso instance already shut down. Cannot submit new requests.");
+    }
+    this.picasso = picasso;
+    this.data = new Request.Builder(uri, resourceId);
+  }
+
+  /**
+   * A placeholder drawable to be used while the image is being loaded. If the requested image is
+   * not immediately available in the memory cache then this resource will be set on the target
+   * {@link ImageView}.
+   */
+  public RequestCreator placeholder(int placeholderResId) {
+    if (placeholderResId == 0) {
+      throw new IllegalArgumentException("Placeholder image resource invalid.");
+    }
+    if (placeholderDrawable != null) {
+      throw new IllegalStateException("Placeholder image already set.");
+    }
+    this.placeholderResId = placeholderResId;
+    return this;
+  }
+
+  /**
+   * A placeholder drawable to be used while the image is being loaded. If the requested image is
+   * not immediately available in the memory cache then this resource will be set on the target
+   * {@link ImageView}.
+   * <p>
+   * If you are not using a placeholder image but want to clear an existing image (such as when
+   * used in an {@link android.widget.Adapter adapter}), pass in {@code null}.
+   */
+  public RequestCreator placeholder(Drawable placeholderDrawable) {
+    if (placeholderResId != 0) {
+      throw new IllegalStateException("Placeholder image already set.");
+    }
+    this.placeholderDrawable = placeholderDrawable;
+    return this;
+  }
+
+  /** An error drawable to be used if the request image could not be loaded. */
+  public RequestCreator error(int errorResId) {
+    if (errorResId == 0) {
+      throw new IllegalArgumentException("Error image resource invalid.");
+    }
+    if (errorDrawable != null) {
+      throw new IllegalStateException("Error image already set.");
+    }
+    this.errorResId = errorResId;
+    return this;
+  }
+
+  /** An error drawable to be used if the request image could not be loaded. */
+  public RequestCreator error(Drawable errorDrawable) {
+    if (errorDrawable == null) {
+      throw new IllegalArgumentException("Error image may not be null.");
+    }
+    if (errorResId != 0) {
+      throw new IllegalStateException("Error image already set.");
+    }
+    this.errorDrawable = errorDrawable;
+    return this;
+  }
+
+  /**
+   * Attempt to resize the image to fit exactly into the target {@link ImageView}'s bounds. This
+   * will result in delayed execution of the request until the {@link ImageView} has been measured.
+   * <p/>
+   * <em>Note:</em> This method works only when your target is an {@link ImageView}.
+   */
+  public RequestCreator fit() {
+    deferred = true;
+    return this;
+  }
+
+  /** Internal use only. Used by {@link DeferredRequestCreator}. */
+  RequestCreator unfit() {
+    deferred = false;
+    return this;
+  }
+
+  /** Resize the image to the specified dimension size. */
+  public RequestCreator resizeDimen(int targetWidthResId, int targetHeightResId) {
+    Resources resources = picasso.context.getResources();
+    int targetWidth = resources.getDimensionPixelSize(targetWidthResId);
+    int targetHeight = resources.getDimensionPixelSize(targetHeightResId);
+    return resize(targetWidth, targetHeight);
+  }
+
+  /** Resize the image to the specified size in pixels. */
+  public RequestCreator resize(int targetWidth, int targetHeight) {
+    data.resize(targetWidth, targetHeight);
+    return this;
+  }
+
+  /**
+   * Crops an image inside of the bounds specified by {@link #resize(int, int)} rather than
+   * distorting the aspect ratio. This cropping technique scales the image so that it fills the
+   * requested bounds and then crops the extra.
+   */
+  public RequestCreator centerCrop() {
+    data.centerCrop();
+    return this;
+  }
+
+  /**
+   * Centers an image inside of the bounds specified by {@link #resize(int, int)}. This scales
+   * the image so that both dimensions are equal to or less than the requested bounds.
+   */
+  public RequestCreator centerInside() {
+    data.centerInside();
+    return this;
+  }
+
+  /** Rotate the image by the specified degrees. */
+  public RequestCreator rotate(float degrees) {
+    data.rotate(degrees);
+    return this;
+  }
+
+  /** Rotate the image by the specified degrees around a pivot point. */
+  public RequestCreator rotate(float degrees, float pivotX, float pivotY) {
+    data.rotate(degrees, pivotX, pivotY);
+    return this;
+  }
+
+  /**
+   * Add a custom transformation to be applied to the image.
+   * <p/>
+   * Custom transformations will always be run after the built-in transformations.
+   */
+  // TODO show example of calling resize after a transform in the javadoc
+  public RequestCreator transform(Transformation transformation) {
+    data.transform(transformation);
+    return this;
+  }
+
+  /**
+   * Indicate that this action should not use the memory cache for attempting to load or save the
+   * image. This can be useful when you know an image will only ever be used once (e.g., loading
+   * an image from the filesystem and uploading to a remote server).
+   */
+  public RequestCreator skipMemoryCache() {
+    skipMemoryCache = true;
+    return this;
+  }
+
+  /** Disable brief fade in of images loaded from the disk cache or network. */
+  public RequestCreator noFade() {
+    noFade = true;
+    return this;
+  }
+
+  /** Synchronously fulfill this request. Must not be called from the main thread. */
+  public Bitmap get() throws IOException {
+    checkNotMain();
+    if (deferred) {
+      throw new IllegalStateException("Fit cannot be used with get.");
+    }
+    if (!data.hasImage()) {
+      return null;
+    }
+
+    Request finalData = picasso.transformRequest(data.build());
+    String key = createKey(finalData);
+
+    Action action = new GetAction(picasso, finalData, skipMemoryCache, key);
+    return forRequest(picasso.context, picasso, picasso.dispatcher, picasso.cache, picasso.stats,
+        action, picasso.dispatcher.downloader).hunt();
+  }
+
+  /**
+   * Asynchronously fulfills the request without a {@link ImageView} or {@link Target}. This is
+   * useful when you want to warm up the cache with an image.
+   */
+  public void fetch() {
+    if (deferred) {
+      throw new IllegalStateException("Fit cannot be used with fetch.");
+    }
+    if (data.hasImage()) {
+      Request finalData = picasso.transformRequest(data.build());
+      String key = createKey(finalData);
+
+      Action action = new FetchAction(picasso, finalData, skipMemoryCache, key);
+      picasso.enqueueAndSubmit(action);
+    }
+  }
+
+  /**
+   * Asynchronously fulfills the request into the specified {@link Target}. In most cases, you
+   * should use this when you are dealing with a custom {@link android.view.View View} or view
+   * holder which should implement the {@link Target} interface.
+   * <p>
+   * Implementing on a {@link android.view.View View}:
+   * <blockquote><pre>
+   * public class ProfileView extends FrameLayout implements Target {
+   *   {@literal @}Override public void onBitmapLoaded(Bitmap bitmap, LoadedFrom from) {
+   *     setBackgroundDrawable(new BitmapDrawable(bitmap));
+   *   }
+   *
+   *   {@literal @}Override public void onBitmapFailed() {
+   *     setBackgroundResource(R.drawable.profile_error);
+   *   }
+   * }
+   * </pre></blockquote>
+   * Implementing on a view holder object for use inside of an adapter:
+   * <blockquote><pre>
+   * public class ViewHolder implements Target {
+   *   public FrameLayout frame;
+   *   public TextView name;
+   *
+   *   {@literal @}Override public void onBitmapLoaded(Bitmap bitmap, LoadedFrom from) {
+   *     frame.setBackgroundDrawable(new BitmapDrawable(bitmap));
+   *   }
+   *
+   *   {@literal @}Override public void onBitmapFailed() {
+   *     frame.setBackgroundResource(R.drawable.profile_error);
+   *   }
+   * }
+   * </pre></blockquote>
+   * <p>
+   * <em>Note:</em> This method keeps a weak reference to the {@link Target} instance and will be
+   * garbage collected if you do not keep a strong reference to it. To receive callbacks when an
+   * image is loaded use {@link #into(android.widget.ImageView, Callback)}.
+   */
+  public void into(Target target) {
+    if (target == null) {
+      throw new IllegalArgumentException("Target must not be null.");
+    }
+    if (deferred) {
+      throw new IllegalStateException("Fit cannot be used with a Target.");
+    }
+
+    Drawable drawable =
+        placeholderResId != 0 ? picasso.context.getResources().getDrawable(placeholderResId)
+            : placeholderDrawable;
+
+    if (!data.hasImage()) {
+      picasso.cancelRequest(target);
+      target.onPrepareLoad(drawable);
+      return;
+    }
+
+    Request finalData = picasso.transformRequest(data.build());
+    String requestKey = createKey(finalData);
+
+    if (!skipMemoryCache) {
+      Bitmap bitmap = picasso.quickMemoryCacheCheck(requestKey);
+      if (bitmap != null) {
+        picasso.cancelRequest(target);
+        target.onBitmapLoaded(bitmap, MEMORY);
+        return;
+      }
+    }
+
+    target.onPrepareLoad(drawable);
+
+    Action action = new TargetAction(picasso, target, finalData, skipMemoryCache, requestKey);
+    picasso.enqueueAndSubmit(action);
+  }
+
+  /**
+   * Asynchronously fulfills the request into the specified {@link ImageView}.
+   * <p/>
+   * <em>Note:</em> This method keeps a weak reference to the {@link ImageView} instance and will
+   * automatically support object recycling.
+   */
+  public void into(ImageView target) {
+    into(target, null);
+  }
+
+  /**
+   * Asynchronously fulfills the request into the specified {@link ImageView} and invokes the
+   * target {@link Callback} if it's not {@code null}.
+   * <p/>
+   * <em>Note:</em> The {@link Callback} param is a strong reference and will prevent your
+   * {@link android.app.Activity} or {@link android.app.Fragment} from being garbage collected. If
+   * you use this method, it is <b>strongly</b> recommended you invoke an adjacent
+   * {@link Picasso#cancelRequest(android.widget.ImageView)} call to prevent temporary leaking.
+   */
+  public void into(ImageView target, Callback callback) {
+    if (target == null) {
+      throw new IllegalArgumentException("Target must not be null.");
+    }
+
+    if (!data.hasImage()) {
+      picasso.cancelRequest(target);
+      PicassoDrawable.setPlaceholder(target, placeholderResId, placeholderDrawable);
+      return;
+    }
+
+    if (deferred) {
+      if (data.hasSize()) {
+        throw new IllegalStateException("Fit cannot be used with resize.");
+      }
+      int measuredWidth = target.getMeasuredWidth();
+      int measuredHeight = target.getMeasuredHeight();
+      if (measuredWidth == 0 || measuredHeight == 0) {
+        PicassoDrawable.setPlaceholder(target, placeholderResId, placeholderDrawable);
+        picasso.defer(target, new DeferredRequestCreator(this, target, callback));
+        return;
+      }
+      data.resize(measuredWidth, measuredHeight);
+    }
+
+    Request finalData = picasso.transformRequest(data.build());
+    String requestKey = createKey(finalData);
+
+    if (!skipMemoryCache) {
+      Bitmap bitmap = picasso.quickMemoryCacheCheck(requestKey);
+      if (bitmap != null) {
+        picasso.cancelRequest(target);
+        PicassoDrawable.setBitmap(target, picasso.context, bitmap, MEMORY, noFade,
+            picasso.debugging);
+        if (callback != null) {
+          callback.onSuccess();
+        }
+        return;
+      }
+    }
+
+    PicassoDrawable.setPlaceholder(target, placeholderResId, placeholderDrawable);
+
+    Action action =
+        new ImageViewAction(picasso, target, finalData, skipMemoryCache, noFade, errorResId,
+            errorDrawable, requestKey, callback);
+
+    picasso.enqueueAndSubmit(action);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/ResourceBitmapHunter.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import java.io.IOException;
+
+import static com.squareup.picasso.Picasso.LoadedFrom.DISK;
+
+class ResourceBitmapHunter extends BitmapHunter {
+  private final Context context;
+
+  ResourceBitmapHunter(Context context, Picasso picasso, Dispatcher dispatcher, Cache cache,
+      Stats stats, Action action) {
+    super(picasso, dispatcher, cache, stats, action);
+    this.context = context;
+  }
+
+  @Override Bitmap decode(Request data) throws IOException {
+    Resources res = Utils.getResources(context, data);
+    int id = Utils.getResourceId(res, data);
+    return decodeResource(res, id, data);
+  }
+
+  @Override Picasso.LoadedFrom getLoadedFrom() {
+    return DISK;
+  }
+
+  private Bitmap decodeResource(Resources resources, int id, Request data) {
+    BitmapFactory.Options bitmapOptions = null;
+    if (data.hasSize()) {
+      bitmapOptions = new BitmapFactory.Options();
+      bitmapOptions.inJustDecodeBounds = true;
+      BitmapFactory.decodeResource(resources, id, bitmapOptions);
+      calculateInSampleSize(data.targetWidth, data.targetHeight, bitmapOptions);
+    }
+    return BitmapFactory.decodeResource(resources, id, bitmapOptions);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Stats.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.graphics.Bitmap;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+
+import static android.os.Process.THREAD_PRIORITY_BACKGROUND;
+
+class Stats {
+  private static final int CACHE_HIT = 0;
+  private static final int CACHE_MISS = 1;
+  private static final int BITMAP_DECODE_FINISHED = 2;
+  private static final int BITMAP_TRANSFORMED_FINISHED = 3;
+
+  private static final String STATS_THREAD_NAME = Utils.THREAD_PREFIX + "Stats";
+
+  final HandlerThread statsThread;
+  final Cache cache;
+  final Handler handler;
+
+  long cacheHits;
+  long cacheMisses;
+  long totalOriginalBitmapSize;
+  long totalTransformedBitmapSize;
+  long averageOriginalBitmapSize;
+  long averageTransformedBitmapSize;
+  int originalBitmapCount;
+  int transformedBitmapCount;
+
+  Stats(Cache cache) {
+    this.cache = cache;
+    this.statsThread = new HandlerThread(STATS_THREAD_NAME, THREAD_PRIORITY_BACKGROUND);
+    this.statsThread.start();
+    this.handler = new StatsHandler(statsThread.getLooper(), this);
+  }
+
+  void dispatchBitmapDecoded(Bitmap bitmap) {
+    processBitmap(bitmap, BITMAP_DECODE_FINISHED);
+  }
+
+  void dispatchBitmapTransformed(Bitmap bitmap) {
+    processBitmap(bitmap, BITMAP_TRANSFORMED_FINISHED);
+  }
+
+  void dispatchCacheHit() {
+    handler.sendEmptyMessage(CACHE_HIT);
+  }
+
+  void dispatchCacheMiss() {
+    handler.sendEmptyMessage(CACHE_MISS);
+  }
+
+  void shutdown() {
+    statsThread.quit();
+  }
+
+  void performCacheHit() {
+    cacheHits++;
+  }
+
+  void performCacheMiss() {
+    cacheMisses++;
+  }
+
+  void performBitmapDecoded(long size) {
+    originalBitmapCount++;
+    totalOriginalBitmapSize += size;
+    averageOriginalBitmapSize = getAverage(originalBitmapCount, totalOriginalBitmapSize);
+  }
+
+  void performBitmapTransformed(long size) {
+    transformedBitmapCount++;
+    totalTransformedBitmapSize += size;
+    averageTransformedBitmapSize = getAverage(originalBitmapCount, totalTransformedBitmapSize);
+  }
+
+  synchronized StatsSnapshot createSnapshot() {
+    return new StatsSnapshot(cache.maxSize(), cache.size(), cacheHits, cacheMisses,
+        totalOriginalBitmapSize, totalTransformedBitmapSize, averageOriginalBitmapSize,
+        averageTransformedBitmapSize, originalBitmapCount, transformedBitmapCount,
+        System.currentTimeMillis());
+  }
+
+  private void processBitmap(Bitmap bitmap, int what) {
+    // Never send bitmaps to the handler as they could be recycled before we process them.
+    int bitmapSize = Utils.getBitmapBytes(bitmap);
+    handler.sendMessage(handler.obtainMessage(what, bitmapSize, 0));
+  }
+
+  private static long getAverage(int count, long totalSize) {
+    return totalSize / count;
+  }
+
+  private static class StatsHandler extends Handler {
+
+    private final Stats stats;
+
+    public StatsHandler(Looper looper, Stats stats) {
+      super(looper);
+      this.stats = stats;
+    }
+
+    @Override public void handleMessage(final Message msg) {
+      switch (msg.what) {
+        case CACHE_HIT:
+          stats.performCacheHit();
+          break;
+        case CACHE_MISS:
+          stats.performCacheMiss();
+          break;
+        case BITMAP_DECODE_FINISHED:
+          stats.performBitmapDecoded(msg.arg1);
+          break;
+        case BITMAP_TRANSFORMED_FINISHED:
+          stats.performBitmapTransformed(msg.arg1);
+          break;
+        default:
+          Picasso.HANDLER.post(new Runnable() {
+            @Override public void run() {
+              throw new AssertionError("Unhandled stats message." + msg.what);
+            }
+          });
+      }
+    }
+  }
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/StatsSnapshot.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.util.Log;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+/** Represents all stats for a {@link Picasso} instance at a single point in time. */
+public class StatsSnapshot {
+  private static final String TAG = "Picasso";
+
+  public final int maxSize;
+  public final int size;
+  public final long cacheHits;
+  public final long cacheMisses;
+  public final long totalOriginalBitmapSize;
+  public final long totalTransformedBitmapSize;
+  public final long averageOriginalBitmapSize;
+  public final long averageTransformedBitmapSize;
+  public final int originalBitmapCount;
+  public final int transformedBitmapCount;
+
+  public final long timeStamp;
+
+  public StatsSnapshot(int maxSize, int size, long cacheHits, long cacheMisses,
+      long totalOriginalBitmapSize, long totalTransformedBitmapSize, long averageOriginalBitmapSize,
+      long averageTransformedBitmapSize, int originalBitmapCount, int transformedBitmapCount,
+      long timeStamp) {
+    this.maxSize = maxSize;
+    this.size = size;
+    this.cacheHits = cacheHits;
+    this.cacheMisses = cacheMisses;
+    this.totalOriginalBitmapSize = totalOriginalBitmapSize;
+    this.totalTransformedBitmapSize = totalTransformedBitmapSize;
+    this.averageOriginalBitmapSize = averageOriginalBitmapSize;
+    this.averageTransformedBitmapSize = averageTransformedBitmapSize;
+    this.originalBitmapCount = originalBitmapCount;
+    this.transformedBitmapCount = transformedBitmapCount;
+    this.timeStamp = timeStamp;
+  }
+
+  /** Prints out this {@link StatsSnapshot} into log. */
+  public void dump() {
+    StringWriter logWriter = new StringWriter();
+    dump(new PrintWriter(logWriter));
+    Log.i(TAG, logWriter.toString());
+  }
+
+  /** Prints out this {@link StatsSnapshot} with the the provided {@link PrintWriter}. */
+  public void dump(PrintWriter writer) {
+    writer.println("===============BEGIN PICASSO STATS ===============");
+    writer.println("Memory Cache Stats");
+    writer.print("  Max Cache Size: ");
+    writer.println(maxSize);
+    writer.print("  Cache Size: ");
+    writer.println(size);
+    writer.print("  Cache % Full: ");
+    writer.println((int) Math.ceil((float) size / maxSize * 100));
+    writer.print("  Cache Hits: ");
+    writer.println(cacheHits);
+    writer.print("  Cache Misses: ");
+    writer.println(cacheMisses);
+    writer.println("Bitmap Stats");
+    writer.print("  Total Bitmaps Decoded: ");
+    writer.println(originalBitmapCount);
+    writer.print("  Total Bitmap Size: ");
+    writer.println(totalOriginalBitmapSize);
+    writer.print("  Total Transformed Bitmaps: ");
+    writer.println(transformedBitmapCount);
+    writer.print("  Total Transformed Bitmap Size: ");
+    writer.println(totalTransformedBitmapSize);
+    writer.print("  Average Bitmap Size: ");
+    writer.println(averageOriginalBitmapSize);
+    writer.print("  Average Transformed Bitmap Size: ");
+    writer.println(averageTransformedBitmapSize);
+    writer.println("===============END PICASSO STATS ===============");
+    writer.flush();
+  }
+
+  @Override public String toString() {
+    return "StatsSnapshot{"
+        + "maxSize="
+        + maxSize
+        + ", size="
+        + size
+        + ", cacheHits="
+        + cacheHits
+        + ", cacheMisses="
+        + cacheMisses
+        + ", totalOriginalBitmapSize="
+        + totalOriginalBitmapSize
+        + ", totalTransformedBitmapSize="
+        + totalTransformedBitmapSize
+        + ", averageOriginalBitmapSize="
+        + averageOriginalBitmapSize
+        + ", averageTransformedBitmapSize="
+        + averageTransformedBitmapSize
+        + ", originalBitmapCount="
+        + originalBitmapCount
+        + ", transformedBitmapCount="
+        + transformedBitmapCount
+        + ", timeStamp="
+        + timeStamp
+        + '}';
+  }
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Target.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+
+import static com.squareup.picasso.Picasso.LoadedFrom;
+
+/**
+ * Represents an arbitrary listener for image loading.
+ * <p/>
+ * Objects implementing this class <strong>must</strong> have a working implementation of
+ * {@link #equals(Object)} and {@link #hashCode()} for proper storage internally. Instances of this
+ * interface will also be compared to determine if view recycling is occurring. It is recommended
+ * that you add this interface directly on to a custom view type when using in an adapter to ensure
+ * correct recycling behavior.
+ */
+public interface Target {
+  /**
+   * Callback when an image has been successfully loaded.
+   * <p/>
+   * <strong>Note:</strong> You must not recycle the bitmap.
+   */
+  void onBitmapLoaded(Bitmap bitmap, LoadedFrom from);
+
+  /** Callback indicating the image could not be successfully loaded. */
+  void onBitmapFailed(Drawable errorDrawable);
+
+  /** Callback invoked right before your request is submitted. */
+  void onPrepareLoad(Drawable placeHolderDrawable);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/TargetAction.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.graphics.Bitmap;
+
+final class TargetAction extends Action<Target> {
+
+  TargetAction(Picasso picasso, Target target, Request data, boolean skipCache, String key) {
+    super(picasso, target, data, skipCache, false, 0, null, key);
+  }
+
+  @Override void complete(Bitmap result, Picasso.LoadedFrom from) {
+    if (result == null) {
+      throw new AssertionError(
+          String.format("Attempted to complete action with no result!\n%s", this));
+    }
+    Target target = getTarget();
+    if (target != null) {
+      target.onBitmapLoaded(result, from);
+      if (result.isRecycled()) {
+        throw new IllegalStateException("Target callback must not recycle bitmap!");
+      }
+    }
+  }
+
+  @Override void error() {
+    Target target = getTarget();
+    if (target != null) {
+      target.onBitmapFailed(errorDrawable);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Transformation.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.graphics.Bitmap;
+
+/** Image transformation. */
+public interface Transformation {
+  /**
+   * Transform the source bitmap into a new bitmap. If you create a new bitmap instance, you must
+   * call {@link android.graphics.Bitmap#recycle()} on {@code source}. You may return the original
+   * if no transformation is required.
+   */
+  Bitmap transform(Bitmap source);
+
+  /**
+   * Returns a unique key for the transformation, used for caching purposes. If the transformation
+   * has parameters (e.g. size, scale factor, etc) then these should be part of the key.
+   */
+  String key();
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/UrlConnectionDownloader.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.content.Context;
+import android.net.Uri;
+import android.net.http.HttpResponseCache;
+import android.os.Build;
+import java.io.File;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+import static com.squareup.picasso.Utils.parseResponseSourceHeader;
+
+/**
+ * A {@link Downloader} which uses {@link HttpURLConnection} to download images. A disk cache of 2%
+ * of the total available space will be used (capped at 50MB) will automatically be installed in the
+ * application's cache directory, when available.
+ */
+public class UrlConnectionDownloader implements Downloader {
+  static final String RESPONSE_SOURCE = "X-Android-Response-Source";
+
+  private static final Object lock = new Object();
+  static volatile Object cache;
+
+  private final Context context;
+
+  public UrlConnectionDownloader(Context context) {
+    this.context = context.getApplicationContext();
+  }
+
+  protected HttpURLConnection openConnection(Uri path) throws IOException {
+    HttpURLConnection connection = (HttpURLConnection) new URL(path.toString()).openConnection();
+    connection.setConnectTimeout(Utils.DEFAULT_CONNECT_TIMEOUT);
+    connection.setReadTimeout(Utils.DEFAULT_READ_TIMEOUT);
+    return connection;
+  }
+
+  @Override public Response load(Uri uri, boolean localCacheOnly) throws IOException {
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+      installCacheIfNeeded(context);
+    }
+
+    HttpURLConnection connection = openConnection(uri);
+    connection.setUseCaches(true);
+    if (localCacheOnly) {
+      connection.setRequestProperty("Cache-Control", "only-if-cached,max-age=" + Integer.MAX_VALUE);
+    }
+
+    int responseCode = connection.getResponseCode();
+    if (responseCode >= 300) {
+      connection.disconnect();
+      throw new ResponseException(responseCode + " " + connection.getResponseMessage());
+    }
+
+    boolean fromCache = parseResponseSourceHeader(connection.getHeaderField(RESPONSE_SOURCE));
+
+    return new Response(connection.getInputStream(), fromCache);
+  }
+
+  private static void installCacheIfNeeded(Context context) {
+    // DCL + volatile should be safe after Java 5.
+    if (cache == null) {
+      try {
+        synchronized (lock) {
+          if (cache == null) {
+            cache = ResponseCacheIcs.install(context);
+          }
+        }
+      } catch (IOException ignored) {
+      }
+    }
+  }
+
+  private static class ResponseCacheIcs {
+    static Object install(Context context) throws IOException {
+      File cacheDir = Utils.createDefaultCacheDir(context);
+      HttpResponseCache cache = HttpResponseCache.getInstalled();
+      if (cache == null) {
+        long maxSize = Utils.calculateDiskCacheSize(cacheDir);
+        cache = HttpResponseCache.install(cacheDir, maxSize);
+      }
+      return cache;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/thirdparty/com/squareup/picasso/Utils.java
@@ -0,0 +1,304 @@
+/*
+ * Copyright (C) 2013 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.picasso;
+
+import android.annotation.TargetApi;
+import android.app.ActivityManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.os.Looper;
+import android.os.Process;
+import android.os.StatFs;
+import android.provider.Settings;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.concurrent.ThreadFactory;
+
+import static android.content.Context.ACTIVITY_SERVICE;
+import static android.content.pm.ApplicationInfo.FLAG_LARGE_HEAP;
+import static android.os.Build.VERSION.SDK_INT;
+import static android.os.Build.VERSION_CODES.HONEYCOMB;
+import static android.os.Build.VERSION_CODES.HONEYCOMB_MR1;
+import static android.os.Process.THREAD_PRIORITY_BACKGROUND;
+import static android.provider.Settings.System.AIRPLANE_MODE_ON;
+
+final class Utils {
+  static final String THREAD_PREFIX = "Picasso-";
+  static final String THREAD_IDLE_NAME = THREAD_PREFIX + "Idle";
+  static final int DEFAULT_READ_TIMEOUT = 20 * 1000; // 20s
+  static final int DEFAULT_CONNECT_TIMEOUT = 15 * 1000; // 15s
+  private static final String PICASSO_CACHE = "picasso-cache";
+  private static final int KEY_PADDING = 50; // Determined by exact science.
+  private static final int MIN_DISK_CACHE_SIZE = 5 * 1024 * 1024; // 5MB
+  private static final int MAX_DISK_CACHE_SIZE = 50 * 1024 * 1024; // 50MB
+
+  /* WebP file header
+     0                   1                   2                   3
+     0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+    |      'R'      |      'I'      |      'F'      |      'F'      |
+    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+    |                           File Size                           |
+    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+    |      'W'      |      'E'      |      'B'      |      'P'      |
+    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+  */
+  private static final int WEBP_FILE_HEADER_SIZE = 12;
+  private static final String WEBP_FILE_HEADER_RIFF = "RIFF";
+  private static final String WEBP_FILE_HEADER_WEBP = "WEBP";
+
+  private Utils() {
+    // No instances.
+  }
+
+  static int getBitmapBytes(Bitmap bitmap) {
+    int result;
+    if (SDK_INT >= HONEYCOMB_MR1) {
+      result = BitmapHoneycombMR1.getByteCount(bitmap);
+    } else {
+      result = bitmap.getRowBytes() * bitmap.getHeight();
+    }
+    if (result < 0) {
+      throw new IllegalStateException("Negative size: " + bitmap);
+    }
+    return result;
+  }
+
+  static void checkNotMain() {
+    if (Looper.getMainLooper().getThread() == Thread.currentThread()) {
+      throw new IllegalStateException("Method call should not happen from the main thread.");
+    }
+  }
+
+  static String createKey(Request data) {
+    StringBuilder builder;
+
+    if (data.uri != null) {
+      String path = data.uri.toString();
+      builder = new StringBuilder(path.length() + KEY_PADDING);
+      builder.append(path);
+    } else {
+      builder = new StringBuilder(KEY_PADDING);
+      builder.append(data.resourceId);
+    }
+    builder.append('\n');
+
+    if (data.rotationDegrees != 0) {
+      builder.append("rotation:").append(data.rotationDegrees);
+      if (data.hasRotationPivot) {
+        builder.append('@').append(data.rotationPivotX).append('x').append(data.rotationPivotY);
+      }
+      builder.append('\n');
+    }
+    if (data.targetWidth != 0) {
+      builder.append("resize:").append(data.targetWidth).append('x').append(data.targetHeight);
+      builder.append('\n');
+    }
+    if (data.centerCrop) {
+      builder.append("centerCrop\n");
+    } else if (data.centerInside) {
+      builder.append("centerInside\n");
+    }
+
+    if (data.transformations != null) {
+      //noinspection ForLoopReplaceableByForEach
+      for (int i = 0, count = data.transformations.size(); i < count; i++) {
+        builder.append(data.transformations.get(i).key());
+        builder.append('\n');
+      }
+    }
+
+    return builder.toString();
+  }
+
+  static void closeQuietly(InputStream is) {
+    if (is == null) return;
+    try {
+      is.close();
+    } catch (IOException ignored) {
+    }
+  }
+
+  /** Returns {@code true} if header indicates the response body was loaded from the disk cache. */
+  static boolean parseResponseSourceHeader(String header) {
+    if (header == null) {
+      return false;
+    }
+    String[] parts = header.split(" ", 2);
+    if ("CACHE".equals(parts[0])) {
+      return true;
+    }
+    if (parts.length == 1) {
+      return false;
+    }
+    try {
+      return "CONDITIONAL_CACHE".equals(parts[0]) && Integer.parseInt(parts[1]) == 304;
+    } catch (NumberFormatException e) {
+      return false;
+    }
+  }
+
+  static Downloader createDefaultDownloader(Context context) {
+    return new UrlConnectionDownloader(context);
+  }
+
+  static File createDefaultCacheDir(Context context) {
+    File cache = new File(context.getApplicationContext().getCacheDir(), PICASSO_CACHE);
+    if (!cache.exists()) {
+      cache.mkdirs();
+    }
+    return cache;
+  }
+
+  static long calculateDiskCacheSize(File dir) {
+    long size = MIN_DISK_CACHE_SIZE;
+
+    try {
+      StatFs statFs = new StatFs(dir.getAbsolutePath());
+      long available = ((long) statFs.getBlockCount()) * statFs.getBlockSize();
+      // Target 2% of the total space.
+      size = available / 50;
+    } catch (IllegalArgumentException ignored) {
+    }
+
+    // Bound inside min/max size for disk cache.
+    return Math.max(Math.min(size, MAX_DISK_CACHE_SIZE), MIN_DISK_CACHE_SIZE);
+  }
+
+  static int calculateMemoryCacheSize(Context context) {
+    ActivityManager am = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE);
+    boolean largeHeap = (context.getApplicationInfo().flags & FLAG_LARGE_HEAP) != 0;
+    int memoryClass = am.getMemoryClass();
+    if (largeHeap && SDK_INT >= HONEYCOMB) {
+      memoryClass = ActivityManagerHoneycomb.getLargeMemoryClass(am);
+    }
+    // Target ~15% of the available heap.
+    return 1024 * 1024 * memoryClass / 7;
+  }
+
+  static boolean isAirplaneModeOn(Context context) {
+    ContentResolver contentResolver = context.getContentResolver();
+    return Settings.System.getInt(contentResolver, AIRPLANE_MODE_ON, 0) != 0;
+  }
+
+  static boolean hasPermission(Context context, String permission) {
+    return context.checkCallingOrSelfPermission(permission) == PackageManager.PERMISSION_GRANTED;
+  }
+
+  static byte[] toByteArray(InputStream input) throws IOException {
+    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+    byte[] buffer = new byte[1024 * 4];
+    int n = 0;
+    while (-1 != (n = input.read(buffer))) {
+      byteArrayOutputStream.write(buffer, 0, n);
+    }
+    return byteArrayOutputStream.toByteArray();
+  }
+
+  static boolean isWebPFile(InputStream stream) throws IOException {
+    byte[] fileHeaderBytes = new byte[WEBP_FILE_HEADER_SIZE];
+    boolean isWebPFile = false;
+    if (stream.read(fileHeaderBytes, 0, WEBP_FILE_HEADER_SIZE) == WEBP_FILE_HEADER_SIZE) {
+      // If a file's header starts with RIFF and end with WEBP, the file is a WebP file
+      isWebPFile = WEBP_FILE_HEADER_RIFF.equals(new String(fileHeaderBytes, 0, 4, "US-ASCII"))
+          && WEBP_FILE_HEADER_WEBP.equals(new String(fileHeaderBytes, 8, 4, "US-ASCII"));
+    }
+    return isWebPFile;
+  }
+
+  static int getResourceId(Resources resources, Request data) throws FileNotFoundException {
+    if (data.resourceId != 0 || data.uri == null) {
+      return data.resourceId;
+    }
+
+    String pkg = data.uri.getAuthority();
+    if (pkg == null) throw new FileNotFoundException("No package provided: " + data.uri);
+
+    int id;
+    List<String> segments = data.uri.getPathSegments();
+    if (segments == null || segments.isEmpty()) {
+      throw new FileNotFoundException("No path segments: " + data.uri);
+    } else if (segments.size() == 1) {
+      try {
+        id = Integer.parseInt(segments.get(0));
+      } catch (NumberFormatException e) {
+        throw new FileNotFoundException("Last path segment is not a resource ID: " + data.uri);
+      }
+    } else if (segments.size() == 2) {
+      String type = segments.get(0);
+      String name = segments.get(1);
+
+      id = resources.getIdentifier(name, type, pkg);
+    } else {
+      throw new FileNotFoundException("More than two path segments: " + data.uri);
+    }
+    return id;
+  }
+
+  static Resources getResources(Context context, Request data) throws FileNotFoundException {
+    if (data.resourceId != 0 || data.uri == null) {
+      return context.getResources();
+    }
+
+    String pkg = data.uri.getAuthority();
+    if (pkg == null) throw new FileNotFoundException("No package provided: " + data.uri);
+    try {
+      PackageManager pm = context.getPackageManager();
+      return pm.getResourcesForApplication(pkg);
+    } catch (PackageManager.NameNotFoundException e) {
+      throw new FileNotFoundException("Unable to obtain resources for package: " + data.uri);
+    }
+  }
+
+  @TargetApi(HONEYCOMB)
+  private static class ActivityManagerHoneycomb {
+    static int getLargeMemoryClass(ActivityManager activityManager) {
+      return activityManager.getLargeMemoryClass();
+    }
+  }
+
+  static class PicassoThreadFactory implements ThreadFactory {
+    @SuppressWarnings("NullableProblems")
+    public Thread newThread(Runnable r) {
+      return new PicassoThread(r);
+    }
+  }
+
+  private static class PicassoThread extends Thread {
+    public PicassoThread(Runnable r) {
+      super(r);
+    }
+
+    @Override public void run() {
+      Process.setThreadPriority(THREAD_PRIORITY_BACKGROUND);
+      super.run();
+    }
+  }
+
+  @TargetApi(HONEYCOMB_MR1)
+  private static class BitmapHoneycombMR1 {
+    static int getByteCount(Bitmap bitmap) {
+      return bitmap.getByteCount();
+    }
+  }
+}
--- a/services/sync/modules/browserid_identity.js
+++ b/services/sync/modules/browserid_identity.js
@@ -30,16 +30,23 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/FxAccounts.jsm");
 
 XPCOMUtils.defineLazyGetter(this, 'fxAccountsCommon', function() {
   let ob = {};
   Cu.import("resource://gre/modules/FxAccountsCommon.js", ob);
   return ob;
 });
 
+XPCOMUtils.defineLazyGetter(this, 'log', function() {
+  let log = Log.repository.getLogger("Sync.BrowserIDManager");
+  log.addAppender(new Log.DumpAppender());
+  log.level = Log.Level[Svc.Prefs.get("log.logger.identity")] || Log.Level.Error;
+  return log;
+});
+
 const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync.ui.showCustomizationDialog";
 
 function deriveKeyBundle(kB) {
   let out = CryptoUtils.hkdf(kB, undefined,
                              "identity.mozilla.com/picl/v1/oldsync", 2*32);
   let bundle = new BulkKeyBundle();
   // [encryptionKey, hmacKey]
   bundle.keyPair = [out.slice(0, 32), out.slice(32, 64)];
@@ -55,19 +62,17 @@ function AuthenticationError(message) {
   this.message = message || "";
 }
 
 this.BrowserIDManager = function BrowserIDManager() {
   this._fxaService = fxAccounts;
   this._tokenServerClient = new TokenServerClient();
   // will be a promise that resolves when we are ready to authenticate
   this.whenReadyToAuthenticate = null;
-  this._log = Log.repository.getLogger("Sync.BrowserIDManager");
-  this._log.addAppender(new Log.DumpAppender());
-  this._log.level = Log.Level[Svc.Prefs.get("log.logger.identity")] || Log.Level.Error;
+  this._log = log;
 };
 
 this.BrowserIDManager.prototype = {
   __proto__: IdentityManager.prototype,
 
   _fxaService: null,
   _tokenServerClient: null,
   // https://docs.services.mozilla.com/token/apis.html
--- a/toolkit/components/osfile/modules/osfile_async_front.jsm
+++ b/toolkit/components/osfile/modules/osfile_async_front.jsm
@@ -898,16 +898,21 @@ File.exists = function exists(path) {
  * exists at |path|.
  * - {bool} flush - If |false| or unspecified, return immediately once the
  * write is complete. If |true|, before writing, force the operating system
  * to write its internal disk buffers to the disk. This is considerably slower
  * (not just for the application but for the whole system) but also safer:
  * if the system shuts down improperly (typically due to a kernel freeze
  * or a power failure) or if the device is disconnected before the buffer
  * is flushed, the file has more chances of not being corrupted.
+ * - {string} backupTo - If specified, backup the destination file as |backupTo|.
+ * Note that this function renames the destination file before overwriting it.
+ * If the process or the operating system freezes or crashes
+ * during the short window between these operations,
+ * the destination file will have been moved to its backup.
  *
  * @return {promise}
  * @resolves {number} The number of bytes actually written.
  */
 File.writeAtomic = function writeAtomic(path, buffer, options = {}) {
   // Copy |options| to avoid modifying the original object but preserve the
   // reference to |outExecutionDuration| option if it is passed.
   options = clone(options, ["outExecutionDuration"]);
--- a/toolkit/components/osfile/modules/osfile_shared_front.jsm
+++ b/toolkit/components/osfile/modules/osfile_shared_front.jsm
@@ -373,16 +373,21 @@ AbstractFile.read = function read(path, 
  * (not just for the application but for the whole system) but also safer:
  * if the system shuts down improperly (typically due to a kernel freeze
  * or a power failure) or if the device is disconnected before the buffer
  * is flushed, the file has more chances of not being corrupted.
  * - {string} compression - If empty or unspecified, do not compress the file.
  * If "lz4", compress the contents of the file atomically using lz4. For the
  * time being, the container format is specific to Mozilla and cannot be read
  * by means other than OS.File.read(..., { compression: "lz4"})
+ * - {string} backupTo - If specified, backup the destination file as |backupTo|.
+ * Note that this function renames the destination file before overwriting it.
+ * If the process or the operating system freezes or crashes
+ * during the short window between these operations,
+ * the destination file will have been moved to its backup.
  *
  * @return {number} The number of bytes actually written.
  */
 AbstractFile.writeAtomic =
      function writeAtomic(path, buffer, options = {}) {
 
   // Verify that path is defined and of the correct type
   if (typeof path != "string" || path == "") {
@@ -403,16 +408,23 @@ AbstractFile.writeAtomic =
     buffer = Lz4.compressFileContent(buffer, options);
     options = Object.create(options);
     options.bytes = buffer.byteLength;
   }
 
   let bytesWritten = 0;
 
   if (!options.tmpPath) {
+    if (options.backupTo) {
+      try {
+        OS.File.move(path, options.backupTo, {noCopy: true});
+      } catch (ex if ex.becauseNoSuchFile) {
+        // The file doesn't exist, nothing to backup.
+      }
+    }
     // Just write, without any renaming trick
     let dest = OS.File.open(path, {write: true, truncate: true});
     try {
       bytesWritten = dest.write(buffer, options);
       if (options.flush) {
         dest.flush();
       }
     } finally {
@@ -429,16 +441,24 @@ AbstractFile.writeAtomic =
     }
   } catch (x) {
     OS.File.remove(options.tmpPath);
     throw x;
   } finally {
     tmpFile.close();
   }
 
+  if (options.backupTo) {
+    try {
+      OS.File.move(path, options.backupTo, {noCopy: true});
+    } catch (ex if ex.becauseNoSuchFile) {
+      // The file doesn't exist, nothing to backup.
+    }
+  }
+
   OS.File.move(options.tmpPath, path, {noCopy: true});
   return bytesWritten;
 };
 
 /**
   * Remove an existing directory and its contents.
   *
   * @param {string} path The name of the directory.
new file mode 100644
--- /dev/null
+++ b/toolkit/components/osfile/tests/xpcshell/test_osfile_writeAtomic_backupTo_option.js
@@ -0,0 +1,143 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let {OS: {File, Path, Constants}} = Components.utils.import("resource://gre/modules/osfile.jsm", {});
+Components.utils.import("resource://gre/modules/Task.jsm");
+
+/**
+ * Remove all temporary files and back up files, including
+ * test_backupTo_option_with_tmpPath.tmp
+ * test_backupTo_option_with_tmpPath.tmp.backup
+ * test_backupTo_option_without_tmpPath.tmp
+ * test_backupTo_option_without_tmpPath.tmp.backup
+ * test_non_backupTo_option.tmp
+ * test_non_backupTo_option.tmp.backup
+ * test_backupTo_option_without_destination_file.tmp
+ * test_backupTo_option_without_destination_file.tmp.backup
+ * test_backupTo_option_with_backup_file.tmp
+ * test_backupTo_option_with_backup_file.tmp.backup
+ */
+function clearFiles() {
+  return Task.spawn(function () {
+    let files = ["test_backupTo_option_with_tmpPath.tmp",
+                  "test_backupTo_option_without_tmpPath.tmp",
+                  "test_non_backupTo_option.tmp",
+                  "test_backupTo_option_without_destination_file.tmp",
+                  "test_backupTo_option_with_backup_file.tmp"];
+    for (let file of files) {
+      let path = Path.join(Constants.Path.tmpDir, file);
+      yield File.remove(path);
+      yield File.remove(path + ".backup");
+    }
+  });
+}
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function* init() {
+  yield clearFiles();
+});
+
+/**
+ * test when
+ * |backupTo| specified
+ * |tmpPath| specified
+ * destination file exists
+ * @result destination file will be backed up
+ */
+add_task(function* test_backupTo_option_with_tmpPath() {
+  let DEFAULT_CONTENTS = "default contents" + Math.random();
+  let WRITE_CONTENTS = "abc" + Math.random();
+  let path = Path.join(Constants.Path.tmpDir,
+                       "test_backupTo_option_with_tmpPath.tmp");
+  yield File.writeAtomic(path, DEFAULT_CONTENTS);
+  yield File.writeAtomic(path, WRITE_CONTENTS, { tmpPath: path + ".tmp",
+                                        backupTo: path + ".backup" });
+  do_check_true((yield File.exists(path + ".backup")));
+  let contents = yield File.read(path + ".backup");
+  do_check_eq(DEFAULT_CONTENTS, (new TextDecoder()).decode(contents));
+});
+
+/**
+ * test when
+ * |backupTo| specified
+ * |tmpPath| not specified
+ * destination file exists
+ * @result destination file will be backed up
+ */
+add_task(function* test_backupTo_option_without_tmpPath() {
+  let DEFAULT_CONTENTS = "default contents" + Math.random();
+  let WRITE_CONTENTS = "abc" + Math.random();
+  let path = Path.join(Constants.Path.tmpDir,
+                       "test_backupTo_option_without_tmpPath.tmp");
+  yield File.writeAtomic(path, DEFAULT_CONTENTS);
+  yield File.writeAtomic(path, WRITE_CONTENTS, { backupTo: path + ".backup" });
+  do_check_true((yield File.exists(path + ".backup")));
+  let contents = yield File.read(path + ".backup");
+  do_check_eq(DEFAULT_CONTENTS, (new TextDecoder()).decode(contents));
+});
+
+/**
+ * test when
+ * |backupTo| not specified
+ * |tmpPath| not specified
+ * destination file exists
+ * @result destination file will not be backed up
+ */
+add_task(function* test_non_backupTo_option() {
+  let DEFAULT_CONTENTS = "default contents" + Math.random();
+  let WRITE_CONTENTS = "abc" + Math.random();
+  let path = Path.join(Constants.Path.tmpDir,
+                       "test_non_backupTo_option.tmp");
+  yield File.writeAtomic(path, DEFAULT_CONTENTS);
+  yield File.writeAtomic(path, WRITE_CONTENTS);
+  do_check_false((yield File.exists(path + ".backup")));
+});
+
+/**
+ * test when
+ * |backupTo| specified
+ * |tmpPath| not specified
+ * destination file not exists
+ * @result no back up file exists
+ */
+add_task(function* test_backupTo_option_without_destination_file() {
+  let DEFAULT_CONTENTS = "default contents" + Math.random();
+  let WRITE_CONTENTS = "abc" + Math.random();
+  let path = Path.join(Constants.Path.tmpDir,
+                       "test_backupTo_option_without_destination_file.tmp");
+  yield File.remove(path);
+  yield File.writeAtomic(path, WRITE_CONTENTS, { backupTo: path + ".backup" });
+  do_check_false((yield File.exists(path + ".backup")));
+});
+
+/**
+ * test when
+ * |backupTo| specified
+ * |tmpPath| not specified
+ * backup file exists
+ * destination file exists
+ * @result destination file will be backed up
+ */
+add_task(function* test_backupTo_option_with_backup_file() {
+  let DEFAULT_CONTENTS = "default contents" + Math.random();
+  let WRITE_CONTENTS = "abc" + Math.random();
+  let path = Path.join(Constants.Path.tmpDir,
+                       "test_backupTo_option_with_backup_file.tmp");
+  yield File.writeAtomic(path, DEFAULT_CONTENTS);
+
+  yield File.writeAtomic(path + ".backup", new Uint8Array(1000));
+
+  yield File.writeAtomic(path, WRITE_CONTENTS, { backupTo: path + ".backup" });
+  do_check_true((yield File.exists(path + ".backup")));
+  let contents = yield File.read(path + ".backup");
+  do_check_eq(DEFAULT_CONTENTS, (new TextDecoder()).decode(contents));
+});
+
+add_task(function* cleanup() {
+  yield clearFiles();
+});
--- a/toolkit/components/osfile/tests/xpcshell/xpcshell.ini
+++ b/toolkit/components/osfile/tests/xpcshell/xpcshell.ini
@@ -20,8 +20,9 @@ tail =
 [test_removeDir.js]
 [test_reset.js]
 [test_shutdown.js]
 [test_unique.js]
 [test_open.js]
 [test_telemetry.js]
 [test_duration.js]
 [test_compression.js]
+[test_osfile_writeAtomic_backupTo_option.js]
--- a/toolkit/devtools/Loader.jsm
+++ b/toolkit/devtools/Loader.jsm
@@ -63,18 +63,18 @@ BuiltinProvider.prototype = {
         "devtools/app-actor-front": "resource://gre/modules/devtools/app-actor-front.js",
         "devtools/styleinspector/css-logic": "resource://gre/modules/devtools/styleinspector/css-logic",
         "devtools/css-color": "resource://gre/modules/devtools/css-color",
         "devtools/output-parser": "resource://gre/modules/devtools/output-parser",
         "devtools/touch-events": "resource://gre/modules/devtools/touch-events",
         "devtools/client": "resource://gre/modules/devtools/client",
         "devtools/pretty-fast": "resource://gre/modules/devtools/pretty-fast.js",
 
-        "acorn": "resource://gre/modules/devtools/acorn.js",
-        "acorn_loose": "resource://gre/modules/devtools/acorn_loose.js",
+        "acorn": "resource://gre/modules/devtools/acorn",
+        "acorn/util/walk": "resource://gre/modules/devtools/acorn/walk.js",
 
         // Allow access to xpcshell test items from the loader.
         "xpcshell-test": "resource://test"
       },
       globals: loaderGlobals,
       invisibleToDebugger: this.invisibleToDebugger
     });
 
@@ -110,17 +110,17 @@ SrcdirProvider.prototype = {
     let appActorURI = this.fileURI(OS.Path.join(toolkitDir, "apps", "app-actor-front.js"));
     let cssLogicURI = this.fileURI(OS.Path.join(toolkitDir, "styleinspector", "css-logic"));
     let cssColorURI = this.fileURI(OS.Path.join(toolkitDir, "css-color"));
     let outputParserURI = this.fileURI(OS.Path.join(toolkitDir, "output-parser"));
     let touchEventsURI = this.fileURI(OS.Path.join(toolkitDir, "touch-events"));
     let clientURI = this.fileURI(OS.Path.join(toolkitDir, "client"));
     let prettyFastURI = this.fileURI(OS.Path.join(toolkitDir), "pretty-fast.js");
     let acornURI = this.fileURI(OS.Path.join(toolkitDir, "acorn"));
-    let acornLoosseURI = this.fileURI(OS.Path.join(toolkitDir, "acorn_loose.js"));
+    let acornWalkURI = OS.Path.join(acornURI, "walk.js");
     this.loader = new loader.Loader({
       modules: {
         "toolkit/loader": loader,
         "source-map": SourceMap,
       },
       paths: {
         "": "resource://gre/modules/commonjs/",
         "main": mainURI,
@@ -131,17 +131,17 @@ SrcdirProvider.prototype = {
         "devtools/styleinspector/css-logic": cssLogicURI,
         "devtools/css-color": cssColorURI,
         "devtools/output-parser": outputParserURI,
         "devtools/touch-events": touchEventsURI,
         "devtools/client": clientURI,
         "devtools/pretty-fast": prettyFastURI,
 
         "acorn": acornURI,
-        "acorn_loose": acornLoosseURI
+        "acorn/util/walk": acornWalkURI
       },
       globals: loaderGlobals,
       invisibleToDebugger: this.invisibleToDebugger
     });
 
     return this._writeManifest(devtoolsDir).then(null, Cu.reportError);
   },
 
--- a/toolkit/devtools/acorn/UPGRADING.md
+++ b/toolkit/devtools/acorn/UPGRADING.md
@@ -17,8 +17,12 @@ 2. Make sure that all tests pass:
 
 3. Copy acorn.js to our tree:
 
        $ cp acorn.js /path/to/mozilla-central/toolkit/devtools/acorn/acorn.js
 
 4. Copy acorn_loose.js to our tree:
 
        $ cp acorn_loose.js /path/to/mozilla-central/toolkit/devtools/acorn/acorn_loose.js
+
+5. Copy util/walk.js to our tree:
+
+       $ cp util/walk.js /path/to/mozilla-central/toolkit/devtools/acorn/walk.js
--- a/toolkit/devtools/acorn/moz.build
+++ b/toolkit/devtools/acorn/moz.build
@@ -1,14 +1,15 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
 XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']
 
-JS_MODULES_PATH = 'modules/devtools'
+JS_MODULES_PATH = 'modules/devtools/acorn'
 
 EXTRA_JS_MODULES += [
     'acorn.js',
     'acorn_loose.js',
+    'walk.js',
 ]
--- a/toolkit/devtools/acorn/tests/unit/test_import_acorn.js
+++ b/toolkit/devtools/acorn/tests/unit/test_import_acorn.js
@@ -1,15 +1,18 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /**
  * Test that we can require acorn.
  */
 
 function run_test() {
-  const acorn = require("acorn");
-  const acorn_loose = require("acorn_loose");
+  const acorn = require("acorn/acorn");
+  const acorn_loose = require("acorn/acorn_loose");
+  const walk = require("acorn/util/walk");
   do_check_true(isObject(acorn));
   do_check_true(isObject(acorn_loose));
+  do_check_true(isObject(walk));
   do_check_eq(typeof acorn.parse, "function");
   do_check_eq(typeof acorn_loose.parse_dammit, "function");
+  do_check_eq(typeof walk.simple, "function");
 }
--- a/toolkit/devtools/acorn/tests/unit/test_lenient_parser.js
+++ b/toolkit/devtools/acorn/tests/unit/test_lenient_parser.js
@@ -1,16 +1,16 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /**
  * Test that acorn's lenient parser gives something usable.
  */
 
-const acorn_loose = require("acorn_loose");
+const acorn_loose = require("acorn/acorn_loose");
 
 function run_test() {
   let actualAST = acorn_loose.parse_dammit("let x = 10");
 
   do_print("Actual AST:");
   do_print(JSON.stringify(actualAST, null, 2));
   do_print("Expected AST:");
   do_print(JSON.stringify(expectedAST, null, 2));
--- a/toolkit/devtools/acorn/tests/unit/test_same_ast.js
+++ b/toolkit/devtools/acorn/tests/unit/test_same_ast.js
@@ -1,16 +1,16 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /**
  * Test that Reflect and acorn create the same AST for ES5.
  */
 
-const acorn = require("acorn");
+const acorn = require("acorn/acorn");
 Cu.import("resource://gre/modules/reflect.jsm");
 
 const testCode = "" + function main () {
   function makeAcc(n) {
     return function () {
       return ++n;
     };
   }
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/acorn/walk.js
@@ -0,0 +1,313 @@
+// AST walker module for Mozilla Parser API compatible trees
+
+(function(mod) {
+  if (typeof exports == "object" && typeof module == "object") return mod(exports); // CommonJS
+  if (typeof define == "function" && define.amd) return define(["exports"], mod); // AMD
+  mod((this.acorn || (this.acorn = {})).walk = {}); // Plain browser env
+})(function(exports) {
+  "use strict";
+
+  // A simple walk is one where you simply specify callbacks to be
+  // called on specific nodes. The last two arguments are optional. A
+  // simple use would be
+  //
+  //     walk.simple(myTree, {
+  //         Expression: function(node) { ... }
+  //     });
+  //
+  // to do something with all expressions. All Parser API node types
+  // can be used to identify node types, as well as Expression,
+  // Statement, and ScopeBody, which denote categories of nodes.
+  //
+  // The base argument can be used to pass a custom (recursive)
+  // walker, and state can be used to give this walked an initial
+  // state.
+  exports.simple = function(node, visitors, base, state) {
+    if (!base) base = exports.base;
+    function c(node, st, override) {
+      var type = override || node.type, found = visitors[type];
+      base[type](node, st, c);
+      if (found) found(node, st);
+    }
+    c(node, state);
+  };
+
+  // A recursive walk is one where your functions override the default
+  // walkers. They can modify and replace the state parameter that's
+  // threaded through the walk, and can opt how and whether to walk
+  // their child nodes (by calling their third argument on these
+  // nodes).
+  exports.recursive = function(node, state, funcs, base) {
+    var visitor = funcs ? exports.make(funcs, base) : base;
+    function c(node, st, override) {
+      visitor[override || node.type](node, st, c);
+    }
+    c(node, state);
+  };
+
+  function makeTest(test) {
+    if (typeof test == "string")
+      return function(type) { return type == test; };
+    else if (!test)
+      return function() { return true; };
+    else
+      return test;
+  }
+
+  function Found(node, state) { this.node = node; this.state = state; }
+
+  // Find a node with a given start, end, and type (all are optional,
+  // null can be used as wildcard). Returns a {node, state} object, or
+  // undefined when it doesn't find a matching node.
+  exports.findNodeAt = function(node, start, end, test, base, state) {
+    test = makeTest(test);
+    try {
+      if (!base) base = exports.base;
+      var c = function(node, st, override) {
+        var type = override || node.type;
+        if ((start == null || node.start <= start) &&
+            (end == null || node.end >= end))
+          base[type](node, st, c);
+        if (test(type, node) &&
+            (start == null || node.start == start) &&
+            (end == null || node.end == end))
+          throw new Found(node, st);
+      };
+      c(node, state);
+    } catch (e) {
+      if (e instanceof Found) return e;
+      throw e;
+    }
+  };
+
+  // Find the innermost node of a given type that contains the given
+  // position. Interface similar to findNodeAt.
+  exports.findNodeAround = function(node, pos, test, base, state) {
+    test = makeTest(test);
+    try {
+      if (!base) base = exports.base;
+      var c = function(node, st, override) {
+        var type = override || node.type;
+        if (node.start > pos || node.end < pos) return;
+        base[type](node, st, c);
+        if (test(type, node)) throw new Found(node, st);
+      };
+      c(node, state);
+    } catch (e) {
+      if (e instanceof Found) return e;
+      throw e;
+    }
+  };
+
+  // Find the outermost matching node after a given position.
+  exports.findNodeAfter = function(node, pos, test, base, state) {
+    test = makeTest(test);
+    try {
+      if (!base) base = exports.base;
+      var c = function(node, st, override) {
+        if (node.end < pos) return;
+        var type = override || node.type;
+        if (node.start >= pos && test(type, node)) throw new Found(node, st);
+        base[type](node, st, c);
+      };
+      c(node, state);
+    } catch (e) {
+      if (e instanceof Found) return e;
+      throw e;
+    }
+  };
+
+  // Find the outermost matching node before a given position.
+  exports.findNodeBefore = function(node, pos, test, base, state) {
+    test = makeTest(test);
+    if (!base) base = exports.base;
+    var max;
+    var c = function(node, st, override) {
+      if (node.start > pos) return;
+      var type = override || node.type;
+      if (node.end <= pos && (!max || max.node.end < node.end) && test(type, node))
+        max = new Found(node, st);
+      base[type](node, st, c);
+    };
+    c(node, state);
+    return max;
+  };
+
+  // Used to create a custom walker. Will fill in all missing node
+  // type properties with the defaults.
+  exports.make = function(funcs, base) {
+    if (!base) base = exports.base;
+    var visitor = {};
+    for (var type in base) visitor[type] = base[type];
+    for (var type in funcs) visitor[type] = funcs[type];
+    return visitor;
+  };
+
+  function skipThrough(node, st, c) { c(node, st); }
+  function ignore(_node, _st, _c) {}
+
+  // Node walkers.
+
+  var base = exports.base = {};
+  base.Program = base.BlockStatement = function(node, st, c) {
+    for (var i = 0; i < node.body.length; ++i)
+      c(node.body[i], st, "Statement");
+  };
+  base.Statement = skipThrough;
+  base.EmptyStatement = ignore;
+  base.ExpressionStatement = function(node, st, c) {
+    c(node.expression, st, "Expression");
+  };
+  base.IfStatement = function(node, st, c) {
+    c(node.test, st, "Expression");
+    c(node.consequent, st, "Statement");
+    if (node.alternate) c(node.alternate, st, "Statement");
+  };
+  base.LabeledStatement = function(node, st, c) {
+    c(node.body, st, "Statement");
+  };
+  base.BreakStatement = base.ContinueStatement = ignore;
+  base.WithStatement = function(node, st, c) {
+    c(node.object, st, "Expression");
+    c(node.body, st, "Statement");
+  };
+  base.SwitchStatement = function(node, st, c) {
+    c(node.discriminant, st, "Expression");
+    for (var i = 0; i < node.cases.length; ++i) {
+      var cs = node.cases[i];
+      if (cs.test) c(cs.test, st, "Expression");
+      for (var j = 0; j < cs.consequent.length; ++j)
+        c(cs.consequent[j], st, "Statement");
+    }
+  };
+  base.ReturnStatement = function(node, st, c) {
+    if (node.argument) c(node.argument, st, "Expression");
+  };
+  base.ThrowStatement = function(node, st, c) {
+    c(node.argument, st, "Expression");
+  };
+  base.TryStatement = function(node, st, c) {
+    c(node.block, st, "Statement");
+    if (node.handler) c(node.handler.body, st, "ScopeBody");
+    if (node.finalizer) c(node.finalizer, st, "Statement");
+  };
+  base.WhileStatement = function(node, st, c) {
+    c(node.test, st, "Expression");
+    c(node.body, st, "Statement");
+  };
+  base.DoWhileStatement = base.WhileStatement;
+  base.ForStatement = function(node, st, c) {
+    if (node.init) c(node.init, st, "ForInit");
+    if (node.test) c(node.test, st, "Expression");
+    if (node.update) c(node.update, st, "Expression");
+    c(node.body, st, "Statement");
+  };
+  base.ForInStatement = function(node, st, c) {
+    c(node.left, st, "ForInit");
+    c(node.right, st, "Expression");
+    c(node.body, st, "Statement");
+  };
+  base.ForInit = function(node, st, c) {
+    if (node.type == "VariableDeclaration") c(node, st);
+    else c(node, st, "Expression");
+  };
+  base.DebuggerStatement = ignore;
+
+  base.FunctionDeclaration = function(node, st, c) {
+    c(node, st, "Function");
+  };
+  base.VariableDeclaration = function(node, st, c) {
+    for (var i = 0; i < node.declarations.length; ++i) {
+      var decl = node.declarations[i];
+      if (decl.init) c(decl.init, st, "Expression");
+    }
+  };
+
+  base.Function = function(node, st, c) {
+    c(node.body, st, "ScopeBody");
+  };
+  base.ScopeBody = function(node, st, c) {
+    c(node, st, "Statement");
+  };
+
+  base.Expression = skipThrough;
+  base.ThisExpression = ignore;
+  base.ArrayExpression = function(node, st, c) {
+    for (var i = 0; i < node.elements.length; ++i) {
+      var elt = node.elements[i];
+      if (elt) c(elt, st, "Expression");
+    }
+  };
+  base.ObjectExpression = function(node, st, c) {
+    for (var i = 0; i < node.properties.length; ++i)
+      c(node.properties[i].value, st, "Expression");
+  };
+  base.FunctionExpression = base.FunctionDeclaration;
+  base.SequenceExpression = function(node, st, c) {
+    for (var i = 0; i < node.expressions.length; ++i)
+      c(node.expressions[i], st, "Expression");
+  };
+  base.UnaryExpression = base.UpdateExpression = function(node, st, c) {
+    c(node.argument, st, "Expression");
+  };
+  base.BinaryExpression = base.AssignmentExpression = base.LogicalExpression = function(node, st, c) {
+    c(node.left, st, "Expression");
+    c(node.right, st, "Expression");
+  };
+  base.ConditionalExpression = function(node, st, c) {
+    c(node.test, st, "Expression");
+    c(node.consequent, st, "Expression");
+    c(node.alternate, st, "Expression");
+  };
+  base.NewExpression = base.CallExpression = function(node, st, c) {
+    c(node.callee, st, "Expression");
+    if (node.arguments) for (var i = 0; i < node.arguments.length; ++i)
+      c(node.arguments[i], st, "Expression");
+  };
+  base.MemberExpression = function(node, st, c) {
+    c(node.object, st, "Expression");
+    if (node.computed) c(node.property, st, "Expression");
+  };
+  base.Identifier = base.Literal = ignore;
+
+  // A custom walker that keeps track of the scope chain and the
+  // variables defined in it.
+  function makeScope(prev, isCatch) {
+    return {vars: Object.create(null), prev: prev, isCatch: isCatch};
+  }
+  function normalScope(scope) {
+    while (scope.isCatch) scope = scope.prev;
+    return scope;
+  }
+  exports.scopeVisitor = exports.make({
+    Function: function(node, scope, c) {
+      var inner = makeScope(scope);
+      for (var i = 0; i < node.params.length; ++i)
+        inner.vars[node.params[i].name] = {type: "argument", node: node.params[i]};
+      if (node.id) {
+        var decl = node.type == "FunctionDeclaration";
+        (decl ? normalScope(scope) : inner).vars[node.id.name] =
+          {type: decl ? "function" : "function name", node: node.id};
+      }
+      c(node.body, inner, "ScopeBody");
+    },
+    TryStatement: function(node, scope, c) {
+      c(node.block, scope, "Statement");
+      if (node.handler) {
+        var inner = makeScope(scope, true);
+        inner.vars[node.handler.param.name] = {type: "catch clause", node: node.handler.param};
+        c(node.handler.body, inner, "ScopeBody");
+      }
+      if (node.finalizer) c(node.finalizer, scope, "Statement");
+    },
+    VariableDeclaration: function(node, scope, c) {
+      var target = normalScope(scope);
+      for (var i = 0; i < node.declarations.length; ++i) {
+        var decl = node.declarations[i];
+        target.vars[decl.id.name] = {type: "var", node: decl.id};
+        if (decl.init) c(decl.init, scope, "Expression");
+      }
+    }
+  });
+
+});
--- a/toolkit/devtools/pretty-fast/pretty-fast.js
+++ b/toolkit/devtools/pretty-fast/pretty-fast.js
@@ -9,17 +9,17 @@
   } else if (typeof exports === 'object') {
     module.exports = factory();
   } else {
     root.prettyFast = factory();
   }
 }(this, function () {
   "use strict";
 
-  var acorn = this.acorn || require("acorn");
+  var acorn = this.acorn || require("acorn/acorn");
   var sourceMap = this.sourceMap || require("source-map");
   var SourceNode = sourceMap.SourceNode;
 
   // If any of these tokens are seen before a "[" token, we know that "[" token
   // is the start of an array literal, rather than a property access.
   //
   // The only exception is "}", which would need to be disambiguated by
   // parsing. The majority of the time, an open bracket following a closing
--- a/toolkit/devtools/pretty-fast/tests/unit/head_pretty-fast.js
+++ b/toolkit/devtools/pretty-fast/tests/unit/head_pretty-fast.js
@@ -3,17 +3,17 @@ const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 const { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 const { require } = devtools;
 
 this.sourceMap = require("source-map");
-this.acorn = require("acorn");
+this.acorn = require("acorn/acorn");
 this.prettyFast = require("devtools/pretty-fast");
 const { console } = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
 
 // Register a console listener, so console messages don't just disappear
 // into the ether.
 let errorCount = 0;
 let listener = {
   observe: function (aMessage) {
--- a/toolkit/devtools/server/actors/pretty-print-worker.js
+++ b/toolkit/devtools/server/actors/pretty-print-worker.js
@@ -22,17 +22,17 @@
  * printed source text, and `mappings` is an array or source mappings from the
  * pretty printed code back to the ugly source text.
  *
  * In the case of an error, the worker responds with a message of the form:
  *
  *     { id, error }
  */
 
-importScripts("resource://gre/modules/devtools/acorn.js");
+importScripts("resource://gre/modules/devtools/acorn/acorn.js");
 importScripts("resource://gre/modules/devtools/source-map.js");
 importScripts("resource://gre/modules/devtools/pretty-fast.js");
 
 self.onmessage = (event) => {
   const { data: { id, url, indent, source } } = event;
   try {
     const prettified = prettyFast(source, {
       url: url,
--- a/toolkit/devtools/server/actors/webapps.js
+++ b/toolkit/devtools/server/actors/webapps.js
@@ -559,23 +559,25 @@ WebappsActor.prototype = {
     }
 
     let reg = DOMApplicationRegistry;
     let app = reg.getAppByManifestURL(manifestURL);
     if (!app) {
       return { error: "appNotFound" };
     }
 
-    if (this._isAppAllowedForManifest(app.manifestURL)) {
+    return this._isAppAllowedForURL(app.manifestURL).then(allowed => {
+      if (!allowed) {
+        return { error: "forbidden" };
+      }
       return reg.getManifestFor(manifestURL).then(function (manifest) {
         app.manifest = manifest;
-        return {app: app};
+        return { app: app };
       });
-    }
-    return { error: "forbidden" };
+    });
   },
 
   _areCertifiedAppsAllowed: function wa__areCertifiedAppsAllowed() {
     let pref = "devtools.debugger.forbid-certified-apps";
     return !Services.prefs.getBoolPref(pref);
   },
 
   _isAppAllowedForManifest: function wa__isAppAllowedForManifest(aManifest) {