Merge m-c to inbound. a=merge
authorRyan VanderMeulen <ryanvm@gmail.com>
Wed, 25 Mar 2015 13:43:32 -0400
changeset 264593 dfd22e7a35c8cc0f2fbb73738f0cbcf1c23fcfe6
parent 264592 bf5843b1b295e0293bb582916db4182b3bc21200 (current diff)
parent 264478 5330c6f461a41f93f11a22a49d29774bd6dd36b2 (diff)
child 264594 de70aff4ed8b22d7c142f2e9f6ed29ef0e9fade1
push id4718
push userraliiev@mozilla.com
push dateMon, 11 May 2015 18:39:53 +0000
treeherdermozilla-beta@c20c4ef55f08 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone39.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge m-c to inbound. a=merge
mobile/android/base/resources/color/overlay_btn_text.xml
mobile/android/base/resources/layout/overlay_share_send_tab_button.xml
--- a/addon-sdk/source/examples/debug-client/data/client.js
+++ b/addon-sdk/source/examples/debug-client/data/client.js
@@ -197,17 +197,17 @@ var Connection = Class({
   addPool: function(pool) {
     this.pools.add(pool);
   },
   removePool: function(pool) {
     this.pools.delete(pool);
   },
   poolFor: function(id) {
     for (let pool of this.pools.values()) {
-      if pool.has(id)
+      if (pool.has(id))
         return pool;
     }
   },
   get: function(id) {
     var pool = this.poolFor(id);
     return pool && pool.get(id);
   },
   disconnect: function() {
@@ -792,17 +792,17 @@ var Tab = Client.from({
     "canvasActor": "canvas",
     "webglActor": "webgl",
     "webaudioActor": "webaudio",
     "styleSheetsActor": "stylesheets",
     "styleEditorActor": "styleeditor",
     "storageActor": "storage",
     "gcliActor": "gcli",
     "memoryActor": "memory",
-    "eventLoopLag": "eventLoopLag"
+    "eventLoopLag": "eventLoopLag",
 
     "trace": "trace", // missing
   }
 });
 
 var tablist = Client.from({
   "category": "dict",
   "typeName": "tablist",
--- a/b2g/config/dolphin/sources.xml
+++ b/b2g/config/dolphin/sources.xml
@@ -10,17 +10,17 @@
   <!--original fetch url was git://codeaurora.org/-->
   <remote fetch="https://git.mozilla.org/external/caf" name="caf"/>
   <!--original fetch url was https://git.mozilla.org/releases-->
   <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/>
   <!-- B2G specific things. -->
   <project name="platform_build" path="build" remote="b2g" revision="ef937d1aca7c4cf89ecb5cc43ae8c21c2000a9db">
     <copyfile dest="Makefile" src="core/root.mk"/>
   </project>
-  <project name="gaia" path="gaia" remote="mozillaorg" revision="aebfbd998041e960cea0468533c0b5041b504850"/>
+  <project name="gaia" path="gaia" remote="mozillaorg" revision="508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2"/>
   <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/>
   <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="86cd7486d8e50eaac8ef6fe2f51f09d25194577b"/>
   <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/>
   <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/>
   <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/>
   <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/>
   <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/>
   <project name="apitrace" path="external/apitrace" remote="apitrace" revision="b685e3aab4fde7624d78993877a8f7910f2a5f06"/>
--- a/b2g/config/emulator-ics/sources.xml
+++ b/b2g/config/emulator-ics/sources.xml
@@ -14,17 +14,17 @@
   <!--original fetch url was git://github.com/apitrace/-->
   <remote fetch="https://git.mozilla.org/external/apitrace" name="apitrace"/>
   <default remote="caf" revision="refs/tags/android-4.0.4_r2.1" sync-j="4"/>
   <!-- Gonk specific things and forks -->
   <project name="platform_build" path="build" remote="b2g" revision="173b3104bfcbd23fc9dccd4b0035fc49aae3d444">
     <copyfile dest="Makefile" src="core/root.mk"/>
   </project>
   <project name="fake-dalvik" path="dalvik" remote="b2g" revision="ca1f327d5acc198bb4be62fa51db2c039032c9ce"/>
-  <project name="gaia.git" path="gaia" remote="mozillaorg" revision="aebfbd998041e960cea0468533c0b5041b504850"/>
+  <project name="gaia.git" path="gaia" remote="mozillaorg" revision="508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2"/>
   <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="86cd7486d8e50eaac8ef6fe2f51f09d25194577b"/>
   <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/>
   <project name="platform_hardware_ril" path="hardware/ril" remote="b2g" revision="93f9ba577f68d772093987c2f1c0a4ae293e1802"/>
   <project name="platform_external_qemu" path="external/qemu" remote="b2g" revision="527d1c939ee57deb7192166e56e2a3fffa8cb087"/>
   <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/>
   <project name="apitrace" path="external/apitrace" remote="apitrace" revision="34ea6163f9f0e0122fb0bb03607eccdca31ced7a"/>
   <!-- Stock Android things -->
   <project name="platform/abi/cpp" path="abi/cpp" revision="dd924f92906085b831bf1cbbc7484d3c043d613c"/>
--- a/b2g/config/emulator-jb/sources.xml
+++ b/b2g/config/emulator-jb/sources.xml
@@ -12,17 +12,17 @@
   <!--original fetch url was https://git.mozilla.org/releases-->
   <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/>
   <!-- B2G specific things. -->
   <project name="platform_build" path="build" remote="b2g" revision="4efd19d199ae52656604f794c5a77518400220fd">
     <copyfile dest="Makefile" src="core/root.mk"/>
   </project>
   <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/>
   <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/>
-  <project name="gaia" path="gaia" remote="mozillaorg" revision="aebfbd998041e960cea0468533c0b5041b504850"/>
+  <project name="gaia" path="gaia" remote="mozillaorg" revision="508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2"/>
   <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="86cd7486d8e50eaac8ef6fe2f51f09d25194577b"/>
   <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/>
   <project name="apitrace" path="external/apitrace" remote="apitrace" revision="b685e3aab4fde7624d78993877a8f7910f2a5f06"/>
   <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/>
   <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/>
   <!-- Stock Android things -->
   <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.1" path="prebuilts/clang/linux-x86/3.1" revision="5c45f43419d5582949284eee9cef0c43d866e03b"/>
   <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.2" path="prebuilts/clang/linux-x86/3.2" revision="3748b4168e7bd8d46457d4b6786003bc6a5223ce"/>
--- a/b2g/config/emulator-kk/sources.xml
+++ b/b2g/config/emulator-kk/sources.xml
@@ -10,17 +10,17 @@
   <!--original fetch url was git://codeaurora.org/-->
   <remote fetch="https://git.mozilla.org/external/caf" name="caf"/>
   <!--original fetch url was https://git.mozilla.org/releases-->
   <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/>
   <!-- B2G specific things. -->
   <project name="platform_build" path="build" remote="b2g" revision="ef937d1aca7c4cf89ecb5cc43ae8c21c2000a9db">
     <copyfile dest="Makefile" src="core/root.mk"/>
   </project>
-  <project name="gaia" path="gaia" remote="mozillaorg" revision="aebfbd998041e960cea0468533c0b5041b504850"/>
+  <project name="gaia" path="gaia" remote="mozillaorg" revision="508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2"/>
   <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/>
   <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="86cd7486d8e50eaac8ef6fe2f51f09d25194577b"/>
   <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/>
   <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/>
   <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/>
   <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/>
   <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/>
   <project name="apitrace" path="external/apitrace" remote="apitrace" revision="b685e3aab4fde7624d78993877a8f7910f2a5f06"/>
--- a/b2g/config/emulator-l/sources.xml
+++ b/b2g/config/emulator-l/sources.xml
@@ -10,17 +10,17 @@
   <!--original fetch url was git://codeaurora.org/-->
   <remote fetch="https://git.mozilla.org/external/caf" name="caf"/>
   <!--original fetch url was https://git.mozilla.org/releases-->
   <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/>
   <!-- B2G specific things. -->
   <project name="platform_build" path="build" remote="b2g" revision="52775e03a2d8532429dff579cb2cd56718e488c3">
     <copyfile dest="Makefile" src="core/root.mk"/>
   </project>
-  <project name="gaia" path="gaia" remote="mozillaorg" revision="aebfbd998041e960cea0468533c0b5041b504850"/>
+  <project name="gaia" path="gaia" remote="mozillaorg" revision="508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2"/>
   <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/>
   <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="86cd7486d8e50eaac8ef6fe2f51f09d25194577b"/>
   <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/>
   <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/>
   <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/>
   <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/>
   <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/>
   <project name="apitrace" path="external/apitrace" remote="apitrace" revision="b685e3aab4fde7624d78993877a8f7910f2a5f06"/>
--- a/b2g/config/emulator/sources.xml
+++ b/b2g/config/emulator/sources.xml
@@ -14,17 +14,17 @@
   <!--original fetch url was git://github.com/apitrace/-->
   <remote fetch="https://git.mozilla.org/external/apitrace" name="apitrace"/>
   <default remote="caf" revision="refs/tags/android-4.0.4_r2.1" sync-j="4"/>
   <!-- Gonk specific things and forks -->
   <project name="platform_build" path="build" remote="b2g" revision="173b3104bfcbd23fc9dccd4b0035fc49aae3d444">
     <copyfile dest="Makefile" src="core/root.mk"/>
   </project>
   <project name="fake-dalvik" path="dalvik" remote="b2g" revision="ca1f327d5acc198bb4be62fa51db2c039032c9ce"/>
-  <project name="gaia.git" path="gaia" remote="mozillaorg" revision="aebfbd998041e960cea0468533c0b5041b504850"/>
+  <project name="gaia.git" path="gaia" remote="mozillaorg" revision="508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2"/>
   <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="86cd7486d8e50eaac8ef6fe2f51f09d25194577b"/>
   <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/>
   <project name="platform_hardware_ril" path="hardware/ril" remote="b2g" revision="93f9ba577f68d772093987c2f1c0a4ae293e1802"/>
   <project name="platform_external_qemu" path="external/qemu" remote="b2g" revision="527d1c939ee57deb7192166e56e2a3fffa8cb087"/>
   <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/>
   <project name="apitrace" path="external/apitrace" remote="apitrace" revision="34ea6163f9f0e0122fb0bb03607eccdca31ced7a"/>
   <!-- Stock Android things -->
   <project name="platform/abi/cpp" path="abi/cpp" revision="dd924f92906085b831bf1cbbc7484d3c043d613c"/>
--- a/b2g/config/flame-kk/sources.xml
+++ b/b2g/config/flame-kk/sources.xml
@@ -10,17 +10,17 @@
   <!--original fetch url was git://codeaurora.org/-->
   <remote fetch="https://git.mozilla.org/external/caf" name="caf"/>
   <!--original fetch url was https://git.mozilla.org/releases-->
   <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/>
   <!-- B2G specific things. -->
   <project name="platform_build" path="build" remote="b2g" revision="ef937d1aca7c4cf89ecb5cc43ae8c21c2000a9db">
     <copyfile dest="Makefile" src="core/root.mk"/>
   </project>
-  <project name="gaia" path="gaia" remote="mozillaorg" revision="aebfbd998041e960cea0468533c0b5041b504850"/>
+  <project name="gaia" path="gaia" remote="mozillaorg" revision="508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2"/>
   <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/>
   <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="86cd7486d8e50eaac8ef6fe2f51f09d25194577b"/>
   <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/>
   <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/>
   <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/>
   <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/>
   <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/>
   <project name="apitrace" path="external/apitrace" remote="apitrace" revision="b685e3aab4fde7624d78993877a8f7910f2a5f06"/>
--- a/b2g/config/flame/sources.xml
+++ b/b2g/config/flame/sources.xml
@@ -12,17 +12,17 @@
   <!--original fetch url was https://git.mozilla.org/releases-->
   <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/>
   <!-- B2G specific things. -->
   <project name="platform_build" path="build" remote="b2g" revision="4efd19d199ae52656604f794c5a77518400220fd">
     <copyfile dest="Makefile" src="core/root.mk"/>
   </project>
   <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/>
   <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/>
-  <project name="gaia" path="gaia" remote="mozillaorg" revision="aebfbd998041e960cea0468533c0b5041b504850"/>
+  <project name="gaia" path="gaia" remote="mozillaorg" revision="508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2"/>
   <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="86cd7486d8e50eaac8ef6fe2f51f09d25194577b"/>
   <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/>
   <project name="apitrace" path="external/apitrace" remote="apitrace" revision="b685e3aab4fde7624d78993877a8f7910f2a5f06"/>
   <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/>
   <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/>
   <!-- Stock Android things -->
   <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.1" path="prebuilts/clang/linux-x86/3.1" revision="e95b4ce22c825da44d14299e1190ea39a5260bde"/>
   <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.2" path="prebuilts/clang/linux-x86/3.2" revision="471afab478649078ad7c75ec6b252481a59e19b8"/>
--- a/b2g/config/gaia.json
+++ b/b2g/config/gaia.json
@@ -1,9 +1,9 @@
 {
     "git": {
-        "git_revision": "aebfbd998041e960cea0468533c0b5041b504850", 
+        "git_revision": "508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2", 
         "remote": "https://git.mozilla.org/releases/gaia.git", 
         "branch": ""
     }, 
-    "revision": "26282fe3ad19972a8d84cdc7eee85f73b6cfcc4e", 
+    "revision": "19761d0d782f0b33d2605bae9aa2e135e592d622", 
     "repo_path": "integration/gaia-central"
 }
--- a/b2g/config/nexus-4/sources.xml
+++ b/b2g/config/nexus-4/sources.xml
@@ -12,17 +12,17 @@
   <!--original fetch url was https://git.mozilla.org/releases-->
   <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/>
   <!-- B2G specific things. -->
   <project name="platform_build" path="build" remote="b2g" revision="4efd19d199ae52656604f794c5a77518400220fd">
     <copyfile dest="Makefile" src="core/root.mk"/>
   </project>
   <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/>
   <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/>
-  <project name="gaia" path="gaia" remote="mozillaorg" revision="aebfbd998041e960cea0468533c0b5041b504850"/>
+  <project name="gaia" path="gaia" remote="mozillaorg" revision="508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2"/>
   <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="86cd7486d8e50eaac8ef6fe2f51f09d25194577b"/>
   <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/>
   <project name="apitrace" path="external/apitrace" remote="apitrace" revision="b685e3aab4fde7624d78993877a8f7910f2a5f06"/>
   <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/>
   <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/>
   <!-- Stock Android things -->
   <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.1" path="prebuilts/clang/linux-x86/3.1" revision="5c45f43419d5582949284eee9cef0c43d866e03b"/>
   <project groups="linux" name="platform/prebuilts/clang/linux-x86/3.2" path="prebuilts/clang/linux-x86/3.2" revision="3748b4168e7bd8d46457d4b6786003bc6a5223ce"/>
--- a/b2g/config/nexus-5-l/sources.xml
+++ b/b2g/config/nexus-5-l/sources.xml
@@ -10,17 +10,17 @@
   <!--original fetch url was git://codeaurora.org/-->
   <remote fetch="https://git.mozilla.org/external/caf" name="caf"/>
   <!--original fetch url was https://git.mozilla.org/releases-->
   <remote fetch="https://git.mozilla.org/releases" name="mozillaorg"/>
   <!-- B2G specific things. -->
   <project name="platform_build" path="build" remote="b2g" revision="52775e03a2d8532429dff579cb2cd56718e488c3">
     <copyfile dest="Makefile" src="core/root.mk"/>
   </project>
-  <project name="gaia" path="gaia" remote="mozillaorg" revision="aebfbd998041e960cea0468533c0b5041b504850"/>
+  <project name="gaia" path="gaia" remote="mozillaorg" revision="508b8d48fb5ecf08bf0e5b4fef42bc48b770e7f2"/>
   <project name="fake-libdvm" path="dalvik" remote="b2g" revision="d50ae982b19f42f0b66d08b9eb306be81687869f"/>
   <project name="gonk-misc" path="gonk-misc" remote="b2g" revision="86cd7486d8e50eaac8ef6fe2f51f09d25194577b"/>
   <project name="librecovery" path="librecovery" remote="b2g" revision="1b3591a50ed352fc6ddb77462b7b35d0bfa555a3"/>
   <project name="moztt" path="external/moztt" remote="b2g" revision="ed2cf97a6c37a4bbd0bbbbffe06ec7136d8c79ff"/>
   <project name="rilproxy" path="rilproxy" remote="b2g" revision="5ef30994f4778b4052e58a4383dbe7890048c87e"/>
   <project name="valgrind" path="external/valgrind" remote="b2g" revision="daa61633c32b9606f58799a3186395fd2bbb8d8c"/>
   <project name="vex" path="external/VEX" remote="b2g" revision="47f031c320888fe9f3e656602588565b52d43010"/>
   <project name="apitrace" path="external/apitrace" remote="apitrace" revision="b685e3aab4fde7624d78993877a8f7910f2a5f06"/>
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -32,16 +32,18 @@ pref("extensions.strictCompatibility", f
 
 // Specifies a minimum maxVersion an addon needs to say it's compatible with
 // for it to be compatible by default.
 pref("extensions.minCompatibleAppVersion", "4.0");
 // Temporary preference to forcibly make themes more safe with Australis even if
 // extensions.checkCompatibility=false has been set.
 pref("extensions.checkCompatibility.temporaryThemeOverride_minAppVersion", "29.0a1");
 
+pref("xpinstall.customConfirmationUI", true);
+
 // Preferences for AMO integration
 pref("extensions.getAddons.cache.enabled", true);
 pref("extensions.getAddons.maxResults", 15);
 pref("extensions.getAddons.get.url", "https://services.addons.mozilla.org/%LOCALE%/firefox/api/%API_VERSION%/search/guid:%IDS%?src=firefox&appOS=%OS%&appVersion=%VERSION%");
 pref("extensions.getAddons.getWithPerformance.url", "https://services.addons.mozilla.org/%LOCALE%/firefox/api/%API_VERSION%/search/guid:%IDS%?src=firefox&appOS=%OS%&appVersion=%VERSION%&tMain=%TIME_MAIN%&tFirstPaint=%TIME_FIRST_PAINT%&tSessionRestored=%TIME_SESSION_RESTORED%");
 pref("extensions.getAddons.search.browseURL", "https://addons.mozilla.org/%LOCALE%/firefox/search?q=%TERMS%&platform=%OS%&appver=%VERSION%");
 pref("extensions.getAddons.search.url", "https://services.addons.mozilla.org/%LOCALE%/firefox/api/%API_VERSION%/search/%TERMS%/all/%MAX_RESULTS%/%OS%/%VERSION%/%COMPATIBILITY_MODE%?src=firefox");
 pref("extensions.webservice.discoverURL", "https://services.addons.mozilla.org/%LOCALE%/firefox/discovery/pane/%VERSION%/%OS%/%COMPATIBILITY_MODE%");
@@ -1695,19 +1697,19 @@ pref("loop.ping.timeout", 10000);
 pref("loop.feedback.baseUrl", "https://input.mozilla.org/api/v1/feedback");
 pref("loop.feedback.product", "Loop");
 pref("loop.debug.loglevel", "Error");
 pref("loop.debug.dispatcher", false);
 pref("loop.debug.websocket", false);
 pref("loop.debug.sdk", false);
 pref("loop.debug.twoWayMediaTelemetry", false);
 #ifdef DEBUG
-pref("loop.CSP", "default-src 'self' about: file: chrome: http://localhost:*; img-src 'self' data: https://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net http://localhost:* ws://localhost:*; media-src blob:");
+pref("loop.CSP", "default-src 'self' about: file: chrome: http://localhost:*; img-src *; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net http://localhost:* ws://localhost:*; media-src blob:");
 #else
-pref("loop.CSP", "default-src 'self' about: file: chrome:; img-src 'self' data: https://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net; media-src blob:");
+pref("loop.CSP", "default-src 'self' about: file: chrome:; img-src *; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net; media-src blob:");
 #endif
 pref("loop.oauth.google.redirect_uri", "urn:ietf:wg:oauth:2.0:oob:auto");
 pref("loop.oauth.google.scope", "https://www.google.com/m8/feeds");
 pref("loop.fxa_oauth.tokendata", "");
 pref("loop.fxa_oauth.profile", "");
 pref("loop.support_url", "https://support.mozilla.org/kb/group-conversations-firefox-hello-webrtc");
 pref("loop.contacts.gravatars.show", false);
 pref("loop.contacts.gravatars.promo", true);
--- a/browser/base/content/browser-addons.js
+++ b/browser/base/content/browser-addons.js
@@ -43,19 +43,25 @@ const gXPInstallObserver = {
     var brandShortName = brandBundle.getString("brandShortName");
 
     var notificationID = aTopic;
     // Make notifications persist a minimum of 30 seconds
     var options = {
       timeout: Date.now() + 30000
     };
 
+    try {
+      options.originHost = installInfo.originatingURI.host;
+    } catch (e) {
+      // originatingURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs.
+    }
+
     switch (aTopic) {
-    case "addon-install-disabled":
-      notificationID = "xpinstall-disabled"
+    case "addon-install-disabled": {
+      notificationID = "xpinstall-disabled";
 
       if (gPrefService.prefIsLocked("xpinstall.enabled")) {
         messageString = gNavigatorBundle.getString("xpinstallDisabledMessageLocked");
         buttons = [];
       }
       else {
         messageString = gNavigatorBundle.getString("xpinstallDisabledMessage");
 
@@ -65,71 +71,79 @@ const gXPInstallObserver = {
           callback: function editPrefs() {
             gPrefService.setBoolPref("xpinstall.enabled", true);
           }
         };
       }
 
       PopupNotifications.show(browser, notificationID, messageString, anchorID,
                               action, null, options);
-      break;
-    case "addon-install-blocked":
-      let originatingHost;
-      try {
-        originatingHost = installInfo.originatingURI.host;
-      } catch (ex) {
+      break; }
+    case "addon-install-blocked": {
+      if (!options.originHost) {
         // Need to deal with missing originatingURI and with about:/data: URIs more gracefully,
         // see bug 1063418 - but for now, bail:
         return;
       }
-      messageString = gNavigatorBundle.getFormattedString("xpinstallPromptWarning",
-                        [brandShortName, originatingHost]);
+      messageString = gNavigatorBundle.getFormattedString("xpinstallPromptMessage",
+                        [brandShortName]);
 
       let secHistogram = Components.classes["@mozilla.org/base/telemetry;1"].getService(Ci.nsITelemetry).getHistogramById("SECURITY_UI");
       action = {
         label: gNavigatorBundle.getString("xpinstallPromptAllowButton"),
         accessKey: gNavigatorBundle.getString("xpinstallPromptAllowButton.accesskey"),
         callback: function() {
           secHistogram.add(Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH);
           installInfo.install();
         }
       };
 
       secHistogram.add(Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED);
       PopupNotifications.show(browser, notificationID, messageString, anchorID,
                               action, null, options);
-      break;
-    case "addon-install-started":
-      var needsDownload = function needsDownload(aInstall) {
+      break; }
+    case "addon-install-started": {
+      let needsDownload = function needsDownload(aInstall) {
         return aInstall.state != AddonManager.STATE_DOWNLOADED;
       }
       // If all installs have already been downloaded then there is no need to
       // show the download progress
       if (!installInfo.installs.some(needsDownload))
         return;
       notificationID = "addon-progress";
-      messageString = gNavigatorBundle.getString("addonDownloading");
+      messageString = gNavigatorBundle.getString("addonDownloadingAndVerifying");
       messageString = PluralForm.get(installInfo.installs.length, messageString);
+      messageString = messageString.replace("#1", installInfo.installs.length);
       options.installs = installInfo.installs;
       options.contentWindow = browser.contentWindow;
       options.sourceURI = browser.currentURI;
-      options.eventCallback = function(aEvent) {
-        if (aEvent != "removed")
-          return;
-        options.contentWindow = null;
-        options.sourceURI = null;
+      options.eventCallback = (aEvent) => {
+        switch (aEvent) {
+          case "removed":
+            options.contentWindow = null;
+            options.sourceURI = null;
+            break;
+        }
       };
-      PopupNotifications.show(browser, notificationID, messageString, anchorID,
-                              null, null, options);
-      break;
-    case "addon-install-failed":
+      let notification = PopupNotifications.show(browser, notificationID, messageString,
+                                                 anchorID, null, null, options);
+      notification._startTime = Date.now();
+
+      let cancelButton = document.getElementById("addon-progress-cancel");
+      cancelButton.label = gNavigatorBundle.getString("addonInstall.cancelButton.label");
+      cancelButton.accessKey = gNavigatorBundle.getString("addonInstall.cancelButton.accesskey");
+
+      let acceptButton = document.getElementById("addon-progress-accept");
+      acceptButton.label = gNavigatorBundle.getString("addonInstall.acceptButton.label");
+      acceptButton.accessKey = gNavigatorBundle.getString("addonInstall.acceptButton.accesskey");
+      break; }
+    case "addon-install-failed": {
       // TODO This isn't terribly ideal for the multiple failure case
       for (let install of installInfo.installs) {
-        let host = (installInfo.originatingURI instanceof Ci.nsIStandardURL) &&
-                   installInfo.originatingURI.host;
+        let host = options.originHost;
         if (!host)
           host = (install.sourceURI instanceof Ci.nsIStandardURL) &&
                  install.sourceURI.host;
 
         let error = (host || install.error == 0) ? "addonError" : "addonLocalError";
         if (install.error != 0)
           error += install.error;
         else if (install.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED)
@@ -142,19 +156,110 @@ const gXPInstallObserver = {
         if (host)
           messageString = messageString.replace("#2", host);
         messageString = messageString.replace("#3", brandShortName);
         messageString = messageString.replace("#4", Services.appinfo.version);
 
         PopupNotifications.show(browser, notificationID, messageString, anchorID,
                                 action, null, options);
       }
-      break;
-    case "addon-install-complete":
-      var needsRestart = installInfo.installs.some(function(i) {
+      this._removeProgressNotification(browser);
+      break; }
+    case "addon-install-confirmation": {
+      options.eventCallback = (aEvent) => {
+        switch (aEvent) {
+          case "removed":
+            if (installInfo) {
+              for (let install of installInfo.installs)
+                install.cancel();
+            }
+            this.acceptInstallation = null;
+            break;
+          case "shown":
+            let addonList = document.getElementById("addon-install-confirmation-content");
+            while (addonList.firstChild)
+              addonList.firstChild.remove();
+
+            for (let install of installInfo.installs) {
+              let container = document.createElement("hbox");
+              let name = document.createElement("label");
+              let author = document.createElement("label");
+              name.setAttribute("value", install.addon.name);
+              author.setAttribute("value", !install.addon.creator ? "" :
+                gNavigatorBundle.getFormattedString("addonConfirmInstall.author", [install.addon.creator]));
+              name.setAttribute("class", "addon-install-confirmation-name");
+              author.setAttribute("class", "addon-install-confirmation-author");
+              container.appendChild(name);
+              container.appendChild(author);
+              addonList.appendChild(container);
+            }
+
+            this.acceptInstallation = () => {
+              for (let install of installInfo.installs)
+                install.install();
+              installInfo = null;
+
+              Services.telemetry
+                      .getHistogramById("SECURITY_UI")
+                      .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL_CLICK_THROUGH);
+            };
+            break;
+        }
+      };
+
+      messageString = gNavigatorBundle.getString("addonConfirmInstall.message");
+      messageString = PluralForm.get(installInfo.installs.length, messageString);
+      messageString = messageString.replace("#1", brandShortName);
+      messageString = messageString.replace("#2", installInfo.installs.length);
+
+      let cancelButton = document.getElementById("addon-install-confirmation-cancel");
+      cancelButton.label = gNavigatorBundle.getString("addonInstall.cancelButton.label");
+      cancelButton.accessKey = gNavigatorBundle.getString("addonInstall.cancelButton.accesskey");
+
+      let acceptButton = document.getElementById("addon-install-confirmation-accept");
+      acceptButton.label = gNavigatorBundle.getString("addonInstall.acceptButton.label");
+      acceptButton.accessKey = gNavigatorBundle.getString("addonInstall.acceptButton.accesskey");
+
+      let showNotification = () => {
+        // The download may have been cancelled during the security delay
+        if (!PopupNotifications.getNotification("addon-progress", browser))
+          return;
+
+        let tab = gBrowser.getTabForBrowser(browser);
+        if (tab)
+          gBrowser.selectedTab = tab;
+
+        if (PopupNotifications.isPanelOpen) {
+          let rect = document.getElementById("addon-progress-notification").getBoundingClientRect();
+          let notification = document.getElementById("addon-install-confirmation-notification");
+          notification.style.minHeight = rect.height + "px";
+        }
+
+        PopupNotifications.show(browser, notificationID, messageString, anchorID,
+                                action, null, options);
+
+        this._removeProgressNotification(browser);
+
+        Services.telemetry
+                .getHistogramById("SECURITY_UI")
+                .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL);
+      };
+
+      let downloadDuration = 0;
+      let progressNotification = PopupNotifications.getNotification("addon-progress", browser);
+      if (progressNotification)
+        downloadDuration = Date.now() - progressNotification._startTime;
+      let securityDelay = Services.prefs.getIntPref("security.dialog_enable_delay") - downloadDuration;
+      if (securityDelay > 0)
+        setTimeout(showNotification, securityDelay);
+      else
+        showNotification();
+      break; }
+    case "addon-install-complete": {
+      let needsRestart = installInfo.installs.some(function(i) {
         return i.addon.pendingOperations != AddonManager.PENDING_NONE;
       });
 
       if (needsRestart) {
         messageString = gNavigatorBundle.getString("addonsInstalledNeedsRestart");
         action = {
           label: gNavigatorBundle.getString("addonInstallRestartButton"),
           accessKey: gNavigatorBundle.getString("addonInstallRestartButton.accesskey"),
@@ -175,18 +280,23 @@ const gXPInstallObserver = {
 
       // Remove notificaion on dismissal, since it's possible to cancel the
       // install through the addons manager UI, making the "restart" prompt
       // irrelevant.
       options.removeOnDismissal = true;
 
       PopupNotifications.show(browser, notificationID, messageString, anchorID,
                               action, null, options);
-      break;
+      break; }
     }
+  },
+  _removeProgressNotification(aBrowser) {
+    let notification = PopupNotifications.getNotification("addon-progress", aBrowser);
+    if (notification)
+      notification.remove();
   }
 };
 
 var LightWeightThemeWebInstaller = {
   handleEvent: function (event) {
     switch (event.type) {
       case "InstallBrowserTheme":
       case "PreviewBrowserTheme":
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -1218,16 +1218,17 @@ var gBrowserInit = {
     setTimeout(function() { SafeBrowsing.init(); }, 2000);
 #endif
 
     Services.obs.addObserver(gSessionHistoryObserver, "browser:purge-session-history", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-disabled", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-started", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-blocked", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-failed", false);
+    Services.obs.addObserver(gXPInstallObserver, "addon-install-confirmation", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-complete", false);
     window.messageManager.addMessageListener("Browser:URIFixup", gKeywordURIFixup);
 
     BrowserOffline.init();
     OfflineApps.init();
     IndexedDBPromptHelper.init();
 #ifdef E10S_TESTING_ONLY
     gRemoteTabsUI.init();
@@ -1529,16 +1530,17 @@ var gBrowserInit = {
       LoopUI.uninit();
       FullZoom.destroy();
 
       Services.obs.removeObserver(gSessionHistoryObserver, "browser:purge-session-history");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-disabled");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-started");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-blocked");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-failed");
+      Services.obs.removeObserver(gXPInstallObserver, "addon-install-confirmation");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-complete");
       window.messageManager.removeMessageListener("Browser:URIFixup", gKeywordURIFixup);
       window.messageManager.removeMessageListener("Browser:LoadURI", RedirectLoad);
 
       try {
         gPrefService.removeObserver(gHomeButton.prefDomain, gHomeButton);
       } catch (ex) {
         Cu.reportError(ex);
@@ -2098,16 +2100,22 @@ function loadURI(uri, referrer, postData
 
 function getShortcutOrURIAndPostData(aURL, aCallback) {
   let mayInheritPrincipal = false;
   let postData = null;
   let shortcutURL = null;
   let keyword = aURL;
   let param = "";
 
+  // XXX Bug 1100294 will remove this little hack by using an async version of
+  // PlacesUtils.getURLAndPostDataForKeyword(). For now we simulate an async
+  // execution with at least a setTimeout(fn, 0).
+  let originalCallback = aCallback;
+  aCallback = data => setTimeout(() => originalCallback(data));
+
   let offset = aURL.indexOf(" ");
   if (offset > 0) {
     keyword = aURL.substr(0, offset);
     param = aURL.substr(offset + 1);
   }
 
   let engine = Services.search.getEngineByAlias(keyword);
   if (engine) {
@@ -4546,18 +4554,22 @@ var TabsProgressListener = {
       }, true);
     }
   },
 
   onLocationChange: function (aBrowser, aWebProgress, aRequest, aLocationURI,
                               aFlags) {
     // Filter out location changes caused by anchor navigation
     // or history.push/pop/replaceState.
-    if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
+    if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
+      // Reader mode actually cares about these:
+      let mm = gBrowser.selectedBrowser.messageManager;
+      mm.sendAsyncMessage("Reader:PushState");
       return;
+    }
 
     // Filter out location changes in sub documents.
     if (!aWebProgress.isTopLevel)
       return;
 
     // Only need to call locationChange if the PopupNotifications object
     // for this window has already been initialized (i.e. its getter no
     // longer exists)
--- a/browser/base/content/content.js
+++ b/browser/base/content/content.js
@@ -484,24 +484,29 @@ let AboutReaderListener = {
   _articlePromise: null,
 
   init: function() {
     addEventListener("AboutReaderContentLoaded", this, false, true);
     addEventListener("DOMContentLoaded", this, false);
     addEventListener("pageshow", this, false);
     addEventListener("pagehide", this, false);
     addMessageListener("Reader:ParseDocument", this);
+    addMessageListener("Reader:PushState", this);
   },
 
   receiveMessage: function(message) {
     switch (message.name) {
       case "Reader:ParseDocument":
         this._articlePromise = ReaderMode.parseDocument(content.document).catch(Cu.reportError);
         content.document.location = "about:reader?url=" + encodeURIComponent(message.data.url);
         break;
+
+      case "Reader:PushState":
+        this.updateReaderButton();
+        break;
     }
   },
 
   get isAboutReader() {
     return content.document.documentURI.startsWith("about:reader");
   },
 
   handleEvent: function(aEvent) {
@@ -514,39 +519,44 @@ let AboutReaderListener = {
         if (!this.isAboutReader) {
           return;
         }
 
         if (content.document.body) {
           // Update the toolbar icon to show the "reader active" icon.
           sendAsyncMessage("Reader:UpdateReaderButton");
           new AboutReader(global, content, this._articlePromise);
+          this._articlePromise = null;
         }
         break;
 
       case "pagehide":
         sendAsyncMessage("Reader:UpdateReaderButton", { isArticle: false });
         break;
 
       case "pageshow":
         // If a page is loaded from the bfcache, we won't get a "DOMContentLoaded"
         // event, so we need to rely on "pageshow" in this case.
-        if (!aEvent.persisted) {
-          break;
+        if (aEvent.persisted) {
+          this.updateReaderButton();
         }
-        // Fall through.
+        break;
       case "DOMContentLoaded":
-        if (!ReaderMode.isEnabledForParseOnLoad || this.isAboutReader) {
-          return;
-        }
+        this.updateReaderButton();
+        break;
 
-        let isArticle = ReaderMode.isProbablyReaderable(content.document);
-        sendAsyncMessage("Reader:UpdateReaderButton", { isArticle: isArticle });
     }
-  }
+  },
+  updateReaderButton: function() {
+    if (!ReaderMode.isEnabledForParseOnLoad || this.isAboutReader) {
+      return;
+    }
+    let isArticle = ReaderMode.isProbablyReaderable(content.document);
+    sendAsyncMessage("Reader:UpdateReaderButton", { isArticle: isArticle });
+  },
 };
 AboutReaderListener.init();
 
 // An event listener for custom "WebChannelMessageToChrome" events on pages
 addEventListener("WebChannelMessageToChrome", function (e) {
   // if target is window then we want the document principal, otherwise fallback to target itself.
   let principal = e.target.nodePrincipal ? e.target.nodePrincipal : e.target.document.nodePrincipal;
 
--- a/browser/base/content/popup-notifications.inc
+++ b/browser/base/content/popup-notifications.inc
@@ -5,62 +5,56 @@
            footertype="promobox"
            position="after_start"
            hidden="true"
            orient="vertical"
            role="alert"/>
 
     <popupnotification id="webRTC-shareDevices-notification" hidden="true">
       <popupnotificationcontent id="webRTC-selectCamera" orient="vertical">
-        <separator class="thin"/>
         <label value="&getUserMedia.selectCamera.label;"
                accesskey="&getUserMedia.selectCamera.accesskey;"
                control="webRTC-selectCamera-menulist"/>
         <menulist id="webRTC-selectCamera-menulist">
           <menupopup id="webRTC-selectCamera-menupopup"/>
         </menulist>
       </popupnotificationcontent>
 
       <popupnotificationcontent id="webRTC-selectWindowOrScreen" orient="vertical">
-        <separator class="thin"/>
         <label id="webRTC-selectWindow-label"
                control="webRTC-selectWindow-menulist"/>
         <menulist id="webRTC-selectWindow-menulist"
                   oncommand="gWebRTCUI.updateMainActionLabel(this);">
           <menupopup id="webRTC-selectWindow-menupopup"/>
         </menulist>
         <description id="webRTC-all-windows-shared" hidden="true">&getUserMedia.allWindowsShared.message;</description>
       </popupnotificationcontent>
 
       <popupnotificationcontent id="webRTC-selectMicrophone" orient="vertical">
-        <separator class="thin"/>
         <label value="&getUserMedia.selectMicrophone.label;"
                accesskey="&getUserMedia.selectMicrophone.accesskey;"
                control="webRTC-selectMicrophone-menulist"/>
         <menulist id="webRTC-selectMicrophone-menulist">
           <menupopup id="webRTC-selectMicrophone-menupopup"/>
         </menulist>
       </popupnotificationcontent>
     </popupnotification>
 
     <popupnotification id="webapps-install-progress-notification" hidden="true">
-      <popupnotificationcontent id="webapps-install-progress-content" orient="vertical" align="start">
-        <separator class="thin"/>
-      </popupnotificationcontent>
+      <popupnotificationcontent id="webapps-install-progress-content" orient="vertical" align="start"/>
     </popupnotification>
 
     <popupnotification id="servicesInstall-notification" hidden="true">
       <popupnotificationcontent orient="vertical" align="start">
         <!-- XXX bug 974146, tests are looking for this, can't remove yet. -->
       </popupnotificationcontent>
     </popupnotification>
 
     <popupnotification id="pointerLock-notification" hidden="true">
       <popupnotificationcontent orient="vertical" align="start">
-        <separator class="thin"/>
         <label id="pointerLock-cancel">&pointerLock.notification.message;</label>
       </popupnotificationcontent>
     </popupnotification>
 
     <popupnotification id="password-notification" hidden="true">
       <popupnotificationcontent orient="vertical">
         <textbox id="password-notification-username"/>
         <textbox id="password-notification-password" type="password"
@@ -68,8 +62,23 @@
       </popupnotificationcontent>
     </popupnotification>
 
 #ifdef E10S_TESTING_ONLY
     <popupnotification id="enable-e10s-notification" hidden="true">
       <popupnotificationcontent orient="vertical"/>
     </popupnotification>
 #endif
+
+    <popupnotification id="addon-progress-notification" hidden="true">
+      <button id="addon-progress-cancel"
+              oncommand="this.parentNode.cancel();"/>
+      <button id="addon-progress-accept" disabled="true"/>
+    </popupnotification>
+
+    <popupnotification id="addon-install-confirmation-notification" hidden="true">
+      <popupnotificationcontent id="addon-install-confirmation-content" orient="vertical"/>
+      <button id="addon-install-confirmation-cancel"
+              oncommand="PopupNotifications.getNotification('addon-install-confirmation').remove();"/>
+      <button id="addon-install-confirmation-accept"
+              oncommand="gXPInstallObserver.acceptInstallation();
+                         PopupNotifications.getNotification('addon-install-confirmation').remove();"/>
+    </popupnotification>
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -447,17 +447,16 @@ skip-if = e10s # Bug 1093941 - Waits ind
 [browser_urlbarEnter.js]
 skip-if = e10s # Bug 1093941 - used to cause obscure non-windows child process crashes on try
 [browser_urlbarEnterAfterMouseOver.js]
 skip-if = os == "linux" || e10s # Bug 1073339 - Investigate autocomplete test unreliability on Linux/e10s
 [browser_urlbarRevert.js]
 skip-if = e10s # Bug 1093941 - ESC reverted the location bar value - Got foobar, expected example.com
 [browser_urlbarSearchSingleWordNotification.js]
 [browser_urlbarStop.js]
-skip-if = e10s # Bug 1093941 - test calls gBrowser.contentWindow.stop
 [browser_urlbarTrimURLs.js]
 [browser_urlbar_search_healthreport.js]
 [browser_utilityOverlay.js]
 [browser_visibleFindSelection.js]
 skip-if = e10s # Bug 921935 - focusmanager issues with e10s (test calls waitForFocus)
 [browser_visibleLabel.js]
 [browser_visibleTabs.js]
 [browser_visibleTabs_bookmarkAllPages.js]
--- a/browser/base/content/test/general/browser_bug1064280_changeUrlInPinnedTab.js
+++ b/browser/base/content/test/general/browser_bug1064280_changeUrlInPinnedTab.js
@@ -1,33 +1,35 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
-add_task(function(){
+add_task(function* () {
   // Test that changing the URL in a pinned tab works correctly
 
   let TEST_LINK_INITIAL = "about:";
   let TEST_LINK_CHANGED = "about:support";
 
   let appTab = gBrowser.addTab(TEST_LINK_INITIAL);
+  let browser = appTab.linkedBrowser;
+  yield BrowserTestUtils.browserLoaded(browser);
+
   gBrowser.pinTab(appTab);
   is(appTab.pinned, true, "Tab was successfully pinned");
 
   let initialTabsNo = gBrowser.tabs.length;
 
   let goButton = document.getElementById("urlbar-go-button");
   gBrowser.selectedTab = appTab;
   gURLBar.focus();
   gURLBar.value = TEST_LINK_CHANGED;
 
-  let promisePageload = promiseTabLoadEvent(appTab);
   goButton.click();
-  yield promisePageload;
+  yield BrowserTestUtils.browserLoaded(browser);
 
   is(appTab.linkedBrowser.currentURI.spec, TEST_LINK_CHANGED,
      "New page loaded in the app tab");
   is(gBrowser.tabs.length, initialTabsNo, "No additional tabs were opened");
 });
 
 registerCleanupFunction(function () {
   gBrowser.removeTab(gBrowser.selectedTab);
--- a/browser/base/content/test/general/browser_bug553455.js
+++ b/browser/base/content/test/general/browser_bug553455.js
@@ -1,92 +1,96 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/
  */
 
 const TESTROOT = "http://example.com/browser/toolkit/mozapps/extensions/test/xpinstall/";
 const TESTROOT2 = "http://example.org/browser/toolkit/mozapps/extensions/test/xpinstall/";
 const SECUREROOT = "https://example.com/browser/toolkit/mozapps/extensions/test/xpinstall/";
-const XPINSTALL_URL = "chrome://mozapps/content/xpinstall/xpinstallConfirm.xul";
 const PREF_INSTALL_REQUIREBUILTINCERTS = "extensions.install.requireBuiltInCerts";
-const PROGRESS_NOTIFICATION = "addon-progress-notification";
+const PROGRESS_NOTIFICATION = "addon-progress";
 
 var rootDir = getRootDirectory(gTestPath);
 var path = rootDir.split('/');
 var chromeName = path[0] + '//' + path[2];
 var croot = chromeName + "/content/browser/toolkit/mozapps/extensions/test/xpinstall/";
 var jar = getJar(croot);
 if (jar) {
   var tmpdir = extractJarToTmp(jar);
   croot = 'file://' + tmpdir.path + '/';
 }
 const CHROMEROOT = croot;
 
 var gApp = document.getElementById("bundle_brand").getString("brandShortName");
 var gVersion = Services.appinfo.version;
-var check_notification;
+
+function get_observer_topic(aNotificationId) {
+  let topic = aNotificationId;
+  if (topic == "xpinstall-disabled")
+    topic = "addon-install-disabled";
+  else if (topic == "addon-progress")
+    topic = "addon-install-started";
+  return topic;
+}
 
 function wait_for_progress_notification(aCallback) {
   wait_for_notification(PROGRESS_NOTIFICATION, aCallback, "popupshowing");
 }
 
 function wait_for_notification(aId, aCallback, aEvent = "popupshown") {
   info("Waiting for " + aId + " notification");
-  check_notification = function() {
+
+  let topic = get_observer_topic(aId);
+  function observer(aSubject, aTopic, aData) {
     // Ignore the progress notification unless that is the notification we want
-    if (aId != PROGRESS_NOTIFICATION && PopupNotifications.panel.childNodes[0].id == PROGRESS_NOTIFICATION)
+    if (aId != PROGRESS_NOTIFICATION &&
+        aTopic == get_observer_topic(PROGRESS_NOTIFICATION))
       return;
 
-    PopupNotifications.panel.removeEventListener(aEvent, check_notification, false);
+    Services.obs.removeObserver(observer, topic);
+
+    if (PopupNotifications.isPanelOpen)
+      executeSoon(verify);
+    else
+      PopupNotifications.panel.addEventListener(aEvent, event_listener);
+  }
+
+  function event_listener() {
+    // Ignore the progress notification unless that is the notification we want
+    if (aId != PROGRESS_NOTIFICATION &&
+        PopupNotifications.panel.childNodes[0].id == PROGRESS_NOTIFICATION + "-notification")
+      return;
+
+    PopupNotifications.panel.removeEventListener(aEvent, event_listener);
+
+    verify();
+  }
+
+  function verify() {
     info("Saw a notification");
+    ok(PopupNotifications.isPanelOpen, "Panel should be open");
     is(PopupNotifications.panel.childNodes.length, 1, "Should be only one notification");
-    if (PopupNotifications.panel.childNodes.length)
-      is(PopupNotifications.panel.childNodes[0].id, aId, "Should have seen the right notification");
+    if (PopupNotifications.panel.childNodes.length) {
+      is(PopupNotifications.panel.childNodes[0].id,
+         aId + "-notification", "Should have seen the right notification");
+    }
     aCallback(PopupNotifications.panel);
-  };
-  PopupNotifications.panel.addEventListener(aEvent, check_notification, false);
+  }
+
+  Services.obs.addObserver(observer, topic, false);
 }
 
 function wait_for_notification_close(aCallback) {
   info("Waiting for notification to close");
   PopupNotifications.panel.addEventListener("popuphidden", function() {
     PopupNotifications.panel.removeEventListener("popuphidden", arguments.callee, false);
     aCallback();
   }, false);
 }
 
-function wait_for_install_dialog(aCallback) {
-  info("Waiting for install dialog");
-  Services.wm.addListener({
-    onOpenWindow: function(aXULWindow) {
-      info("Install dialog opened, waiting for focus");
-      Services.wm.removeListener(this);
-
-      var domwindow = aXULWindow.QueryInterface(Ci.nsIInterfaceRequestor)
-                                .getInterface(Ci.nsIDOMWindow);
-      waitForFocus(function() {
-        info("Saw install dialog");
-        is(domwindow.document.location.href, XPINSTALL_URL, "Should have seen the right window open");
-
-        // Override the countdown timer on the accept button
-        var button = domwindow.document.documentElement.getButton("accept");
-        button.disabled = false;
-
-        aCallback(domwindow);
-      }, domwindow);
-    },
-
-    onCloseWindow: function(aXULWindow) {
-    },
-
-    onWindowTitleChange: function(aXULWindow, aNewTitle) {
-    }
-  });
-}
-
 function wait_for_single_notification(aCallback) {
   function inner_waiter() {
     info("Waiting for single notification");
     // Notification should never close while we wait
     ok(PopupNotifications.isPanelOpen, "Notification should still be open");
     if (PopupNotifications.panel.childNodes.length == 2) {
       executeSoon(inner_waiter);
       return;
@@ -109,17 +113,17 @@ function setup_redirect(aSettings) {
   req.send(null);
 }
 
 var TESTS = [
 function test_disabled_install() {
   Services.prefs.setBoolPref("xpinstall.enabled", false);
 
   // Wait for the disabled notification
-  wait_for_notification("xpinstall-disabled-notification", function(aPanel) {
+  wait_for_notification("xpinstall-disabled", function(aPanel) {
     let notification = aPanel.childNodes[0];
     is(notification.button.label, "Enable", "Should have seen the right button");
     is(notification.getAttribute("label"),
        "Software installation is currently disabled. Click Enable and try again.");
 
     wait_for_notification_close(function() {
       try {
         ok(Services.prefs.getBoolPref("xpinstall.enabled"), "Installation should be enabled");
@@ -146,105 +150,112 @@ function test_disabled_install() {
     "XPI": "unsigned.xpi"
   }));
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_blocked_install() {
   // Wait for the blocked notification
-  wait_for_notification("addon-install-blocked-notification", function(aPanel) {
+  wait_for_notification("addon-install-blocked", function(aPanel) {
     let notification = aPanel.childNodes[0];
     is(notification.button.label, "Allow", "Should have seen the right button");
+    is(notification.getAttribute("originhost"), "example.com",
+       "Should have seen the right origin host");
     is(notification.getAttribute("label"),
-       gApp + " prevented this site (example.com) from asking you to install " +
-       "software on your computer.",
+       gApp + " prevented this site from asking you to install software on your computer.",
        "Should have seen the right message");
 
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "XPI Test will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         AddonManager.getAllInstalls(function(aInstalls) {
         is(aInstalls.length, 1, "Should be one pending install");
           aInstalls[0].cancel();
 
           wait_for_notification_close(runNextTest);
           gBrowser.removeTab(gBrowser.selectedTab);
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
 
     // Click on Allow
     EventUtils.synthesizeMouse(notification.button, 20, 10, {});
 
     // Notification should have changed to progress notification
     ok(PopupNotifications.isPanelOpen, "Notification should still be open");
     notification = aPanel.childNodes[0];
     is(notification.id, "addon-progress-notification", "Should have seen the progress notification");
-
   });
 
   var triggers = encodeURIComponent(JSON.stringify({
     "XPI": "unsigned.xpi"
   }));
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_whitelisted_install() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
+    gBrowser.selectedTab = originalTab;
+
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
+      is(gBrowser.selectedTab, tab,
+         "tab selected in response to the addon-install-confirmation notification");
+
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "XPI Test will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         AddonManager.getAllInstalls(function(aInstalls) {
           is(aInstalls.length, 1, "Should be one pending install");
           aInstalls[0].cancel();
 
           Services.perms.remove("example.com", "install");
           wait_for_notification_close(runNextTest);
           gBrowser.removeTab(gBrowser.selectedTab);
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "XPI": "unsigned.xpi"
   }));
-  gBrowser.selectedTab = gBrowser.addTab();
+  let originalTab = gBrowser.selectedTab;
+  let tab = gBrowser.addTab();
+  gBrowser.selectedTab = tab;
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_failed_download() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the failed notification
-    wait_for_notification("addon-install-failed-notification", function(aPanel) {
+    wait_for_notification("addon-install-failed", function(aPanel) {
       let notification = aPanel.childNodes[0];
       is(notification.getAttribute("label"),
          "The add-on could not be downloaded because of a connection failure " +
          "on example.com.",
          "Should have seen the right message");
 
       Services.perms.remove("example.com", "install");
       wait_for_notification_close(runNextTest);
@@ -261,17 +272,17 @@ function test_failed_download() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_corrupt_file() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the failed notification
-    wait_for_notification("addon-install-failed-notification", function(aPanel) {
+    wait_for_notification("addon-install-failed", function(aPanel) {
       let notification = aPanel.childNodes[0];
       is(notification.getAttribute("label"),
          "The add-on downloaded from example.com could not be installed " +
          "because it appears to be corrupt.",
          "Should have seen the right message");
 
       Services.perms.remove("example.com", "install");
       wait_for_notification_close(runNextTest);
@@ -288,17 +299,17 @@ function test_corrupt_file() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_incompatible() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the failed notification
-    wait_for_notification("addon-install-failed-notification", function(aPanel) {
+    wait_for_notification("addon-install-failed", function(aPanel) {
       let notification = aPanel.childNodes[0];
       is(notification.getAttribute("label"),
          "XPI Test could not be installed because it is not compatible with " +
          gApp + " " + gVersion + ".",
          "Should have seen the right message");
 
       Services.perms.remove("example.com", "install");
       wait_for_notification_close(runNextTest);
@@ -315,19 +326,19 @@ function test_incompatible() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_restartless() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.getAttribute("label"),
            "XPI Test has been installed successfully.",
            "Should have seen the right message");
 
         AddonManager.getAllInstalls(function(aInstalls) {
           is(aInstalls.length, 0, "Should be no pending installs");
 
@@ -336,17 +347,17 @@ function test_restartless() {
 
             Services.perms.remove("example.com", "install");
             wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
           });
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "XPI": "restartless.xpi"
@@ -354,19 +365,19 @@ function test_restartless() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_multiple() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "2 add-ons will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         AddonManager.getAllInstalls(function(aInstalls) {
           is(aInstalls.length, 1, "Should be one pending install");
@@ -377,17 +388,17 @@ function test_multiple() {
 
             Services.perms.remove("example.com", "install");
             wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
           });
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "Unsigned XPI": "unsigned.xpi",
@@ -396,35 +407,35 @@ function test_multiple() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_url() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "XPI Test will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         AddonManager.getAllInstalls(function(aInstalls) {
           is(aInstalls.length, 1, "Should be one pending install");
           aInstalls[0].cancel();
 
           wait_for_notification_close(runNextTest);
           gBrowser.removeTab(gBrowser.selectedTab);
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "unsigned.xpi");
 },
 
 function test_localfile() {
@@ -462,17 +473,17 @@ function test_wronghost() {
     if (gBrowser.currentURI.spec != TESTROOT2 + "enabled.html")
       return;
 
     gBrowser.removeEventListener("load", arguments.callee, true);
 
     // Wait for the progress notification
     wait_for_progress_notification(function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-failed-notification", function(aPanel) {
+      wait_for_notification("addon-install-failed", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.getAttribute("label"),
            "The add-on downloaded from example.com could not be installed " +
            "because it appears to be corrupt.",
            "Should have seen the right message");
 
         wait_for_notification_close(runNextTest);
         gBrowser.removeTab(gBrowser.selectedTab);
@@ -483,19 +494,19 @@ function test_wronghost() {
   }, true);
   gBrowser.loadURI(TESTROOT2 + "enabled.html");
 },
 
 function test_reload() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "XPI Test will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         function test_fail() {
           ok(false, "Reloading should not have hidden the notification");
@@ -518,17 +529,17 @@ function test_reload() {
             Services.perms.remove("example.com", "install");
             wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
           });
         }, true);
         gBrowser.loadURI(TESTROOT2 + "enabled.html");
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "Unsigned XPI": "unsigned.xpi"
@@ -536,19 +547,19 @@ function test_reload() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_theme() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "Theme Test will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         AddonManager.getAddonByID("{972ce4c6-7e08-4474-a285-3208198ce6fd}", function(aAddon) {
           ok(aAddon.userDisabled, "Should be switching away from the default theme.");
@@ -561,39 +572,39 @@ function test_theme() {
 
             Services.perms.remove("example.com", "install");
             wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
           });
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "Theme XPI": "theme.xpi"
   }));
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_renotify_blocked() {
   // Wait for the blocked notification
-  wait_for_notification("addon-install-blocked-notification", function(aPanel) {
+  wait_for_notification("addon-install-blocked", function(aPanel) {
     let notification = aPanel.childNodes[0];
 
     wait_for_notification_close(function () {
       info("Timeouts after this probably mean bug 589954 regressed");
       executeSoon(function () {
-        wait_for_notification("addon-install-blocked-notification", function(aPanel) {
+        wait_for_notification("addon-install-blocked", function(aPanel) {
           AddonManager.getAllInstalls(function(aInstalls) {
           is(aInstalls.length, 2, "Should be two pending installs");
             aInstalls[0].cancel();
             aInstalls[1].cancel();
 
             info("Closing browser tab");
             wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
@@ -614,135 +625,104 @@ function test_renotify_blocked() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_renotify_installed() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         // Dismiss the notification
         wait_for_notification_close(function () {
           // Install another
           executeSoon(function () {
             // Wait for the progress notification
             wait_for_progress_notification(function(aPanel) {
               // Wait for the install confirmation dialog
-              wait_for_install_dialog(function(aWindow) {
+              wait_for_notification("addon-install-confirmation", function(aPanel) {
                 info("Timeouts after this probably mean bug 589954 regressed");
 
                 // Wait for the complete notification
-                wait_for_notification("addon-install-complete-notification", function(aPanel) {
+                wait_for_notification("addon-install-complete", function(aPanel) {
                   AddonManager.getAllInstalls(function(aInstalls) {
                   is(aInstalls.length, 1, "Should be one pending installs");
                     aInstalls[0].cancel();
 
                     Services.perms.remove("example.com", "install");
                     wait_for_notification_close(runNextTest);
                     gBrowser.removeTab(gBrowser.selectedTab);
                   });
                 });
 
-                aWindow.document.documentElement.acceptDialog();
+                document.getElementById("addon-install-confirmation-accept").click();
               });
             });
 
             gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
           });
         });
 
         // hide the panel (this simulates the user dismissing it)
         aPanel.hidePopup();
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "XPI": "unsigned.xpi"
   }));
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
-function test_cancel_restart() {
+function test_cancel() {
   function complete_install(callback) {
     let url = TESTROOT + "slowinstall.sjs?continue=true"
     NetUtil.asyncFetch(url, callback || (() => {}));
   }
 
   // Wait for the progress notification
   wait_for_notification(PROGRESS_NOTIFICATION, function(aPanel) {
     let notification = aPanel.childNodes[0];
     // Close the notification
     let anchor = document.getElementById("addons-notification-icon");
     anchor.click();
     // Reopen the notification
     anchor.click();
 
     ok(PopupNotifications.isPanelOpen, "Notification should still be open");
     is(PopupNotifications.panel.childNodes.length, 1, "Should be only one notification");
-    isnot(notification, aPanel.childNodes[0], "Should have reconstructed the notification UI");
     notification = aPanel.childNodes[0];
     is(notification.id, "addon-progress-notification", "Should have seen the progress notification");
-    let button = document.getAnonymousElementByAttribute(notification, "anonid", "cancel");
+    let button = document.getElementById("addon-progress-cancel");
 
     // Wait for the install to fully cancel
     let install = notification.notification.options.installs[0];
     install.addListener({
       onDownloadCancelled: function() {
         install.removeListener(this);
 
         executeSoon(function() {
-          ok(PopupNotifications.isPanelOpen, "Notification should still be open");
-          is(PopupNotifications.panel.childNodes.length, 1, "Should be only one notification");
-          isnot(notification, aPanel.childNodes[0], "Should have reconstructed the notification UI");
-          notification = aPanel.childNodes[0];
-          is(notification.id, "addon-install-cancelled-notification", "Should have seen the cancelled notification");
+          ok(!PopupNotifications.isPanelOpen, "Notification should be closed");
 
-          // Wait for the install confirmation dialog
-          wait_for_install_dialog(function(aWindow) {
-            // Wait for the complete notification
-            wait_for_notification("addon-install-complete-notification", function(aPanel) {
-              let notification = aPanel.childNodes[0];
-              is(notification.button.label, "Restart Now", "Should have seen the right button");
-              is(notification.getAttribute("label"),
-                 "XPI Test will be installed after you restart " + gApp + ".",
-                 "Should have seen the right message");
-
-              AddonManager.getAllInstalls(function(aInstalls) {
-                is(aInstalls.length, 1, "Should be one pending install");
-                aInstalls[0].cancel();
+          AddonManager.getAllInstalls(function(aInstalls) {
+            is(aInstalls.length, 0, "Should be no pending install");
 
-                Services.perms.remove("example.com", "install");
-                wait_for_notification_close(runNextTest);
-                gBrowser.removeTab(gBrowser.selectedTab);
-              });
-            });
-
-            aWindow.document.documentElement.acceptDialog();
+            Services.perms.remove("example.com", "install");
+            gBrowser.removeTab(gBrowser.selectedTab);
+            runNextTest();
           });
-
-          // Restart the download
-          EventUtils.synthesizeMouseAtCenter(notification.button, {});
-
-          // Should be back to a progress notification
-          ok(PopupNotifications.isPanelOpen, "Notification should still be open");
-          is(PopupNotifications.panel.childNodes.length, 1, "Should be only one notification");
-          notification = aPanel.childNodes[0];
-          is(notification.id, "addon-progress-notification", "Should have seen the progress notification");
-
-          complete_install();
         });
       }
     });
 
     // Cancel the download
     EventUtils.synthesizeMouseAtCenter(button, {});
   });
 
@@ -759,17 +739,17 @@ function test_cancel_restart() {
 function test_failed_security() {
   Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
 
   setup_redirect({
     "Location": TESTROOT + "unsigned.xpi"
   });
 
   // Wait for the blocked notification
-  wait_for_notification("addon-install-blocked-notification", function(aPanel) {
+  wait_for_notification("addon-install-blocked", function(aPanel) {
     let notification = aPanel.childNodes[0];
 
     // Click on Allow
     EventUtils.synthesizeMouse(notification.button, 20, 10, {});
 
     // Notification should have changed to progress notification
     ok(PopupNotifications.isPanelOpen, "Notification should still be open");
     is(PopupNotifications.panel.childNodes.length, 1, "Should be only one notification");
@@ -836,36 +816,37 @@ var XPInstallObserver = {
 
 function test() {
   requestLongerTimeout(4);
   waitForExplicitFinish();
 
   Services.prefs.setBoolPref("extensions.logging.enabled", true);
   Services.prefs.setBoolPref("extensions.strictCompatibility", true);
   Services.prefs.setBoolPref("extensions.install.requireSecureOrigin", false);
+  Services.prefs.setIntPref("security.dialog_enable_delay", 0);
 
   Services.obs.addObserver(XPInstallObserver, "addon-install-started", false);
   Services.obs.addObserver(XPInstallObserver, "addon-install-blocked", false);
   Services.obs.addObserver(XPInstallObserver, "addon-install-failed", false);
   Services.obs.addObserver(XPInstallObserver, "addon-install-complete", false);
 
   registerCleanupFunction(function() {
     // Make sure no more test parts run in case we were timed out
     TESTS = [];
-    PopupNotifications.panel.removeEventListener("popupshown", check_notification, false);
 
     AddonManager.getAllInstalls(function(aInstalls) {
       aInstalls.forEach(function(aInstall) {
         aInstall.cancel();
       });
     });
 
     Services.prefs.clearUserPref("extensions.logging.enabled");
     Services.prefs.clearUserPref("extensions.strictCompatibility");
     Services.prefs.clearUserPref("extensions.install.requireSecureOrigin");
+    Services.prefs.clearUserPref("security.dialog_enable_delay");
 
     Services.obs.removeObserver(XPInstallObserver, "addon-install-started");
     Services.obs.removeObserver(XPInstallObserver, "addon-install-blocked");
     Services.obs.removeObserver(XPInstallObserver, "addon-install-failed");
     Services.obs.removeObserver(XPInstallObserver, "addon-install-complete");
   });
 
   runNextTest();
--- a/browser/base/content/test/general/browser_locationBarCommand.js
+++ b/browser/base/content/test/general/browser_locationBarCommand.js
@@ -1,212 +1,214 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const TEST_VALUE = "example.com";
 const START_VALUE = "example.org";
 
-let gFocusManager = Services.focus;
-
-function test() {
-  waitForExplicitFinish();
-
-  registerCleanupFunction(function () {
-    Services.prefs.clearUserPref("browser.altClickSave");
-  });
+add_task(function* setup() {
   Services.prefs.setBoolPref("browser.altClickSave", true);
 
-  runAltLeftClickTest();
-}
+  registerCleanupFunction(() => {
+    Services.prefs.clearUserPref("browser.altClickSave");
+  });
+});
+
+add_task(function* alt_left_click_test() {
+  info("Running test: Alt left click");
 
-// Monkey patch saveURL to avoid dealing with file save code paths
-var oldSaveURL = saveURL;
-saveURL = function() {
+  // Monkey patch saveURL() to avoid dealing with file save code paths.
+  let oldSaveURL = saveURL;
+  let saveURLPromise = new Promise(resolve => {
+    saveURL = () => {
+      // Restore old saveURL() value.
+      saveURL = oldSaveURL;
+      resolve();
+    };
+  });
+
+  triggerCommand(true, {altKey: true});
+
+  yield saveURLPromise;
   ok(true, "SaveURL was called");
   is(gURLBar.value, "", "Urlbar reverted to original value");
-  saveURL = oldSaveURL;
-  runShiftLeftClickTest();
-}
-function runAltLeftClickTest() {
-  info("Running test: Alt left click");
-  triggerCommand(true, { altKey: true });
+});
+
+add_task(function* shift_left_click_test() {
+  info("Running test: Shift left click");
+
+  let newWindowPromise = promiseWaitForNewWindow();
+  triggerCommand(true, {shiftKey: true});
+  let win = yield newWindowPromise;
+
+  // Wait for the initial browser to load.
+  let browser = win.gBrowser.selectedBrowser;
+  yield BrowserTestUtils.browserLoaded(browser);
+
+  info("URL should be loaded in a new window");
+  is(gURLBar.value, "", "Urlbar reverted to original value");
+  is(Services.focus.focusedElement, null, "There should be no focused element");
+  is(Services.focus.focusedWindow, win.gBrowser.contentWindow, "Content window should be focused");
+  is(win.gURLBar.textValue, TEST_VALUE, "New URL is loaded in new window");
+
+  // Cleanup.
+  yield promiseWindowClosed(win);
+});
+
+add_task(function* right_click_test() {
+  info("Running test: Right click on go button");
+
+  // Add a new tab.
+  let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+  triggerCommand(true, {button: 2});
+
+  // Right click should do nothing (context menu will be shown).
+  is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
+
+  // Cleanup.
+  gBrowser.removeCurrentTab();
+});
+
+add_task(function* shift_accel_left_click_test() {
+  info("Running test: Shift+Ctrl/Cmd left click on go button");
+
+  // Add a new tab.
+  let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+  let loadStartedPromise = promiseLoadStarted();
+  triggerCommand(true, {accelKey: true, shiftKey: true});
+  yield loadStartedPromise;
+
+  // Check the load occurred in a new background tab.
+  info("URL should be loaded in a new background tab");
+  is(gURLBar.value, "", "Urlbar reverted to original value");
+  ok(!gURLBar.focused, "Urlbar is no longer focused after urlbar command");
+  is(gBrowser.selectedTab, tab, "Focus did not change to the new tab");
+
+  // Select the new background tab
+  gBrowser.selectedTab = gBrowser.selectedTab.nextSibling;
+  is(gURLBar.value, TEST_VALUE, "New URL is loaded in new tab");
+
+  // Cleanup.
+  gBrowser.removeCurrentTab();
+  gBrowser.removeCurrentTab();
+});
+
+add_task(function* load_in_current_tab_test() {
+  let tests = [
+    {desc: "Simple return keypress"},
+    {desc: "Left click on go button", click: true},
+    {desc: "Ctrl/Cmd+Return keypress", event: {accelKey: true}},
+    {desc: "Alt+Return keypress in a blank tab", event: {altKey: true}}
+  ];
+
+  for (let test of tests) {
+    info(`Running test: ${test.desc}`);
+
+    // Add a new tab.
+    let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+    yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+    // Trigger a load and check it occurs in the current tab.
+    let loadStartedPromise = promiseLoadStarted();
+    triggerCommand(test.click || false, test.event || {});
+    yield loadStartedPromise;
+
+    info("URL should be loaded in the current tab");
+    is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
+    is(Services.focus.focusedElement, null, "There should be no focused element");
+    is(Services.focus.focusedWindow, gBrowser.contentWindow, "Content window should be focused");
+    is(gBrowser.selectedTab, tab, "New URL was loaded in the current tab");
+
+    // Cleanup.
+    gBrowser.removeCurrentTab();
+  }
+});
+
+add_task(function* load_in_new_tab_test() {
+  let tests = [
+    {desc: "Ctrl/Cmd left click on go button", click: true, event: {accelKey: true}},
+    {desc: "Alt+Return keypress in a dirty tab", event: {altKey: true}, url: START_VALUE}
+  ];
+
+  for (let test of tests) {
+    info(`Running test: ${test.desc}`);
+
+    // Add a new tab.
+    let tab = gBrowser.selectedTab = gBrowser.addTab(test.url || "about:blank");
+    yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+    // Trigger a load and check it occurs in the current tab.
+    let tabSelectedPromise = promiseNewTabSelected();
+    triggerCommand(test.click || false, test.event || {});
+    yield tabSelectedPromise;
+
+    // Check the load occurred in a new tab.
+    info("URL should be loaded in a new focused tab");
+    is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
+    is(Services.focus.focusedElement, null, "There should be no focused element");
+    is(Services.focus.focusedWindow, gBrowser.contentWindow, "Content window should be focused");
+    isnot(gBrowser.selectedTab, tab, "New URL was loaded in a new tab");
+
+    // Cleanup.
+    gBrowser.removeCurrentTab();
+    gBrowser.removeCurrentTab();
+  }
+});
+
+function triggerCommand(shouldClick, event) {
+  gURLBar.value = TEST_VALUE;
+  gURLBar.focus();
+
+  if (shouldClick) {
+    is(gURLBar.getAttribute("pageproxystate"), "invalid",
+       "page proxy state must be invalid for go button to be visible");
+
+    let goButton = document.getElementById("urlbar-go-button");
+    EventUtils.synthesizeMouseAtCenter(goButton, event);
+  } else {
+    EventUtils.synthesizeKey("VK_RETURN", event);
+  }
 }
 
-function runShiftLeftClickTest() {
-  let listener = new BrowserWindowListener(getBrowserURL(), function(aWindow) {
-    Services.wm.removeListener(listener);
-    addPageShowListener(aWindow.gBrowser.selectedBrowser, function() {
-      executeSoon(function () {
-        info("URL should be loaded in a new window");
-        is(gURLBar.value, "", "Urlbar reverted to original value");
-        is(gFocusManager.focusedElement, null, "There should be no focused element");
-        is(gFocusManager.focusedWindow, aWindow.gBrowser.contentWindow, "Content window should be focused");
-        is(aWindow.gURLBar.textValue, TEST_VALUE, "New URL is loaded in new window");
-
-        aWindow.close();
-
-        // Continue testing when the original window has focus again.
-        whenWindowActivated(window, runNextTest);
-      });
-    }, "http://example.com/");
-  });
-  Services.wm.addListener(listener);
-
-  info("Running test: Shift left click");
-  triggerCommand(true, { shiftKey: true });
-}
-
-function runNextTest() {
-  let test = gTests.shift();
-  if (!test) {
-    finish();
-    return;
-  }
-
-  info("Running test: " + test.desc);
-  // Tab will be blank if test.startValue is null
-  let tab = gBrowser.selectedTab = gBrowser.addTab(test.startValue);
-  addPageShowListener(gBrowser.selectedBrowser, function() {
-    triggerCommand(test.click, test.event);
-    test.check(tab);
-
-    // Clean up
-    while (gBrowser.tabs.length > 1)
-      gBrowser.removeTab(gBrowser.selectedTab)
-    runNextTest();
+function promiseLoadStarted() {
+  return new Promise(resolve => {
+    gBrowser.addTabsProgressListener({
+      onStateChange(browser, webProgress, req, flags, status) {
+        if (flags & Ci.nsIWebProgressListener.STATE_START) {
+          gBrowser.removeTabsProgressListener(this);
+          resolve();
+        }
+      }
+    });
   });
 }
 
-let gTests = [
-  { desc: "Right click on go button",
-    click: true,
-    event: { button: 2 },
-    check: function(aTab) {
-      // Right click should do nothing (context menu will be shown)
-      is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
-    }
-  },
-
-  { desc: "Left click on go button",
-    click: true,
-    event: {},
-    check: checkCurrent
-  },
-
-  { desc: "Ctrl/Cmd left click on go button",
-    click: true,
-    event: { accelKey: true },
-    check: checkNewTab
-  },
-
-  { desc: "Shift+Ctrl/Cmd left click on go button",
-    click: true,
-    event: { accelKey: true, shiftKey: true },
-    check: function(aTab) {
-      info("URL should be loaded in a new background tab");
-      is(gURLBar.value, "", "Urlbar reverted to original value");
-      ok(!gURLBar.focused, "Urlbar is no longer focused after urlbar command");
-      is(gBrowser.selectedTab, aTab, "Focus did not change to the new tab");
-
-      // Select the new background tab
-      gBrowser.selectedTab = gBrowser.selectedTab.nextSibling;
-      is(gURLBar.value, TEST_VALUE, "New URL is loaded in new tab");
-    }
-  },
-
-  { desc: "Simple return keypress",
-    event: {},
-    check: checkCurrent
-  },
-
-  { desc: "Alt+Return keypress in a blank tab",
-    event: { altKey: true },
-    check: checkCurrent
-  },
-
-  { desc: "Alt+Return keypress in a dirty tab",
-    event: { altKey: true },
-    check: checkNewTab,
-    startValue: START_VALUE
-  },
-
-  { desc: "Ctrl/Cmd+Return keypress",
-    event: { accelKey: true },
-    check: checkCurrent
-  }
-]
-
-let gGoButton = document.getElementById("urlbar-go-button");
-function triggerCommand(aClick, aEvent) {
-  gURLBar.value = TEST_VALUE;
-  gURLBar.focus();
-
-  if (aClick) {
-    is(gURLBar.getAttribute("pageproxystate"), "invalid",
-       "page proxy state must be invalid for go button to be visible");
-    EventUtils.synthesizeMouseAtCenter(gGoButton, aEvent);
-  }
-  else
-    EventUtils.synthesizeKey("VK_RETURN", aEvent);
-}
-
-/* Checks that the URL was loaded in the current tab */
-function checkCurrent(aTab) {
-  info("URL should be loaded in the current tab");
-  is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
-  is(gFocusManager.focusedElement, null, "There should be no focused element");
-  is(gFocusManager.focusedWindow, gBrowser.contentWindow, "Content window should be focused");
-  is(gBrowser.selectedTab, aTab, "New URL was loaded in the current tab");
-}
-
-/* Checks that the URL was loaded in a new focused tab */
-function checkNewTab(aTab) {
-  info("URL should be loaded in a new focused tab");
-  is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
-  is(gFocusManager.focusedElement, null, "There should be no focused element");
-  is(gFocusManager.focusedWindow, gBrowser.contentWindow, "Content window should be focused");
-  isnot(gBrowser.selectedTab, aTab, "New URL was loaded in a new tab");
-}
-
-function addPageShowListener(browser, cb, expectedURL) {
-  browser.addEventListener("pageshow", function pageShowListener() {
-    info("pageshow: " + browser.currentURI.spec);
-    if (expectedURL && browser.currentURI.spec != expectedURL)
-      return; // ignore pageshows for non-expected URLs
-    browser.removeEventListener("pageshow", pageShowListener, false);
-    cb();
+function promiseNewTabSelected() {
+  return new Promise(resolve => {
+    gBrowser.tabContainer.addEventListener("TabSelect", function onSelect() {
+      gBrowser.tabContainer.removeEventListener("TabSelect", onSelect);
+      resolve();
+    });
   });
 }
 
-function whenWindowActivated(win, cb) {
-  if (Services.focus.activeWindow == win) {
-    executeSoon(cb);
-    return;
-  }
+function promiseWaitForNewWindow() {
+  return new Promise(resolve => {
+    let listener = {
+      onOpenWindow(xulWindow) {
+        let win = xulWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+                           .getInterface(Ci.nsIDOMWindow);
 
-  win.addEventListener("activate", function onActivate() {
-    win.removeEventListener("activate", onActivate);
-    executeSoon(cb);
+        Services.wm.removeListener(listener);
+        whenDelayedStartupFinished(win, () => resolve(win));
+      },
+
+      onCloseWindow() {},
+      onWindowTitleChange() {}
+    };
+
+    Services.wm.addListener(listener);
   });
 }
-
-function BrowserWindowListener(aURL, aCallback) {
-  this.callback = aCallback;
-  this.url = aURL;
-}
-BrowserWindowListener.prototype = {
-  onOpenWindow: function(aXULWindow) {
-    let cb = () => this.callback(domwindow);
-    let domwindow = aXULWindow.QueryInterface(Ci.nsIInterfaceRequestor)
-                              .getInterface(Ci.nsIDOMWindow);
-
-    let numWait = 2;
-    function maybeRunCallback() {
-      if (--numWait == 0)
-        cb();
-    }
-
-    whenWindowActivated(domwindow, maybeRunCallback);
-    whenDelayedStartupFinished(domwindow, maybeRunCallback);
-  },
-  onCloseWindow: function(aXULWindow) {},
-  onWindowTitleChange: function(aXULWindow, aNewTitle) {}
-}
--- a/browser/base/content/test/general/browser_urlbarEnter.js
+++ b/browser/base/content/test/general/browser_urlbarEnter.js
@@ -1,69 +1,40 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
+"use strict";
+
 const TEST_VALUE = "example.com/\xF7?\xF7";
 const START_VALUE = "example.com/%C3%B7?%C3%B7";
 
-function test() {
-  waitForExplicitFinish();
-  runNextTest();
-}
+add_task(function* () {
+  info("Simple return keypress");
+  let tab = gBrowser.selectedTab = gBrowser.addTab(START_VALUE);
 
-function locationBarEnter(aEvent, aClosure) {
-  executeSoon(function() {
-    gURLBar.focus();
-    EventUtils.synthesizeKey("VK_RETURN", aEvent);
-    addPageShowListener(aClosure);
-  });
-}
+  gURLBar.focus();
+  EventUtils.synthesizeKey("VK_RETURN", {});
+  yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
-function runNextTest() {
-  let test = gTests.shift();
-  if (!test) {
-    finish();
-    return;
-  }
-  
-  info("Running test: " + test.desc);
-  let tab = gBrowser.selectedTab = gBrowser.addTab(START_VALUE);
-  addPageShowListener(function() {
-    locationBarEnter(test.event, function() {
-      test.check(tab);
+  // Check url bar and selected tab.
+  is(gURLBar.textValue, TEST_VALUE, "Urlbar should preserve the value on return keypress");
+  is(gBrowser.selectedTab, tab, "New URL was loaded in the current tab");
+
+  // Cleanup.
+  gBrowser.removeCurrentTab();
+});
 
-      // Clean up
-      while (gBrowser.tabs.length > 1)
-        gBrowser.removeTab(gBrowser.selectedTab)
-      runNextTest();
-    });
-  });
-}
+add_task(function* () {
+  info("Alt+Return keypress");
+  let tab = gBrowser.selectedTab = gBrowser.addTab(START_VALUE);
 
-let gTests = [
-  { desc: "Simple return keypress",
-    event: {},
-    check: checkCurrent
-  },
+  gURLBar.focus();
+  EventUtils.synthesizeKey("VK_RETURN", {altKey: true});
+  yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
-  { desc: "Alt+Return keypress",
-    event: { altKey: true },
-    check: checkNewTab,
-  },
-]
-
-function checkCurrent(aTab) {
+  // Check url bar and selected tab.
   is(gURLBar.textValue, TEST_VALUE, "Urlbar should preserve the value on return keypress");
-  is(gBrowser.selectedTab, aTab, "New URL was loaded in the current tab");
-}
+  isnot(gBrowser.selectedTab, tab, "New URL was loaded in a new tab");
 
-function checkNewTab(aTab) {
-  is(gURLBar.textValue, TEST_VALUE, "Urlbar should preserve the value on return keypress");
-  isnot(gBrowser.selectedTab, aTab, "New URL was loaded in a new tab");
-}
-
-function addPageShowListener(aFunc) {
-  gBrowser.selectedBrowser.addEventListener("pageshow", function loadListener() {
-    gBrowser.selectedBrowser.removeEventListener("pageshow", loadListener, false);
-    aFunc();
-  });
-}
-
+  // Cleanup.
+  gBrowser.removeTab(tab);
+  gBrowser.removeCurrentTab();
+});
--- a/browser/base/content/test/general/browser_urlbarSearchSingleWordNotification.js
+++ b/browser/base/content/test/general/browser_urlbarSearchSingleWordNotification.js
@@ -55,23 +55,25 @@ function* runURLBarSearchTest(valueToOpe
 
   yield Promise.all([
     docLoadPromise,
     promiseNotificationForTab(aWindow.gBrowser, "keyword-uri-fixup", expectNotification)
   ]);
 }
 
 add_task(function* test_navigate_full_domain() {
-  let tab = gBrowser.selectedTab = gBrowser.addTab();
+  let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
   yield* runURLBarSearchTest("www.mozilla.org", false, false);
   gBrowser.removeTab(tab);
 });
 
 add_task(function* test_navigate_numbers() {
-  let tab = gBrowser.selectedTab = gBrowser.addTab();
+  let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
   yield* runURLBarSearchTest("1234", true, false);
   gBrowser.removeTab(tab);
 });
 
 function get_test_function_for_localhost_with_hostname(hostName, isPrivate) {
   return function* test_navigate_single_host() {
     const pref = "browser.fixup.domainwhitelist.localhost";
     let win;
@@ -79,17 +81,18 @@ function get_test_function_for_localhost
       win = yield promiseOpenAndLoadWindow({private: true}, true);
       let deferredOpenFocus = Promise.defer();
       waitForFocus(deferredOpenFocus.resolve, win);
       yield deferredOpenFocus.promise;
     } else {
       win = window;
     }
     let browser = win.gBrowser;
-    let tab = browser.selectedTab = browser.addTab();
+    let tab = browser.selectedTab = browser.addTab("about:blank");
+    yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
 
     Services.prefs.setBoolPref(pref, false);
     yield* runURLBarSearchTest(hostName, true, true, win);
 
     let notificationBox = browser.getNotificationBox(tab.linkedBrowser);
     let notification = notificationBox.getNotificationWithValue("keyword-uri-fixup");
     let docLoadPromise = waitForDocLoadAndStopIt("http://" + hostName + "/", tab.linkedBrowser);
     notification.querySelector(".notification-button-default").click();
@@ -97,17 +100,18 @@ function get_test_function_for_localhost
     // check pref value
     let prefValue = Services.prefs.getBoolPref(pref);
     is(prefValue, !isPrivate, "Pref should have the correct state.");
 
     yield docLoadPromise;
     browser.removeTab(tab);
 
     // Now try again with the pref set.
-    tab = browser.selectedTab = browser.addTab();
+    tab = browser.selectedTab = browser.addTab("about:blank");
+    yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
     // In a private window, the notification should appear again.
     yield* runURLBarSearchTest(hostName, isPrivate, isPrivate, win);
     browser.removeTab(tab);
     if (isPrivate) {
       info("Waiting for private window to close");
       yield promiseWindowClosed(win);
       let deferredFocus = Promise.defer();
       info("Waiting for focus");
@@ -117,12 +121,13 @@ function get_test_function_for_localhost
   }
 }
 
 add_task(get_test_function_for_localhost_with_hostname("localhost"));
 add_task(get_test_function_for_localhost_with_hostname("localhost."));
 add_task(get_test_function_for_localhost_with_hostname("localhost", true));
 
 add_task(function* test_navigate_invalid_url() {
-  let tab = gBrowser.selectedTab = gBrowser.addTab();
+  let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
   yield* runURLBarSearchTest("mozilla is awesome", true, false);
   gBrowser.removeTab(tab);
 });
--- a/browser/base/content/test/general/browser_urlbarStop.js
+++ b/browser/base/content/test/general/browser_urlbarStop.js
@@ -1,40 +1,69 @@
+"use strict";
+
 const goodURL = "http://mochi.test:8888/";
 const badURL = "http://mochi.test:8888/whatever.html";
 
-function test() {
-  waitForExplicitFinish();
-
+add_task(function* () {
   gBrowser.selectedTab = gBrowser.addTab(goodURL);
-  gBrowser.selectedBrowser.addEventListener("load", onload, true);
-}
-
-function onload() {
-  gBrowser.selectedBrowser.removeEventListener("load", onload, true);
-
+  yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
   is(gURLBar.textValue, gURLBar.trimValue(goodURL), "location bar reflects loaded page");
 
-  typeAndSubmit(badURL);
-  is(gURLBar.textValue, gURLBar.trimValue(badURL), "location bar reflects loading page");
-
-  gBrowser.contentWindow.stop();
+  yield typeAndSubmitAndStop(badURL);
   is(gURLBar.textValue, gURLBar.trimValue(goodURL), "location bar reflects loaded page after stop()");
   gBrowser.removeCurrentTab();
 
   gBrowser.selectedTab = gBrowser.addTab("about:blank");
   is(gURLBar.textValue, "", "location bar is empty");
 
-  typeAndSubmit(badURL);
-  is(gURLBar.textValue, gURLBar.trimValue(badURL), "location bar reflects loading page");
-
-  gBrowser.contentWindow.stop();
+  yield typeAndSubmitAndStop(badURL);
   is(gURLBar.textValue, gURLBar.trimValue(badURL), "location bar reflects stopped page in an empty tab");
   gBrowser.removeCurrentTab();
+});
 
-  finish();
+function typeAndSubmitAndStop(url) {
+  gBrowser.userTypedValue = url;
+  URLBarSetURI();
+  is(gURLBar.textValue, gURLBar.trimValue(url), "location bar reflects loading page");
+
+  let promise = waitForDocLoadAndStopIt();
+  gURLBar.handleCommand();
+  return promise;
 }
 
-function typeAndSubmit(value) {
-  gBrowser.userTypedValue = value;
-  URLBarSetURI();
-  gURLBar.handleCommand();
+function waitForDocLoadAndStopIt() {
+  function content_script() {
+    const {interfaces: Ci, utils: Cu} = Components;
+    Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+    let progressListener = {
+      onStateChange(webProgress, req, flags, status) {
+        if (flags & Ci.nsIWebProgressListener.STATE_START) {
+          wp.removeProgressListener(progressListener);
+
+          /* Hammer time. */
+          content.stop();
+
+          /* Let the parent know we're done. */
+          sendAsyncMessage("{MSG}");
+        }
+      },
+
+      QueryInterface: XPCOMUtils.generateQI(["nsISupportsWeakReference"])
+    };
+
+    let wp = docShell.QueryInterface(Ci.nsIWebProgress);
+    wp.addProgressListener(progressListener, wp.NOTIFY_ALL);
+  }
+
+  return new Promise(resolve => {
+    const MSG = "test:waitForDocLoadAndStopIt";
+    const SCRIPT = content_script.toString().replace("{MSG}", MSG);
+
+    let mm = gBrowser.selectedBrowser.messageManager;
+    mm.loadFrameScript("data:,(" + SCRIPT + ")();", true);
+    mm.addMessageListener(MSG, function onComplete() {
+      mm.removeMessageListener(MSG, onComplete);
+      resolve();
+    });
+  });
 }
--- a/browser/base/content/test/general/browser_urlbar_search_healthreport.js
+++ b/browser/base/content/test/general/browser_urlbar_search_healthreport.js
@@ -34,17 +34,18 @@ add_task(function* test_healthreport_sea
 
   if (data.days.hasDay(now)) {
     let day = data.days.getDay(now);
     if (day.has(field)) {
       oldCount = day.get(field);
     }
   }
 
-  let tab = gBrowser.addTab();
+  let tab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
   gBrowser.selectedTab = tab;
 
   let searchStr = "firefox health report";
   let expectedURL = Services.search.currentEngine.
                     getSubmission(searchStr, "", "keyword").uri.spec;
 
   // Expect the search URL to load but stop it as soon as it starts.
   let docLoadPromise = waitForDocLoadAndStopIt(expectedURL);
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -353,28 +353,20 @@
             };
 
             // Focus the content area before triggering loads, since if the load
             // occurs in a new tab, we want focus to be restored to the content
             // area when the current tab is re-selected.
             gBrowser.selectedBrowser.focus();
 
             let isMouseEvent = aTriggeringEvent instanceof MouseEvent;
-            let altEnter = !isMouseEvent && aTriggeringEvent && aTriggeringEvent.altKey;
-
-            if (altEnter) {
-              // XXX This was added a long time ago, and I'm not sure why it is
-              // necessary. Alt+Enter's default action might cause a system beep,
-              // or something like that?
-              aTriggeringEvent.preventDefault();
-              aTriggeringEvent.stopPropagation();
-            }
 
             // If the current tab is empty, ignore Alt+Enter (just reuse this tab)
-            altEnter = altEnter && !isTabEmpty(gBrowser.selectedTab);
+            let altEnter = !isMouseEvent && aTriggeringEvent &&
+              aTriggeringEvent.altKey && !isTabEmpty(gBrowser.selectedTab);
 
             if (isMouseEvent || altEnter) {
               // Use the standard UI link behaviors for clicks or Alt+Enter
               let where = "tab";
               if (isMouseEvent)
                 where = whereToOpenLink(aTriggeringEvent, false, false);
 
               if (where == "current") {
@@ -1568,26 +1560,27 @@
     </implementation>
   </binding>
 
   <binding id="addon-progress-notification" extends="chrome://global/content/bindings/notification.xml#popup-notification">
     <content align="start">
       <xul:image class="popup-notification-icon"
                  xbl:inherits="popupid,src=icon"/>
       <xul:vbox flex="1">
-        <xul:description class="popup-notification-description addon-progress-description"
-                         xbl:inherits="xbl:text=label"/>
+        <xul:label class="popup-notification-originHost header"
+                   xbl:inherits="value=originhost"
+                   crop="end"/>
+        <xul:description class="popup-notification-description"
+                         xbl:inherits="xbl:text=label,popupid"/>
+        <xul:progressmeter anonid="progressmeter" flex="1" mode="undetermined" class="popup-progress-meter"/>
+        <xul:label anonid="progresstext" class="popup-progress-label" flex="1" crop="end"/>
         <xul:spacer flex="1"/>
-        <xul:hbox align="center">
-          <xul:progressmeter anonid="progressmeter" flex="1" mode="undetermined" class="popup-progress-meter"/>
-          <xul:button anonid="cancel" class="popup-progress-cancel" oncommand="document.getBindingParent(this).cancel()"/>
-        </xul:hbox>
-        <xul:label anonid="progresstext" class="popup-progress-label"/>
         <xul:hbox class="popup-notification-button-container"
                   pack="end" align="center">
+          <children includes="button"/>
           <xul:button anonid="button"
                       class="popup-notification-menubutton"
                       type="menu-button"
                       xbl:inherits="oncommand=buttoncommand,label=buttonlabel,accesskey=buttonaccesskey">
             <xul:menupopup anonid="menupopup"
                            xbl:inherits="oncommand=menucommand">
               <children/>
               <xul:menuitem class="menuitem-iconic popup-notification-closeitem close-icon"
@@ -1601,17 +1594,18 @@
         <xul:toolbarbutton anonid="closebutton"
                            class="messageCloseButton close-icon popup-notification-closebutton tabbable"
                            xbl:inherits="oncommand=closebuttoncommand"
                            tooltiptext="&closeNotification.tooltip;"/>
       </xul:vbox>
     </content>
     <implementation>
       <constructor><![CDATA[
-        this.cancelbtn.setAttribute("tooltiptext", gNavigatorBundle.getString("addonDownloadCancelTooltip"));
+        if (!this.notification)
+          return;
 
         this.notification.options.installs.forEach(function(aInstall) {
           aInstall.addListener(this);
         }, this);
 
         // Calling updateProgress can sometimes cause this notification to be
         // removed in the middle of refreshing the notification panel which
         // makes the panel get refreshed again. Just initialise to the
@@ -1626,27 +1620,27 @@
       ]]></destructor>
 
       <field name="progressmeter" readonly="true">
         document.getAnonymousElementByAttribute(this, "anonid", "progressmeter");
       </field>
       <field name="progresstext" readonly="true">
         document.getAnonymousElementByAttribute(this, "anonid", "progresstext");
       </field>
-      <field name="cancelbtn" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "cancel");
-      </field>
       <field name="DownloadUtils" readonly="true">
         let utils = {};
         Components.utils.import("resource://gre/modules/DownloadUtils.jsm", utils);
         utils.DownloadUtils;
       </field>
 
       <method name="destroy">
         <body><![CDATA[
+          if (!this.notification)
+            return;
+
           this.notification.options.installs.forEach(function(aInstall) {
             aInstall.removeListener(this);
           }, this);
           clearTimeout(this._updateProgressTimeout);
         ]]></body>
       </method>
 
       <method name="setProgress">
@@ -1681,83 +1675,64 @@
             speed = speed * 0.9 + this.notification.speed * 0.1;
 
           this.notification.lastUpdate = now;
           this.notification.lastProgress = aProgress;
           this.notification.speed = speed;
 
           let status = null;
           [status, this.notification.last] = this.DownloadUtils.getDownloadStatus(aProgress, aMaxProgress, speed, this.notification.last);
-          this.progresstext.value = status;
+          this.progresstext.value = this.progresstext.tooltipText = status;
         ]]></body>
       </method>
 
       <method name="cancel">
         <body><![CDATA[
-          // Cache these as cancelling the installs will remove this
-          // notification which will drop these references
-          let browser = this.notification.browser;
-          let sourceURI = this.notification.options.sourceURI;
-
           let installs = this.notification.options.installs;
           installs.forEach(function(aInstall) {
             try {
               aInstall.cancel();
             }
             catch (e) {
               // Cancel will throw if the download has already failed
             }
           }, this);
 
-          let anchorID = "addons-notification-icon";
-          let notificationID = "addon-install-cancelled";
-          let messageString = gNavigatorBundle.getString("addonDownloadCancelled");
-          messageString = PluralForm.get(installs.length, messageString);
-          let buttonText = gNavigatorBundle.getString("addonDownloadRestart");
-          buttonText = PluralForm.get(installs.length, buttonText);
-
-          let action = {
-            label: buttonText,
-            accessKey: gNavigatorBundle.getString("addonDownloadRestart.accessKey"),
-            callback: function() {
-              let weblistener = Cc["@mozilla.org/addons/web-install-listener;1"].
-                                getService(Ci.amIWebInstallListener);
-              if (weblistener.onWebInstallRequested(browser, sourceURI,
-                                                    installs, installs.length)) {
-                installs.forEach(function(aInstall) {
-                  aInstall.install();
-                });
-              }
-            }
-          };
-
-          PopupNotifications.show(browser, notificationID, messageString,
-                                  anchorID, action);
+          PopupNotifications.remove(this.notification);
         ]]></body>
       </method>
 
       <method name="updateProgress">
         <body><![CDATA[
+          if (!this.notification)
+            return;
+
           let downloadingCount = 0;
           let progress = 0;
           let maxProgress = 0;
 
           this.notification.options.installs.forEach(function(aInstall) {
             if (aInstall.maxProgress == -1)
               maxProgress = -1;
             progress += aInstall.progress;
             if (maxProgress >= 0)
               maxProgress += aInstall.maxProgress;
             if (aInstall.state < AddonManager.STATE_DOWNLOADED)
               downloadingCount++;
           });
 
           if (downloadingCount == 0) {
             this.destroy();
-            PopupNotifications.remove(this.notification);
+            if (Preferences.get("xpinstall.customConfirmationUI", false)) {
+              this.progressmeter.mode = "undetermined";
+              this.progresstext.value = this.progresstext.tooltipText =
+                gNavigatorBundle.getString("addonDownloadVerifying");
+            } else {
+              PopupNotifications.remove(this.notification);
+            }
           }
           else {
             this.setProgress(progress, maxProgress);
           }
         ]]></body>
       </method>
 
       <method name="onDownloadProgress">
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -16,16 +16,18 @@ Cu.import("resource:///modules/loop/Loop
 Cu.importGlobalProperties(["Blob"]);
 
 XPCOMUtils.defineLazyModuleGetter(this, "LoopContacts",
                                         "resource:///modules/loop/LoopContacts.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
                                         "resource:///modules/loop/LoopStorage.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "hookWindowCloseForPanelClose",
                                         "resource://gre/modules/MozSocialAPI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PageMetadata",
+                                        "resource://gre/modules/PageMetadata.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
                                         "resource://gre/modules/PluralForm.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "UITour",
                                         "resource:///modules/UITour.jsm");
 XPCOMUtils.defineLazyGetter(this, "appInfo", function() {
   return Cc["@mozilla.org/xre/app-info;1"]
            .getService(Ci.nsIXULAppInfo)
            .QueryInterface(Ci.nsIXULRuntime);
@@ -840,16 +842,34 @@ function injectLoopAPI(targetWindow) {
         let md5Email = [toHexString(hash.charCodeAt(i)) for (i in hash)].join("");
 
         // Compose the Gravatar URL.
         return "https://www.gravatar.com/avatar/" + md5Email + ".jpg?default=blank&s=" + size;
       }
     },
 
     /**
+     * Gets the metadata related to the currently selected tab in
+     * the most recent window.
+     *
+     * @param {Function} A callback that is passed the metadata.
+     */
+    getSelectedTabMetadata: {
+      value: function(callback) {
+        let win = Services.wm.getMostRecentWindow("navigator:browser");
+        win.messageManager.addMessageListener("PageMetadata:PageDataResult", function onPageDataResult(msg) {
+          win.messageManager.removeMessageListener("PageMetadata:PageDataResult", onPageDataResult);
+          let pageData = msg.json;
+          callback(cloneValueInto(pageData, targetWindow));
+        });
+        win.gBrowser.selectedBrowser.messageManager.sendAsyncMessage("PageMetadata:GetPageData");
+      }
+    },
+
+    /**
      * Associates a session-id and a call-id with a window for debugging.
      *
      * @param  {string}  windowId  The window id.
      * @param  {string}  sessionId OT session id.
      * @param  {string}  callId    The callId on the server.
      */
     addConversationContext: {
       enumerable: true,
--- a/browser/components/loop/content/css/panel.css
+++ b/browser/components/loop/content/css/panel.css
@@ -165,43 +165,91 @@ body {
 .content-area input:not(.pristine):invalid {
   border-color: #d74345;
   box-shadow: 0 0 4px #c43c3e;
 }
 
 /* Rooms */
 .rooms {
   min-height: 100px;
+  padding: 0 1rem;
 }
 
 .rooms > h1 {
   font-weight: bold;
   color: #999;
-  padding: .5rem 1rem;
+  padding: .5rem 0;
+}
+
+.rooms > div > .context {
+  margin: .5rem 0 0;
+  background-color: #DEEFF7;
+  border-radius: 3px 3px 0 0;
+  padding: .5rem;
+}
+
+.rooms > div > .context > .context-enabled {
+  margin-bottom: .5rem;
+  display: block;
+}
+
+.rooms > div > .context > .context-enabled > input {
+  -moz-margin-start: 0;
+}
+
+.rooms > div > .context > .context-preview {
+  float: right;
+  width: 100px;
+  max-height: 200px;
+  -moz-margin-start: 10px;
+  margin-bottom: 10px;
 }
 
-.rooms > p {
-  padding: .5rem 0;
-  margin: 0;
+body[dir=rtl] .rooms > div > .context > .context-preview {
+  float: left;
+}
+
+.rooms > div > .context > .context-preview[src=""] {
+  display: none;
+}
+
+.rooms > div > .context > .context-description {
+  display: block;
+  color: #707070;
 }
 
-.rooms > p > .btn {
+.rooms > div > .context > .context-url {
+  display: block;
+  color: #59A1D7;
+  clear: both;
+}
+
+.rooms > div > .btn {
   display: block;
   font-size: 1rem;
-  margin: 0 auto;
+  margin: 0 auto .5rem;
+  width: 100%;
   padding: .5rem 1rem;
+  border-radius: 0 0 3px 3px;
+}
+
+/* Remove when bug 1142671 is backed out. */
+.rooms > div > :not(.context) + .btn {
   border-radius: 3px;
+  margin-top: 0.5rem;
 }
 
 .room-list {
   max-height: 335px; /* XXX better computation needed */
   min-height: 7px;
   overflow: auto;
   border-top: 1px solid #ccc;
   border-bottom: 1px solid #ccc;
+  margin-left: -1rem;
+  margin-right: -1rem;
 }
 
 .room-list:empty {
   border-bottom-width: 0;
 }
 
 .room-list > .room-entry {
   padding: .5rem 1rem;
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -589,16 +589,17 @@ loop.panel = (function(_, mozL10n) {
 
   /**
    * Room list.
    */
   var RoomList = React.createClass({displayName: "RoomList",
     mixins: [Backbone.Events, sharedMixins.WindowCloseMixin],
 
     propTypes: {
+      mozLoop: React.PropTypes.object.isRequired,
       store: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       userDisplayName: React.PropTypes.string.isRequired  // for room creation
     },
 
     getInitialState: function() {
       return this.props.store.getStoreState();
     },
@@ -661,29 +662,84 @@ loop.panel = (function(_, mozL10n) {
             this.state.rooms.map(function(room, i) {
               return React.createElement(RoomEntry, {
                 key: room.roomToken, 
                 dispatcher: this.props.dispatcher, 
                 room: room}
               );
             }, this)
           ), 
-          React.createElement("p", null, 
+          React.createElement("div", null, 
+            React.createElement(ContextInfo, {mozLoop: this.props.mozLoop}), 
             React.createElement("button", {className: "btn btn-info new-room-button", 
                     onClick: this.handleCreateButtonClick, 
                     disabled: this._hasPendingOperation()}, 
               mozL10n.get("rooms_new_room_button_label")
             )
           )
         )
       );
     }
   });
 
   /**
+   * Context info that is offered to be part of a Room.
+   */
+  var ContextInfo = React.createClass({displayName: "ContextInfo",
+    propTypes: {
+      mozLoop: React.PropTypes.object.isRequired,
+    },
+
+    mixins: [sharedMixins.DocumentVisibilityMixin],
+
+    getInitialState: function() {
+      return {
+        previewImage: "",
+        description: "",
+        url: ""
+      };
+    },
+
+    onDocumentVisible: function() {
+      this.props.mozLoop.getSelectedTabMetadata(function callback(metadata) {
+        var previewImage = metadata.previews.length ? metadata.previews[0] : "";
+        var description = metadata.description || metadata.title;
+        var url = metadata.url;
+        this.setState({previewImage: previewImage,
+                       description: description,
+                       url: url});
+      }.bind(this));
+    },
+
+    onDocumentHidden: function() {
+      this.setState({previewImage: "",
+                     description: "",
+                     url: ""});
+    },
+
+    render: function() {
+      if (!this.props.mozLoop.getLoopPref("contextInConverations.enabled") ||
+          !this.state.url) {
+        return null;
+      }
+      return (
+        React.createElement("div", {className: "context"}, 
+          React.createElement("label", {className: "context-enabled"}, 
+            React.createElement("input", {type: "checkbox"}), 
+            mozL10n.get("context_offer_label")
+          ), 
+          React.createElement("img", {className: "context-preview", src: this.state.previewImage}), 
+          React.createElement("span", {className: "context-description"}, this.state.description), 
+          React.createElement("span", {className: "context-url"}, this.state.url)
+        )
+      );
+    }
+  });
+
+  /**
    * Panel view.
    */
   var PanelView = React.createClass({displayName: "PanelView",
     propTypes: {
       notifications: React.PropTypes.object.isRequired,
       // Mostly used for UI components showcase and unit tests
       userProfile: React.PropTypes.object,
       // Used only for unit tests.
@@ -814,17 +870,18 @@ loop.panel = (function(_, mozL10n) {
         React.createElement("div", null, 
           React.createElement(NotificationListView, {notifications: this.props.notifications, 
                                 clearOnDocumentHidden: true}), 
           React.createElement(TabView, {ref: "tabView", selectedTab: this.props.selectedTab, 
             buttonsHidden: hideButtons, mozLoop: this.props.mozLoop}, 
             React.createElement(Tab, {name: "rooms"}, 
               React.createElement(RoomList, {dispatcher: this.props.dispatcher, 
                         store: this.props.roomStore, 
-                        userDisplayName: this._getUserDisplayName()}), 
+                        userDisplayName: this._getUserDisplayName(), 
+                        mozLoop: this.props.mozLoop}), 
               React.createElement(ToSView, null)
             ), 
             React.createElement(Tab, {name: "contacts"}, 
               React.createElement(ContactsList, {selectTab: this.selectTab, 
                             startForm: this.startForm, 
                             notifications: this.props.notifications})
             ), 
             React.createElement(Tab, {name: "contacts_add", hidden: true}, 
@@ -885,16 +942,17 @@ loop.panel = (function(_, mozL10n) {
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
+    ContextInfo: ContextInfo,
     GettingStartedView: GettingStartedView,
     PanelView: PanelView,
     RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView,
     UserIdentity: UserIdentity,
   };
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -589,16 +589,17 @@ loop.panel = (function(_, mozL10n) {
 
   /**
    * Room list.
    */
   var RoomList = React.createClass({
     mixins: [Backbone.Events, sharedMixins.WindowCloseMixin],
 
     propTypes: {
+      mozLoop: React.PropTypes.object.isRequired,
       store: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       userDisplayName: React.PropTypes.string.isRequired  // for room creation
     },
 
     getInitialState: function() {
       return this.props.store.getStoreState();
     },
@@ -661,23 +662,78 @@ loop.panel = (function(_, mozL10n) {
             this.state.rooms.map(function(room, i) {
               return <RoomEntry
                 key={room.roomToken}
                 dispatcher={this.props.dispatcher}
                 room={room}
               />;
             }, this)
           }</div>
-          <p>
+          <div>
+            <ContextInfo mozLoop={this.props.mozLoop} />
             <button className="btn btn-info new-room-button"
                     onClick={this.handleCreateButtonClick}
                     disabled={this._hasPendingOperation()}>
               {mozL10n.get("rooms_new_room_button_label")}
             </button>
-          </p>
+          </div>
+        </div>
+      );
+    }
+  });
+
+  /**
+   * Context info that is offered to be part of a Room.
+   */
+  var ContextInfo = React.createClass({
+    propTypes: {
+      mozLoop: React.PropTypes.object.isRequired,
+    },
+
+    mixins: [sharedMixins.DocumentVisibilityMixin],
+
+    getInitialState: function() {
+      return {
+        previewImage: "",
+        description: "",
+        url: ""
+      };
+    },
+
+    onDocumentVisible: function() {
+      this.props.mozLoop.getSelectedTabMetadata(function callback(metadata) {
+        var previewImage = metadata.previews.length ? metadata.previews[0] : "";
+        var description = metadata.description || metadata.title;
+        var url = metadata.url;
+        this.setState({previewImage: previewImage,
+                       description: description,
+                       url: url});
+      }.bind(this));
+    },
+
+    onDocumentHidden: function() {
+      this.setState({previewImage: "",
+                     description: "",
+                     url: ""});
+    },
+
+    render: function() {
+      if (!this.props.mozLoop.getLoopPref("contextInConverations.enabled") ||
+          !this.state.url) {
+        return null;
+      }
+      return (
+        <div className="context">
+          <label className="context-enabled">
+            <input type="checkbox"/>
+            {mozL10n.get("context_offer_label")}
+          </label>
+          <img className="context-preview" src={this.state.previewImage}/>
+          <span className="context-description">{this.state.description}</span>
+          <span className="context-url">{this.state.url}</span>
         </div>
       );
     }
   });
 
   /**
    * Panel view.
    */
@@ -814,17 +870,18 @@ loop.panel = (function(_, mozL10n) {
         <div>
           <NotificationListView notifications={this.props.notifications}
                                 clearOnDocumentHidden={true} />
           <TabView ref="tabView" selectedTab={this.props.selectedTab}
             buttonsHidden={hideButtons} mozLoop={this.props.mozLoop}>
             <Tab name="rooms">
               <RoomList dispatcher={this.props.dispatcher}
                         store={this.props.roomStore}
-                        userDisplayName={this._getUserDisplayName()}/>
+                        userDisplayName={this._getUserDisplayName()}
+                        mozLoop={this.props.mozLoop}/>
               <ToSView />
             </Tab>
             <Tab name="contacts">
               <ContactsList selectTab={this.selectTab}
                             startForm={this.startForm}
                             notifications={this.props.notifications} />
             </Tab>
             <Tab name="contacts_add" hidden={true}>
@@ -885,16 +942,17 @@ loop.panel = (function(_, mozL10n) {
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
+    ContextInfo: ContextInfo,
     GettingStartedView: GettingStartedView,
     PanelView: PanelView,
     RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView,
     UserIdentity: UserIdentity,
   };
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -328,16 +328,20 @@ loop.store.ActiveRoomStore = (function()
       this.setStoreState({
         apiKey: actionData.apiKey,
         sessionToken: actionData.sessionToken,
         sessionId: actionData.sessionId,
         roomState: ROOM_STATES.JOINED
       });
 
       this._setRefreshTimeout(actionData.expires);
+
+      // Only send media telemetry on one side of the call: the desktop side.
+      actionData["sendTwoWayMediaTelemetry"] = this._isDesktop;
+
       this._sdkDriver.connectSession(actionData);
 
       this._mozLoop.addConversationContext(this._storeState.windowId,
                                            actionData.sessionId, "");
 
       // If we haven't got a room name yet, go and get one. We typically
       // need to do this in the case of the standalone window.
       // XXX When bug 1103331 lands this can be moved to earlier.
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -265,17 +265,18 @@ loop.store = loop.store || {};
      * sdk, and setting the state appropriately.
      */
     _startCallConnection: function() {
       var state = this.getStoreState();
 
       this.sdkDriver.connectSession({
         apiKey: state.apiKey,
         sessionId: state.sessionId,
-        sessionToken: state.sessionToken
+        sessionToken: state.sessionToken,
+        sendTwoWayMediaTelemetry: state.outgoing // only one side of the call
       });
       this.mozLoop.addConversationContext(
         state.windowId,
         state.sessionId,
         state.callId);
       this.setStoreState({callState: CALL_STATES.ONGOING});
     },
 
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -32,17 +32,16 @@ loop.OTSdkDriver = (function() {
       if (this._isDesktop) {
         if (!options.mozLoop) {
           throw new Error("Missing option mozLoop");
         }
         this.mozLoop = options.mozLoop;
       }
 
       this.connections = {};
-      this._setTwoWayMediaStartTime(this.CONNECTION_START_TIME_UNINITIALIZED);
 
       this.dispatcher.register(this, [
         "setupStreamElements",
         "setMute"
       ]);
 
       // Set loop.debug.twoWayMediaTelemetry to true in the browser
       // by changing the hidden pref loop.debug.twoWayMediaTelemetry using
@@ -205,22 +204,30 @@ loop.OTSdkDriver = (function() {
 
     /**
      * Connects a session for the SDK, listening to the required events.
      *
      * sessionData items:
      * - sessionId: The OT session ID
      * - apiKey: The OT API key
      * - sessionToken: The token for the OT session
+     * - sendTwoWayMediaTelemetry: boolean should we send telemetry on length
+     *                             of media sessions.  Callers should ensure
+     *                             that this is only set for one side of the
+     *                             session so that things don't get
+     *                             double-counted.
      *
      * @param {Object} sessionData The session data for setting up the OT session.
      */
     connectSession: function(sessionData) {
       this.session = this.sdk.initSession(sessionData.sessionId);
 
+      this._sendTwoWayMediaTelemetry = !!sessionData.sendTwoWayMediaTelemetry;
+      this._setTwoWayMediaStartTime(this.CONNECTION_START_TIME_UNINITIALIZED);
+
       this.session.on("connectionCreated", this._onConnectionCreated.bind(this));
       this.session.on("streamCreated", this._onRemoteStreamCreated.bind(this));
       this.session.on("streamDestroyed", this._onRemoteStreamDestroyed.bind(this));
       this.session.on("connectionDestroyed",
         this._onConnectionDestroyed.bind(this));
       this.session.on("sessionDisconnected",
         this._onSessionDisconnected.bind(this));
       this.session.on("streamPropertyChanged", this._onStreamPropertyChanged.bind(this));
@@ -463,27 +470,27 @@ loop.OTSdkDriver = (function() {
 
     /**
      * Set and get the start time of the two-way media connection.  These
      * are done as wrapper functions so that we can log sets to make manual
      * verification of various telemetry scenarios possible.  The get API is
      * analogous in order to follow the principle of least surprise for
      * people consuming this code.
      *
-     * If this._isDesktop is not true, returns immediately without making
-     * any changes, since this data is not used, and it makes reading
-     * the logs confusing for manual verification of both ends of the call in
-     * the same browser, which is a case we care about.
+     * If this._sendTwoWayMediaTelemetry is not true, returns immediately
+     * without making any changes, since this data is not used, and it makes
+     * reading the logs confusing for manual verification of both ends of the
+     * call in the same browser, which is a case we care about.
      *
      * @param start  start time in milliseconds, as returned by
      *               performance.now()
      * @private
      */
     _setTwoWayMediaStartTime: function(start) {
-      if (!this._isDesktop) {
+      if (!this._sendTwoWayMediaTelemetry) {
         return;
       }
 
       this.__twoWayMediaStartTime = start;
       if (this._debugTwoWayMediaTelemetry) {
         console.log("Loop Telemetry: noted two-way connection start, " +
                     "start time in ms:", start);
       }
@@ -585,17 +592,17 @@ loop.OTSdkDriver = (function() {
     _maybePublishLocalStream: function() {
       if (this._sessionConnected && this._publisherReady) {
         // We are clear to publish the stream to the session.
         this.session.publish(this.publisher);
 
         // Now record the fact, and check if we've got all media yet.
         this._publishedLocalStream = true;
         if (this._checkAllStreamsConnected()) {
-          this._setTwoWayMediaStartTime(performance.now);
+          this._setTwoWayMediaStartTime(performance.now());
           this.dispatcher.dispatch(new sharedActions.MediaConnected());
         }
       }
     },
 
     /**
      * Used to check if both local and remote streams are available
      * and send an action if they are.
@@ -671,25 +678,24 @@ loop.OTSdkDriver = (function() {
         console.log('Loop Telemetry: noted two-way media connection ' +
           'in bucket: ', bucket);
       }
     },
 
     /**
      * Note connection length if it's valid (the startTime has been initialized
      * and is not later than endTime) and not yet already noted.  If
-     * this._isDesktop is not true, we're assumed to be running in the
-     * standalone client and return immediately.
+     * this._sendTwoWayMediaTelemetry is not true, we return immediately.
      *
      * @param {number} startTime  in milliseconds
      * @param {number} endTime  in milliseconds
      * @private
      */
     _noteConnectionLengthIfNeeded: function(startTime, endTime) {
-      if (!this._isDesktop) {
+      if (!this._sendTwoWayMediaTelemetry) {
         return;
       }
 
       if (startTime == this.CONNECTION_START_TIME_ALREADY_NOTED ||
           startTime == this.CONNECTION_START_TIME_UNINITIALIZED ||
           startTime > endTime) {
         if (this._debugTwoWayMediaTelemetry) {
           console.log("_noteConnectionLengthIfNeeded called with " +
--- a/browser/components/loop/run-all-loop-tests.sh
+++ b/browser/components/loop/run-all-loop-tests.sh
@@ -1,25 +1,38 @@
 #!/bin/sh
 # Run from topsrcdir, no args
 
+if [ "$1" == "--help" ]; then
+  echo "Usage: ./run-all-loop-tests.sh [options]"
+  echo "    --skip-e10s  Skips the e10s tests"
+  exit 0;
+fi
+
 set -e
 
 # Main tests
 ./mach xpcshell-test browser/components/loop/
 ./mach marionette-test browser/components/loop/manifest.ini
 
 # The browser_parsable_css.js can fail if we add some css that isn't parsable.
 #
 # The check to make sure that the media devices can be used in Loop without
 # prompting is in browser_devices_get_user_media_about_urls.js. It's possible
 # to mess this up with CSP handling, and probably other changes, too.
 
-./mach mochitest \
-  browser/components/loop/test/mochitest \
-  browser/modules/test/browser_UITour_loop.js \
+TESTS="
+  browser/components/loop/test/mochitest
+  browser/modules/test/browser_UITour_loop.js
   browser/base/content/test/general/browser_devices_get_user_media_about_urls.js
+"
+
+./mach mochitest $TESTS
+
+if [ "$1" != "--skip-e10s" ]; then
+  ./mach mochitest --e10s $TESTS
+fi
 
 # This is currently disabled because the test itself is busted.  Once bug
 # 1062821 is landed, we should see if things work again, and then re-enable it.
 # The re-enabling is tracked in bug 1113350.
 #
 #  browser/base/content/test/general/browser_parsable_css.js \
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -646,17 +646,18 @@ describe("loop.panel", function() {
       dispatch = sandbox.stub(dispatcher, "dispatch");
     });
 
     function createTestComponent() {
       return TestUtils.renderIntoDocument(
         React.createElement(loop.panel.RoomList, {
           store: roomStore,
           dispatcher: dispatcher,
-          userDisplayName: fakeEmail
+          userDisplayName: fakeEmail,
+          mozLoop: fakeMozLoop
         }));
     }
 
     it("should dispatch a GetAllRooms action on mount", function() {
       createTestComponent();
 
       sinon.assert.calledOnce(dispatch);
       sinon.assert.calledWithExactly(dispatch, new sharedActions.GetAllRooms());
@@ -703,16 +704,59 @@ describe("loop.panel", function() {
       function() {
         roomStore.setStoreState({pendingInitialRetrieval: true});
 
         var view = createTestComponent();
 
         var buttonNode = view.getDOMNode().querySelector("button[disabled]");
         expect(buttonNode).to.not.equal(null);
       });
+
+    it("should show context information when a URL is available",
+      function() {
+        navigator.mozLoop.getLoopPref = function() {
+          return true;
+        }
+
+        var view = TestUtils.renderIntoDocument(
+          React.createElement(loop.panel.ContextInfo, {
+            mozLoop: navigator.mozLoop
+          })
+        );
+        view.setState({
+          previews: [""],
+          description: "fake description",
+          url: "https://www.example.com"
+        });
+
+        var contextEnabledCheckbox = view.getDOMNode().querySelector(".context-enabled");
+        expect(contextEnabledCheckbox).to.not.equal(null);
+      });
+
+    it("should not show context information when a URL is unavailable",
+      function() {
+        navigator.mozLoop.getLoopPref = function() {
+          return true;
+        }
+
+        var view = TestUtils.renderIntoDocument(
+          React.createElement(loop.panel.ContextInfo, {
+            mozLoop: navigator.mozLoop
+          })
+        );
+        view.setState({
+          previews: [""],
+          description: "fake description",
+          url: ""
+        });
+
+        var contextInfo = view.getDOMNode();
+        expect(contextInfo).to.equal(null);
+      });
+
   });
 
   describe('loop.panel.ToSView', function() {
 
     it("should render when the value of loop.seenToS is not set", function() {
       navigator.mozLoop.getLoopPref = function(key) {
         return {
           "gettingStarted.seen": true,
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -536,16 +536,38 @@ describe("loop.store.ActiveRoomStore", f
 
       store.joinedRoom(actionData);
 
       sinon.assert.calledOnce(fakeSdkDriver.connectSession);
       sinon.assert.calledWithExactly(fakeSdkDriver.connectSession,
         actionData);
     });
 
+    it("should pass 'sendTwoWayMediaTelemetry' as true to connectSession if " +
+       "store._isDesktop is true", function() {
+      store._isDesktop = true;
+
+      store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
+
+      sinon.assert.calledOnce(fakeSdkDriver.connectSession);
+      sinon.assert.calledWithMatch(fakeSdkDriver.connectSession,
+        sinon.match.has("sendTwoWayMediaTelemetry", true));
+    });
+
+    it("should pass 'sendTwoWayTelemetry' as false to connectionSession if " +
+       "store._isDesktop is false", function() {
+      store._isDesktop = false;
+
+      store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
+
+      sinon.assert.calledOnce(fakeSdkDriver.connectSession);
+      sinon.assert.calledWithMatch(fakeSdkDriver.connectSession,
+        sinon.match.has("sendTwoWayMediaTelemetry", false));
+    });
+
     it("should call mozLoop.addConversationContext", function() {
       var actionData = new sharedActions.JoinedRoom(fakeJoinedData);
 
       store.setupWindowData(new sharedActions.SetupWindowData({
         windowId: "42",
         type: "room",
       }));
 
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -247,17 +247,18 @@ describe("loop.store.ConversationStore",
 
         store.connectionProgress(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
 
         sinon.assert.calledOnce(sdkDriver.connectSession);
         sinon.assert.calledWithExactly(sdkDriver.connectSession, {
           apiKey: "fakeKey",
           sessionId: "321456",
-          sessionToken: "341256"
+          sessionToken: "341256",
+          sendTwoWayMediaTelemetry: true
         });
       });
 
       it("should call mozLoop.addConversationContext", function() {
         store.setStoreState(fakeSessionData);
 
         store.connectionProgress(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
@@ -575,27 +576,28 @@ describe("loop.store.ConversationStore",
 
     it("should change the state to 'ongoing'", function() {
       store.acceptCall(
         new sharedActions.AcceptCall({callType: CALL_TYPES.AUDIO_ONLY}));
 
       expect(store.getStoreState("callState")).eql(CALL_STATES.ONGOING);
     });
 
-    it("should connect the session", function() {
+    it("should connect the session with sendTwoWayMediaTelemetry set as falsy", function() {
       store.setStoreState(fakeSessionData);
 
       store.acceptCall(
         new sharedActions.AcceptCall({callType: CALL_TYPES.AUDIO_ONLY}));
 
       sinon.assert.calledOnce(sdkDriver.connectSession);
       sinon.assert.calledWithExactly(sdkDriver.connectSession, {
         apiKey: "fakeKey",
         sessionId: "321456",
-        sessionToken: "341256"
+        sessionToken: "341256",
+        sendTwoWayMediaTelemetry: undefined
       });
     });
 
     it("should call mozLoop.addConversationContext", function() {
       store.setStoreState(fakeSessionData);
 
       store.acceptCall(
         new sharedActions.AcceptCall({callType: CALL_TYPES.AUDIO_ONLY}));
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -99,24 +99,16 @@ describe("loop.OTSdkDriver", function ()
       }).to.Throw(/dispatcher/);
     });
 
     it("should throw an error if the sdk is missing", function() {
       expect(function() {
         new loop.OTSdkDriver({dispatcher: dispatcher});
       }).to.Throw(/sdk/);
     });
-
-    it("should set the two-way media start time to 'uninitialized'", function() {
-      var driver = new loop.OTSdkDriver(
-        {sdk: sdk, dispatcher: dispatcher, mozLoop: mozLoop, isDesktop: true});
-
-      expect(driver._getTwoWayMediaStartTime()).to.
-        eql(driver.CONNECTION_START_TIME_UNINITIALIZED);
-    });
   });
 
   describe("#setupStreamElements", function() {
     it("should call initPublisher", function() {
       dispatcher.dispatch(new sharedActions.SetupStreamElements({
         getLocalElementFunc: function() {return fakeLocalElement;},
         getRemoteElementFunc: function() {return fakeRemoteElement;},
         publisherConfig: publisherConfig
@@ -347,16 +339,25 @@ describe("loop.OTSdkDriver", function ()
 
     it("should connect the session", function () {
       driver.connectSession(sessionData);
 
       sinon.assert.calledOnce(session.connect);
       sinon.assert.calledWith(session.connect, "1234567890", "1357924680");
     });
 
+    it("should set the two-way media start time to 'uninitialized' " +
+       "when sessionData.sendTwoWayMediaTelemetry is true'", function() {
+      driver.connectSession(_.extend(sessionData,
+                                     {sendTwoWayMediaTelemetry: true}));
+
+      expect(driver._getTwoWayMediaStartTime()).to.
+        eql(driver.CONNECTION_START_TIME_UNINITIALIZED);
+    });
+
     describe("On connection complete", function() {
       it("should publish the stream if the publisher is ready", function() {
         driver._publisherReady = true;
         session.connect.callsArg(2);
 
         driver.connectSession(sessionData);
 
         sinon.assert.calledOnce(session.publish);
@@ -393,44 +394,47 @@ describe("loop.OTSdkDriver", function ()
 
       sinon.assert.calledOnce(publisher.destroy);
     });
 
     it("should call _noteConnectionLengthIfNeeded with connection duration", function() {
       driver.session = session;
       var startTime = 1;
       var endTime = 3;
+      driver._sendTwoWayMediaTelemetry = true;
       driver._setTwoWayMediaStartTime(startTime);
       sandbox.stub(performance, "now").returns(endTime);
       sandbox.stub(driver, "_noteConnectionLengthIfNeeded");
 
       driver.disconnectSession();
 
       sinon.assert.calledWith(driver._noteConnectionLengthIfNeeded, startTime,
                               endTime);
     });
 
     it("should reset the two-way media connection start time", function() {
       driver.session = session;
       var startTime = 1;
+      driver._sendTwoWayMediaTelemetry = true;
       driver._setTwoWayMediaStartTime(startTime);
       sandbox.stub(performance, "now");
       sandbox.stub(driver, "_noteConnectionLengthIfNeeded");
 
       driver.disconnectSession();
 
       expect(driver._getTwoWayMediaStartTime()).to.
         eql(driver.CONNECTION_START_TIME_UNINITIALIZED);
     });
   });
 
   describe("#_noteConnectionLengthIfNeeded", function() {
     var startTimeMS;
     beforeEach(function() {
       startTimeMS = 1;
+      driver._sendTwoWayMediaTelemetry = true;
       driver._setTwoWayMediaStartTime(startTimeMS);
     });
 
     it("should set two-way media start time to CONNECTION_START_TIME_ALREADY_NOTED", function() {
       var endTimeMS = 3;
       driver._noteConnectionLengthIfNeeded(startTimeMS, endTimeMS);
 
       expect(driver._getTwoWayMediaStartTime()).to.
@@ -478,21 +482,21 @@ describe("loop.OTSdkDriver", function ()
       driver._noteConnectionLengthIfNeeded(startTimeMS, endTimeMS);
 
       sinon.assert.calledOnce(mozLoop.telemetryAddKeyedValue);
       sinon.assert.calledWith(mozLoop.telemetryAddKeyedValue,
         "LOOP_TWO_WAY_MEDIA_CONN_LENGTH",
         mozLoop.TWO_WAY_MEDIA_CONN_LENGTH.MORE_THAN_5M);
     });
 
-    it("should not call mozLoop.noteConnectionLength if driver._isDesktop " +
-       "is false",
+    it("should not call mozLoop.noteConnectionLength if" +
+       " driver._sendTwoWayMediaTelemetry is false",
       function() {
         var endTimeMS = 10 * 60 * 1000;
-        driver._isDesktop = false;
+        driver._sendTwoWayMediaTelemetry = false;
 
         driver._noteConnectionLengthIfNeeded(startTimeMS, endTimeMS);
 
         sinon.assert.notCalled(mozLoop.telemetryAddKeyedValue);
       });
   });
 
   describe("#_noteSharingState", function() {
@@ -611,16 +615,17 @@ describe("loop.OTSdkDriver", function ()
             sinon.match.hasOwn("peerHungup", false));
       });
 
 
       it("should call _noteConnectionLengthIfNeeded with connection duration", function() {
         driver.session = session;
         var startTime = 1;
         var endTime = 3;
+        driver._sendTwoWayMediaTelemetry = true;
         driver._setTwoWayMediaStartTime(startTime);
         sandbox.stub(performance, "now").returns(endTime);
         sandbox.stub(driver, "_noteConnectionLengthIfNeeded");
 
         session.trigger("connectionDestroyed", {
           reason: "clientDisconnected"
         });
 
@@ -655,16 +660,17 @@ describe("loop.OTSdkDriver", function ()
           sinon.assert.calledWithMatch(dispatcher.dispatch,
             sinon.match.hasOwn("reason", FAILURE_DETAILS.EXPIRED_OR_INVALID));
         });
 
       it("should call _noteConnectionLengthIfNeeded with connection duration", function() {
         driver.session = session;
         var startTime = 1;
         var endTime = 3;
+        driver._sendTwoWayMediaTelemetry = true;
         driver._setTwoWayMediaStartTime(startTime);
         sandbox.stub(performance, "now").returns(endTime);
         sandbox.stub(driver, "_noteConnectionLengthIfNeeded");
 
         session.trigger("sessionDisconnected", {
           reason: "networkDisconnected"
         });
 
@@ -742,17 +748,18 @@ describe("loop.OTSdkDriver", function ()
 
         // Called twice due to the VideoDimensionsChanged above.
         sinon.assert.calledTwice(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "mediaConnected"));
       });
 
       it("should store the start time when both streams are up and" +
-      " driver._isDesktop is true", function() {
+      " driver._sendTwoWayMediaTelemetry is true", function() {
+        driver._sendTwoWayMediaTelemetry = true;
         driver._publishedLocalStream = true;
         var startTime = 1;
         sandbox.stub(performance, "now").returns(startTime);
 
         session.trigger("streamCreated", {stream: fakeStream});
 
         expect(driver._getTwoWayMediaStartTime()).to.eql(startTime);
       });
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -110,28 +110,36 @@ var fakeContacts = [{
 navigator.mozLoop = {
   ensureRegistered: function() {},
   getAudioBlob: function(){},
   getLoopPref: function(pref) {
     switch(pref) {
       // Ensure we skip FTE completely.
       case "gettingStarted.seen":
       case "contacts.gravatars.promo":
+      case "contextInConverations.enabled":
         return true;
       case "contacts.gravatars.show":
         return false;
     }
   },
   setLoopPref: function(){},
   releaseCallData: function() {},
   copyString: function() {},
   getUserAvatar: function(emailAddress) {
     return "http://www.gravatar.com/avatar/" + (Math.ceil(Math.random() * 3) === 2 ?
       "0a996f0fe2727ef1668bdb11897e4459" : "foo") + ".jpg?default=blank&s=40";
   },
+  getSelectedTabMetadata: function(callback) {
+    callback({
+      previews: ["chrome://branding/content/about-logo.png"],
+      description: "sample webpage description",
+      url: "https://www.example.com"
+    });
+  },
   contacts: {
     getAll: function(callback) {
       callback(null, [].concat(fakeContacts));
     },
     on: function() {}
   },
   rooms: {
     getAll: function(version, callback) {
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -51,16 +51,38 @@
   }
 
   function returnFalse() {
     return false;
   }
 
   function noop(){}
 
+  // We save the visibility change listeners so that we can fake an event
+  // to the panel once we've loaded all the views.
+  var visibilityListeners = [];
+  var rootObject = window;
+
+  rootObject.document.addEventListener = function(eventName, func) {
+    if (eventName === "visibilitychange") {
+      visibilityListeners.push(func);
+    }
+    window.addEventListener(eventName, func);
+  };
+
+  rootObject.document.removeEventListener = function(eventName, func) {
+    if (eventName === "visibilitychange") {
+      var index = visibilityListeners.indexOf(func);
+      visibilityListeners.splice(index, 1);
+    }
+    window.removeEventListener(eventName, func);
+  };
+
+  loop.shared.mixins.setRootObject(rootObject);
+
   // Feedback API client configured to send data to the stage input server,
   // which is available at https://input.allizom.org
   var stageFeedbackApiClient = new loop.FeedbackAPIClient(
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
@@ -752,16 +774,20 @@
 
       };
     }
   }
 
   window.addEventListener("DOMContentLoaded", function() {
     try {
       React.renderComponent(React.createElement(App, null), document.getElementById("main"));
+
+      for (var listener of visibilityListeners) {
+        listener({target: {hidden: false}});
+      }
     } catch(err) {
       console.error(err);
       uncaughtError = err;
     }
 
     _renderComponentsInIframes();
 
     // Put the title back, in case views changed it.
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -51,16 +51,38 @@
   }
 
   function returnFalse() {
     return false;
   }
 
   function noop(){}
 
+  // We save the visibility change listeners so that we can fake an event
+  // to the panel once we've loaded all the views.
+  var visibilityListeners = [];
+  var rootObject = window;
+
+  rootObject.document.addEventListener = function(eventName, func) {
+    if (eventName === "visibilitychange") {
+      visibilityListeners.push(func);
+    }
+    window.addEventListener(eventName, func);
+  };
+
+  rootObject.document.removeEventListener = function(eventName, func) {
+    if (eventName === "visibilitychange") {
+      var index = visibilityListeners.indexOf(func);
+      visibilityListeners.splice(index, 1);
+    }
+    window.removeEventListener(eventName, func);
+  };
+
+  loop.shared.mixins.setRootObject(rootObject);
+
   // Feedback API client configured to send data to the stage input server,
   // which is available at https://input.allizom.org
   var stageFeedbackApiClient = new loop.FeedbackAPIClient(
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
@@ -752,16 +774,20 @@
 
       };
     }
   }
 
   window.addEventListener("DOMContentLoaded", function() {
     try {
       React.renderComponent(<App />, document.getElementById("main"));
+
+      for (var listener of visibilityListeners) {
+        listener({target: {hidden: false}});
+      }
     } catch(err) {
       console.error(err);
       uncaughtError = err;
     }
 
     _renderComponentsInIframes();
 
     // Put the title back, in case views changed it.
--- a/browser/components/readinglist/ReadingList.jsm
+++ b/browser/components/readinglist/ReadingList.jsm
@@ -14,16 +14,22 @@ Cu.import("resource://gre/modules/Servic
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Log.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "SQLiteStore",
   "resource:///modules/readinglist/SQLiteStore.jsm");
 
+// We use Sync's "Utils" module for the device name, which is unfortunate,
+// but let's give it a better name here.
+XPCOMUtils.defineLazyGetter(this, "SyncUtils", function() {
+  const {Utils} = Cu.import("resource://services-sync/util.js", {});
+  return Utils;
+});
 
 { // Prevent the parent log setup from leaking into the global scope.
   let parentLog = Log.repository.getLogger("readinglist");
   parentLog.level = Preferences.get("browser.readinglist.logLevel", Log.Level.Warn);
   Preferences.observe("browser.readinglist.logLevel", value => {
     parentLog.level = value;
   });
   let formatter = new Log.BasicFormatter();
@@ -31,49 +37,86 @@ XPCOMUtils.defineLazyModuleGetter(this, 
   parentLog.addAppender(new Log.DumpAppender(formatter));
 }
 let log = Log.repository.getLogger("readinglist.api");
 
 
 // Each ReadingListItem has a _record property, an object containing the raw
 // data from the server and local store.  These are the names of the properties
 // in that object.
+//
+// Not important, but FYI: The order that these are listed in follows the order
+// that the server doc lists the fields in the article data model, more or less:
+// http://readinglist.readthedocs.org/en/latest/model.html
 const ITEM_RECORD_PROPERTIES = `
   guid
-  lastModified
+  serverLastModified
   url
+  preview
   title
   resolvedURL
   resolvedTitle
   excerpt
-  preview
-  status
+  archived
+  deleted
   favorite
   isArticle
   wordCount
   unread
   addedBy
   addedOn
   storedOn
   markedReadBy
   markedReadOn
   readPosition
+  syncStatus
 `.trim().split(/\s+/);
 
 // Article objects that are passed to ReadingList.addItem may contain
 // some properties that are known but are not currently stored in the
 // ReadingList records. This is the list of properties that are knowingly
 // disregarded before the item is normalized.
 const ITEM_DISREGARDED_PROPERTIES = `
   byline
   dir
   content
   length
 `.trim().split(/\s+/);
 
+// Each local item has a syncStatus indicating the state of the item in relation
+// to the sync server.  See also Sync.jsm.
+const SYNC_STATUS_SYNCED = 0;
+const SYNC_STATUS_NEW = 1;
+const SYNC_STATUS_CHANGED_STATUS = 2;
+const SYNC_STATUS_CHANGED_MATERIAL = 3;
+const SYNC_STATUS_DELETED = 4;
+
+// These options are passed as the "control" options to store methods and filter
+// out all records in the store with syncStatus SYNC_STATUS_DELETED.
+const STORE_OPTIONS_IGNORE_DELETED = {
+  syncStatus: [
+    SYNC_STATUS_SYNCED,
+    SYNC_STATUS_NEW,
+    SYNC_STATUS_CHANGED_STATUS,
+    SYNC_STATUS_CHANGED_MATERIAL,
+  ],
+};
+
+// Changes to the following item properties are considered "status," or
+// "status-only," changes, in relation to the sync server.  Changes to other
+// properties are considered "material" changes.  See also Sync.jsm.
+const SYNC_STATUS_PROPERTIES_STATUS = `
+  favorite
+  markedReadBy
+  markedReadOn
+  readPosition
+  unread
+`.trim().split(/\s+/);
+
+
 /**
  * A reading list contains ReadingListItems.
  *
  * A list maintains only one copy of an item per URL.  So if for example you use
  * an iterator to get two references to items with the same URL, your references
  * actually refer to the same JS object.
  *
  * Options Objects
@@ -126,37 +169,49 @@ function ReadingListImpl(store) {
   this._iterators = new Set();
   this._listeners = new Set();
 }
 
 ReadingListImpl.prototype = {
 
   ItemRecordProperties: ITEM_RECORD_PROPERTIES,
 
+  SyncStatus: {
+    SYNCED: SYNC_STATUS_SYNCED,
+    NEW: SYNC_STATUS_NEW,
+    CHANGED_STATUS: SYNC_STATUS_CHANGED_STATUS,
+    CHANGED_MATERIAL: SYNC_STATUS_CHANGED_MATERIAL,
+    DELETED: SYNC_STATUS_DELETED,
+  },
+
+  SyncStatusProperties: {
+    STATUS: SYNC_STATUS_PROPERTIES_STATUS,
+  },
+
   /**
    * Yields the number of items in the list.
    *
    * @param optsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects.
    * @return Promise<number> The number of matching items in the list.  Rejected
    *         with an Error on error.
    */
   count: Task.async(function* (...optsList) {
-    return (yield this._store.count(...optsList));
+    return (yield this._store.count(optsList, STORE_OPTIONS_IGNORE_DELETED));
   }),
 
   /**
    * Checks whether a given URL is in the ReadingList already.
    *
    * @param {String/nsIURI} url - URL to check.
    * @returns {Promise} Promise that is fulfilled with a boolean indicating
    *                    whether the URL is in the list or not.
    */
   hasItemForURL: Task.async(function* (url) {
-    url = normalizeURI(url).spec;
+    url = normalizeURI(url);
 
     // This is used on every tab switch and page load of the current tab, so we
     // want it to be quick and avoid a DB query whenever possible.
 
     // First check if any cached items have a direct match.
     if (this._itemsByNormalizedURL.has(url)) {
       return true;
     }
@@ -184,29 +239,49 @@ ReadingListImpl.prototype = {
    *        is resolved.
    * @param optsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects.
    * @return Promise<null> Resolved when the enumeration completes *and* the
    *         last promise returned by the callback is resolved.  Rejected with
    *         an Error on error.
    */
   forEachItem: Task.async(function* (callback, ...optsList) {
+    yield this._forEachItem(callback, optsList, STORE_OPTIONS_IGNORE_DELETED);
+  }),
+
+  /**
+   * Like forEachItem, but enumerates only previously synced items that are
+   * marked as being locally deleted.
+   */
+  forEachSyncedDeletedItem: Task.async(function* (callback, ...optsList) {
+    yield this._forEachItem(callback, optsList, {
+      syncStatus: SYNC_STATUS_DELETED,
+    });
+  }),
+
+  /**
+   * See forEachItem.
+   *
+   * @param storeOptions An options object passed to the store as the "control"
+   *        options.
+   */
+  _forEachItem: Task.async(function* (callback, optsList, storeOptions) {
     let promiseChain = Promise.resolve();
     yield this._store.forEachItem(record => {
       promiseChain = promiseChain.then(() => {
         return new Promise((resolve, reject) => {
           let promise = callback(this._itemFromRecord(record));
           if (promise instanceof Promise) {
             return promise.then(resolve, reject);
           }
           resolve();
           return undefined;
         });
       });
-    }, ...optsList);
+    }, optsList, storeOptions);
     yield promiseChain;
   }),
 
   /**
    * Returns a new ReadingListItemIterator that can be used to enumerate items
    * in the list.
    *
    * @param optsList A variable number of options objects that control the
@@ -231,20 +306,33 @@ ReadingListImpl.prototype = {
    * returned promise is rejected in that case.
    *
    * @param record A simple object representing an item.
    * @return Promise<ReadingListItem> Resolved with the new item when the list
    *         is updated.  Rejected with an Error on error.
    */
   addItem: Task.async(function* (record) {
     record = normalizeRecord(record);
-    record.addedOn = Date.now();
-    if (Services.prefs.prefHasUserValue("services.sync.client.name")) {
-      record.addedBy = Services.prefs.getCharPref("services.sync.client.name");
+    if (!record.url) {
+      throw new Error("The item must have a url");
+    }
+    if (!("addedOn" in record)) {
+      record.addedOn = Date.now();
     }
+    if (!("addedBy" in record)) {
+      try {
+        record.addedBy = Services.prefs.getCharPref("services.sync.client.name");
+      } catch (ex) {
+        record.addedBy = SyncUtils.getDefaultDeviceName();
+      }
+    }
+    if (!("syncStatus" in record)) {
+      record.syncStatus = SYNC_STATUS_NEW;
+    }
+
     yield this._store.addItem(record);
     this._invalidateIterators();
     let item = this._itemFromRecord(record);
     this._callListeners("onItemAdded", item);
     let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
     mm.broadcastAsyncMessage("Reader:Added", item);
     return item;
   }),
@@ -259,16 +347,19 @@ ReadingListImpl.prototype = {
    * It's an error to call this for an item that doesn't belong to the list.
    * The returned promise is rejected in that case.
    *
    * @param item The ReadingListItem to update.
    * @return Promise<null> Resolved when the list is updated.  Rejected with an
    *         Error on error.
    */
   updateItem: Task.async(function* (item) {
+    if (!item._record.url) {
+      throw new Error("The item must have a url");
+    }
     this._ensureItemBelongsToList(item);
     yield this._store.updateItem(item._record);
     this._invalidateIterators();
     this._callListeners("onItemUpdated", item);
   }),
 
   /**
    * Deletes an item from the list.  The item must have a `url`.
@@ -277,17 +368,36 @@ ReadingListImpl.prototype = {
    * The returned promise is rejected in that case.
    *
    * @param item The ReadingListItem to delete.
    * @return Promise<null> Resolved when the list is updated.  Rejected with an
    *         Error on error.
    */
   deleteItem: Task.async(function* (item) {
     this._ensureItemBelongsToList(item);
-    yield this._store.deleteItemByURL(item.url);
+
+    // If the item is new and therefore hasn't been synced yet, delete it from
+    // the store.  Otherwise mark it as deleted but don't actually delete it so
+    // that its status can be synced.
+    if (item._record.syncStatus == SYNC_STATUS_NEW) {
+      yield this._store.deleteItemByURL(item.url);
+    }
+    else {
+      // To prevent data leakage, only keep the record fields needed to sync
+      // the deleted status: guid and syncStatus.
+      let newRecord = {};
+      for (let prop of ITEM_RECORD_PROPERTIES) {
+        newRecord[prop] = null;
+      }
+      newRecord.guid = item._record.guid;
+      newRecord.syncStatus = SYNC_STATUS_DELETED;
+      item._record = newRecord;
+      yield this._store.updateItemByGUID(item._record);
+    }
+
     item.list = null;
     this._itemsByNormalizedURL.delete(item.url);
     this._invalidateIterators();
     let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
     mm.broadcastAsyncMessage("Reader:Removed", item);
     this._callListeners("onItemDeleted", item);
   }),
 
@@ -304,17 +414,17 @@ ReadingListImpl.prototype = {
   /**
    * Find any item that matches a given URL - either the item's URL, or its
    * resolved URL.
    *
    * @param {String/nsIURI} uri - URI to match against. This will be normalized.
    * @return The first matching item, or null if there are no matching items.
    */
   itemForURL: Task.async(function* (uri) {
-    let url = normalizeURI(uri).spec;
+    let url = normalizeURI(uri);
     return (yield this.item({ url: url }, { resolvedURL: url }));
   }),
 
   /**
    * Add to the ReadingList the page that is loaded in a given browser.
    *
    * @param {<xul:browser>} browser - Browser element for the document,
    * used to get metadata about the article.
@@ -503,17 +613,17 @@ ReadingListItem.prototype = {
     return this._record.guid || undefined;
   },
 
   /**
    * The item's URL.
    * @type string
    */
   get url() {
-    return this._record.url;
+    return this._record.url || undefined;
   },
 
   /**
    * The item's URL as an nsIURI.
    * @type nsIURI
    */
   get uri() {
     if (!this._uri) {
@@ -524,17 +634,17 @@ ReadingListItem.prototype = {
     return this._uri;
   },
 
   /**
    * The item's resolved URL.
    * @type string
    */
   get resolvedURL() {
-    return this._record.resolvedURL;
+    return this._record.resolvedURL || undefined;
   },
   set resolvedURL(val) {
     this._updateRecord({ resolvedURL: val });
   },
 
   /**
    * The item's resolved URL as an nsIURI.  The setter takes an nsIURI or a
    * string spec.
@@ -549,53 +659,53 @@ ReadingListItem.prototype = {
     this._updateRecord({ resolvedURL: val });
   },
 
   /**
    * The item's title.
    * @type string
    */
   get title() {
-    return this._record.title;
+    return this._record.title || undefined;
   },
   set title(val) {
     this._updateRecord({ title: val });
   },
 
   /**
    * The item's resolved title.
    * @type string
    */
   get resolvedTitle() {
-    return this._record.resolvedTitle;
+    return this._record.resolvedTitle || undefined;
   },
   set resolvedTitle(val) {
     this._updateRecord({ resolvedTitle: val });
   },
 
   /**
    * The item's excerpt.
    * @type string
    */
   get excerpt() {
-    return this._record.excerpt;
+    return this._record.excerpt || undefined;
   },
   set excerpt(val) {
     this._updateRecord({ excerpt: val });
   },
 
   /**
-   * The item's status.
-   * @type integer
+   * The item's archived status.
+   * @type boolean
    */
-  get status() {
-    return this._record.status;
+  get archived() {
+    return !!this._record.archived;
   },
-  set status(val) {
-    this._updateRecord({ status: val });
+  set archived(val) {
+    this._updateRecord({ archived: !!val });
   },
 
   /**
    * Whether the item is a favorite.
    * @type boolean
    */
   get favorite() {
     return !!this._record.favorite;
@@ -615,17 +725,17 @@ ReadingListItem.prototype = {
     this._updateRecord({ isArticle: !!val });
   },
 
   /**
    * The item's word count.
    * @type integer
    */
   get wordCount() {
-    return this._record.wordCount;
+    return this._record.wordCount || undefined;
   },
   set wordCount(val) {
     this._updateRecord({ wordCount: val });
   },
 
   /**
    * Whether the item is unread.
    * @type boolean
@@ -663,17 +773,17 @@ ReadingListItem.prototype = {
     this._updateRecord({ storedOn: val.valueOf() });
   },
 
   /**
    * The GUID of the device that marked the item read.
    * @type string
    */
   get markedReadBy() {
-    return this._record.markedReadBy;
+    return this._record.markedReadBy || undefined;
   },
   set markedReadBy(val) {
     this._updateRecord({ markedReadBy: val });
   },
 
   /**
    * The date the item marked read.
    * @type Date
@@ -687,28 +797,28 @@ ReadingListItem.prototype = {
     this._updateRecord({ markedReadOn: val.valueOf() });
   },
 
   /**
    * The item's read position.
    * @param integer
    */
   get readPosition() {
-    return this._record.readPosition;
+    return this._record.readPosition || undefined;
   },
   set readPosition(val) {
     this._updateRecord({ readPosition: val });
   },
 
   /**
    * The URL to a preview image.
    * @type string
    */
    get preview() {
-     return this._record.preview;
+     return this._record.preview || undefined;
    },
 
   /**
    * Deletes the item from its list.
    *
    * @return Promise<null> Resolved when the list has been updated.
    */
   delete: Task.async(function* () {
@@ -725,32 +835,49 @@ ReadingListItem.prototype = {
    * Do not use this at all unless you know what you're doing.  Use the public
    * getters and setters, above, instead.
    *
    * A simple object that contains the item's normalized data in the same format
    * that the local store and server use.  Records passed in by the consumer are
    * not normalized, but everywhere else, records are always normalized unless
    * otherwise stated.  The setter normalizes the passed-in value, so it will
    * throw an error if the value is not a valid record.
+   *
+   * This object should reflect the item's representation in the local store, so
+   * when calling the setter, be careful that it doesn't drift away from the
+   * store's record.  If you set it, you should also call updateItem() around
+   * the same time.
    */
   get _record() {
     return this.__record;
   },
   set _record(val) {
     this.__record = normalizeRecord(val);
   },
 
   /**
    * Updates the item's record.  This calls the _record setter, so it will throw
    * an error if the partial record is not valid.
    *
    * @param partialRecord An object containing any of the record properties.
    */
   _updateRecord(partialRecord) {
     let record = this._record;
+
+    // The syncStatus flag can change from SYNCED to either CHANGED_STATUS or
+    // CHANGED_MATERIAL, or from CHANGED_STATUS to CHANGED_MATERIAL.
+    if (record.syncStatus == SYNC_STATUS_SYNCED ||
+        record.syncStatus == SYNC_STATUS_CHANGED_STATUS) {
+      let allStatusChanges = Object.keys(partialRecord).every(prop => {
+        return SYNC_STATUS_PROPERTIES_STATUS.indexOf(prop) >= 0;
+      });
+      record.syncStatus = allStatusChanges ? SYNC_STATUS_CHANGED_STATUS :
+                          SYNC_STATUS_CHANGED_MATERIAL;
+    }
+
     for (let prop in partialRecord) {
       record[prop] = partialRecord[prop];
     }
     this._record = record;
   },
 
   _ensureBelongsToList() {
     if (!this.list) {
@@ -859,53 +986,61 @@ ReadingListItemIterator.prototype = {
  * aren't in ITEM_RECORD_PROPERTIES.
  *
  * @param record A non-normalized record object.
  * @return The new normalized record.
  */
 function normalizeRecord(nonNormalizedRecord) {
   let record = {};
   for (let prop in nonNormalizedRecord) {
-    if (ITEM_DISREGARDED_PROPERTIES.includes(prop)) {
+    if (ITEM_DISREGARDED_PROPERTIES.indexOf(prop) >= 0) {
       continue;
     }
-    if (!ITEM_RECORD_PROPERTIES.includes(prop)) {
+    if (ITEM_RECORD_PROPERTIES.indexOf(prop) < 0) {
       throw new Error("Unrecognized item property: " + prop);
     }
     switch (prop) {
     case "url":
     case "resolvedURL":
       if (nonNormalizedRecord[prop]) {
-        record[prop] = normalizeURI(nonNormalizedRecord[prop]).spec;
+        record[prop] = normalizeURI(nonNormalizedRecord[prop]);
+      }
+      else {
+        record[prop] = nonNormalizedRecord[prop];
       }
       break;
     default:
       record[prop] = nonNormalizedRecord[prop];
       break;
     }
   }
   return record;
 }
 
 /**
  * Normalize a URI, stripping away extraneous parts we don't want to store
  * or compare against.
  *
  * @param {nsIURI/String} uri - URI to normalize.
- * @returns {nsIURI} Cloned and normalized version of the input URI.
+ * @returns {String} String spec of a cloned and normalized version of the
+ *          input URI.
  */
 function normalizeURI(uri) {
   if (typeof uri == "string") {
-    uri = Services.io.newURI(uri, "", null);
+    try {
+      uri = Services.io.newURI(uri, "", null);
+    } catch (ex) {
+      return uri;
+    }
   }
   uri = uri.cloneIgnoringRef();
   try {
     uri.userPass = "";
   } catch (ex) {} // Not all nsURI impls (eg, nsSimpleURI) support .userPass
-  return uri;
+  return uri.spec;
 };
 
 function hash(str) {
   let hasher = Cc["@mozilla.org/security/hash;1"].
                createInstance(Ci.nsICryptoHash);
   hasher.init(Ci.nsICryptoHash.MD5);
   let stream = Cc["@mozilla.org/io/string-input-stream;1"].
                createInstance(Ci.nsIStringInputStream);
@@ -939,14 +1074,14 @@ function getMetadataFromBrowser(browser)
     mm.addMessageListener("PageMetadata:PageDataResult", handleResult);
     mm.sendAsyncMessage("PageMetadata:GetPageData");
   });
 }
 
 Object.defineProperty(this, "ReadingList", {
   get() {
     if (!this._singleton) {
-      let store = new SQLiteStore("reading-list-temp2.sqlite");
+      let store = new SQLiteStore("reading-list.sqlite");
       this._singleton = new ReadingListImpl(store);
     }
     return this._singleton;
   },
 });
--- a/browser/components/readinglist/SQLiteStore.jsm
+++ b/browser/components/readinglist/SQLiteStore.jsm
@@ -30,43 +30,49 @@ this.SQLiteStore = function SQLiteStore(
   this._ensureConnection(pathRelativeToProfileDir);
 };
 
 this.SQLiteStore.prototype = {
 
   /**
    * Yields the number of items in the store that match the given options.
    *
-   * @param optsList A variable number of options objects that control the
+   * @param userOptsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects in ReadingList.jsm.
+   * @param controlOpts A single options object.  Use this to filter out items
+   *        that don't match it -- in other words, to override the user options.
+   *        See Options Objects in ReadingList.jsm.
    * @return Promise<number> The number of matching items in the store.
    *         Rejected with an Error on error.
    */
-  count: Task.async(function* (...optsList) {
-    let [sql, args] = sqlFromOptions(optsList);
+  count: Task.async(function* (userOptsList=[], controlOpts={}) {
+      let [sql, args] = sqlWhereFromOptions(userOptsList, controlOpts);
     let count = 0;
     let conn = yield this._connectionPromise;
     yield conn.executeCached(`
       SELECT COUNT(*) AS count FROM items ${sql};
     `, args, row => count = row.getResultByName("count"));
     return count;
   }),
 
   /**
    * Enumerates the items in the store that match the given options.
    *
    * @param callback Called for each item in the enumeration.  It's passed a
    *        single object, an item.
-   * @param optsList A variable number of options objects that control the
+   * @param userOptsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects in ReadingList.jsm.
+   * @param controlOpts A single options object.  Use this to filter out items
+   *        that don't match it -- in other words, to override the user options.
+   *        See Options Objects in ReadingList.jsm.
    * @return Promise<null> Resolved when the enumeration completes.  Rejected
    *         with an Error on error.
    */
-  forEachItem: Task.async(function* (callback, ...optsList) {
-    let [sql, args] = sqlFromOptions(optsList);
+  forEachItem: Task.async(function* (callback, userOptsList=[], controlOpts={}) {
+    let [sql, args] = sqlWhereFromOptions(userOptsList, controlOpts);
     let colNames = ReadingList.ItemRecordProperties;
     let conn = yield this._connectionPromise;
     yield conn.executeCached(`
       SELECT ${colNames} FROM items ${sql};
     `, args, row => callback(itemFromRow(row)));
   }),
 
   /**
@@ -94,41 +100,60 @@ this.SQLiteStore.prototype = {
    * Updates the properties of an item that's already present in the store.  See
    * ReadingList.prototype.updateItem.
    *
    * @param item The item to update.  It must have a `url`.
    * @return Promise<null> Resolved when the store is updated.  Rejected with an
    *         Error on error.
    */
   updateItem: Task.async(function* (item) {
-    let assignments = [];
-    for (let propName in item) {
-      assignments.push(`${propName} = :${propName}`);
-    }
-    let conn = yield this._connectionPromise;
-    yield conn.executeCached(`
-      UPDATE items SET ${assignments} WHERE url = :url;
-    `, item);
+    yield this._updateItem(item, "url");
   }),
 
   /**
-   * Deletes an item from the store.
+   * Same as updateItem, but the item is keyed off of its `guid` instead of its
+   * `url`.
+   *
+   * @param item The item to update.  It must have a `guid`.
+   * @return Promise<null> Resolved when the store is updated.  Rejected with an
+   *         Error on error.
+   */
+  updateItemByGUID: Task.async(function* (item) {
+    yield this._updateItem(item, "guid");
+  }),
+
+  /**
+   * Deletes an item from the store by its URL.
    *
    * @param url The URL string of the item to delete.
    * @return Promise<null> Resolved when the store is updated.  Rejected with an
    *         Error on error.
    */
   deleteItemByURL: Task.async(function* (url) {
     let conn = yield this._connectionPromise;
     yield conn.executeCached(`
       DELETE FROM items WHERE url = :url;
     `, { url: url });
   }),
 
   /**
+   * Deletes an item from the store by its GUID.
+   *
+   * @param guid The GUID string of the item to delete.
+   * @return Promise<null> Resolved when the store is updated.  Rejected with an
+   *         Error on error.
+   */
+  deleteItemByGUID: Task.async(function* (guid) {
+    let conn = yield this._connectionPromise;
+    yield conn.executeCached(`
+      DELETE FROM items WHERE guid = :guid;
+    `, { guid: guid });
+  }),
+
+  /**
    * Call this when you're done with the store.  Don't use it afterward.
    */
   destroy() {
     if (!this._destroyPromise) {
       this._destroyPromise = Task.spawn(function* () {
         let conn = yield this._connectionPromise;
         yield conn.close();
         this._connectionPromise = Promise.reject("Store destroyed");
@@ -156,16 +181,40 @@ this.SQLiteStore.prototype = {
           PRAGMA locking_mode = EXCLUSIVE;
         `);
         yield this._checkSchema(conn);
         return conn;
       }.bind(this));
     }
   }),
 
+  /**
+   * Updates the properties of an item that's already present in the store.  See
+   * ReadingList.prototype.updateItem.
+   *
+   * @param item The item to update.  It must have the property named by
+   *        keyProp.
+   * @param keyProp The item is keyed off of this property.
+   * @return Promise<null> Resolved when the store is updated.  Rejected with an
+   *         Error on error.
+   */
+  _updateItem: Task.async(function* (item, keyProp) {
+    let assignments = [];
+    for (let propName in item) {
+      assignments.push(`${propName} = :${propName}`);
+    }
+    let conn = yield this._connectionPromise;
+    if (!item[keyProp]) {
+      throw new Error("Item must have " + keyProp);
+    }
+    yield conn.executeCached(`
+      UPDATE items SET ${assignments} WHERE ${keyProp} = :${keyProp};
+    `, item);
+  }),
+
   // Promise<Sqlite.OpenedConnection>
   _connectionPromise: null,
 
   // The current schema version.
   _schemaVersion: 1,
 
   _checkSchema: Task.async(function* (conn) {
     let version = parseInt(yield conn.getSchemaVersion());
@@ -179,38 +228,44 @@ this.SQLiteStore.prototype = {
   _migrateSchema0To1: Task.async(function* (conn) {
     yield conn.execute(`
       PRAGMA journal_mode = wal;
     `);
     // 524288 bytes = 512 KiB
     yield conn.execute(`
       PRAGMA journal_size_limit = 524288;
     `);
+    // Not important, but FYI: The order that these columns are listed in
+    // follows the order that the server doc lists the fields in the article
+    // data model, more or less:
+    // http://readinglist.readthedocs.org/en/latest/model.html
     yield conn.execute(`
       CREATE TABLE items (
         id INTEGER PRIMARY KEY AUTOINCREMENT,
         guid TEXT UNIQUE,
-        url TEXT NOT NULL UNIQUE,
+        serverLastModified INTEGER,
+        url TEXT UNIQUE,
+        preview TEXT,
+        title TEXT,
         resolvedURL TEXT UNIQUE,
-        lastModified INTEGER,
-        title TEXT,
         resolvedTitle TEXT,
         excerpt TEXT,
-        status INTEGER,
+        archived BOOLEAN,
+        deleted BOOLEAN,
         favorite BOOLEAN,
         isArticle BOOLEAN,
         wordCount INTEGER,
         unread BOOLEAN,
         addedBy TEXT,
         addedOn INTEGER,
         storedOn INTEGER,
         markedReadBy TEXT,
         markedReadOn INTEGER,
         readPosition INTEGER,
-        preview TEXT
+        syncStatus INTEGER
       );
     `);
     yield conn.execute(`
       CREATE INDEX items_addedOn ON items (addedOn);
     `);
     yield conn.execute(`
       CREATE INDEX items_unread ON items (unread);
     `);
@@ -231,30 +286,34 @@ function itemFromRow(row) {
   }
   return item;
 }
 
 /**
  * Returns the back part of a SELECT statement generated from the given list of
  * options.
  *
- * @param optsList See Options Objects in ReadingList.jsm.
+ * @param userOptsList A variable number of options objects that control the
+ *        items that are matched.  See Options Objects in ReadingList.jsm.
+ * @param controlOpts A single options object.  Use this to filter out items
+ *        that don't match it -- in other words, to override the user options.
+ *        See Options Objects in ReadingList.jsm.
  * @return An array [sql, args].  sql is a string of SQL.  args is an object
  *         that contains arguments for all the parameters in sql.
  */
-function sqlFromOptions(optsList) {
-  // We modify the options objects, which were passed in by the store client, so
-  // clone them first.
-  optsList = Cu.cloneInto(optsList, {}, { cloneFunctions: false });
+function sqlWhereFromOptions(userOptsList, controlOpts) {
+  // We modify the options objects in userOptsList, which were passed in by the
+  // store client, so clone them first.
+  userOptsList = Cu.cloneInto(userOptsList, {}, { cloneFunctions: false });
 
   let sort;
   let sortDir;
   let limit;
   let offset;
-  for (let opts of optsList) {
+  for (let opts of userOptsList) {
     if ("sort" in opts) {
       sort = opts.sort;
       delete opts.sort;
     }
     if ("descending" in opts) {
       if (opts.descending) {
         sortDir = "DESC";
       }
@@ -279,60 +338,98 @@ function sqlFromOptions(optsList) {
   if (limit) {
     fragments.push(`LIMIT ${limit}`);
     if (offset) {
       fragments.push(`OFFSET ${offset}`);
     }
   }
 
   let args = {};
+  let mainExprs = [];
 
-  function uniqueParamName(name) {
-    if (name in args) {
-      for (let i = 1; ; i++) {
-        let newName = `${name}_${i}`;
-        if (!(newName in args)) {
-          return newName;
-        }
-      }
-    }
-    return name;
+  let controlSQLExpr = sqlExpressionFromOptions([controlOpts], args);
+  if (controlSQLExpr) {
+    mainExprs.push(`(${controlSQLExpr})`);
+  }
+
+  let userSQLExpr = sqlExpressionFromOptions(userOptsList, args);
+  if (userSQLExpr) {
+    mainExprs.push(`(${userSQLExpr})`);
+  }
+
+  if (mainExprs.length) {
+    let conjunction = mainExprs.join(" AND ");
+    fragments.unshift(`WHERE ${conjunction}`);
   }
 
-  // Build a WHERE clause for the remaining properties.  Assume they all refer
-  // to columns.  (If they don't, the SQL query will fail.)
+  let sql = fragments.join(" ");
+  return [sql, args];
+}
+
+/**
+ * Returns a SQL expression generated from the given options list.  Each options
+ * object in the list generates a subexpression, and all the subexpressions are
+ * OR'ed together to produce the final top-level expression.  (e.g., an optsList
+ * with three options objects would generate an expression like "(guid = :guid
+ * OR (title = :title AND unread = :unread) OR resolvedURL = :resolvedURL)".)
+ *
+ * All the properties of the options objects are assumed to refer to columns in
+ * the database.  If they don't, your SQL query will fail.
+ *
+ * @param optsList See Options Objects in ReadingList.jsm.
+ * @param args An object that will hold the SQL parameters.  It will be
+ *        modified.
+ * @return A string of SQL.  Also, args will contain arguments for all the
+ *         parameters in the SQL.
+ */
+function sqlExpressionFromOptions(optsList, args) {
   let disjunctions = [];
   for (let opts of optsList) {
     let conjunctions = [];
     for (let key in opts) {
       if (Array.isArray(opts[key])) {
         // Convert arrays to IN expressions.  e.g., { guid: ['a', 'b', 'c'] }
         // becomes "guid IN (:guid, :guid_1, :guid_2)".  The guid_i arguments
         // are added to opts.
         let array = opts[key];
         let params = [];
         for (let i = 0; i < array.length; i++) {
-          let paramName = uniqueParamName(key);
+          let paramName = uniqueParamName(args, key);
           params.push(`:${paramName}`);
           args[paramName] = array[i];
         }
         conjunctions.push(`${key} IN (${params})`);
       }
       else {
-        let paramName = uniqueParamName(key);
+        let paramName = uniqueParamName(args, key);
         conjunctions.push(`${key} = :${paramName}`);
         args[paramName] = opts[key];
       }
     }
     let conjunction = conjunctions.join(" AND ");
     if (conjunction) {
       disjunctions.push(`(${conjunction})`);
     }
   }
   let disjunction = disjunctions.join(" OR ");
-  if (disjunction) {
-    let where = `WHERE ${disjunction}`;
-    fragments = [where].concat(fragments);
+  return disjunction;
+}
+
+/**
+ * Returns a version of the given name such that it doesn't conflict with the
+ * name of any property in args.  e.g., if name is "foo" but args already has
+ * properties named "foo", "foo1", and "foo2", then "foo3" is returned.
+ *
+ * @param args An object.
+ * @param name The name you want to use.
+ * @return A unique version of the given name.
+ */
+function uniqueParamName(args, name) {
+  if (name in args) {
+    for (let i = 1; ; i++) {
+      let newName = `${name}_${i}`;
+      if (!(newName in args)) {
+        return newName;
+      }
+    }
   }
-
-  let sql = fragments.join(" ");
-  return [sql, args];
+  return name;
 }
--- a/browser/components/readinglist/Scheduler.jsm
+++ b/browser/components/readinglist/Scheduler.jsm
@@ -3,87 +3,92 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict;"
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import('resource://gre/modules/Task.jsm');
 
 
 XPCOMUtils.defineLazyModuleGetter(this, 'LogManager',
   'resource://services-common/logmanager.js');
 
 XPCOMUtils.defineLazyModuleGetter(this, 'Log',
   'resource://gre/modules/Log.jsm');
 
 XPCOMUtils.defineLazyModuleGetter(this, 'Preferences',
   'resource://gre/modules/Preferences.jsm');
 
 XPCOMUtils.defineLazyModuleGetter(this, 'setTimeout',
   'resource://gre/modules/Timer.jsm');
 XPCOMUtils.defineLazyModuleGetter(this, 'clearTimeout',
   'resource://gre/modules/Timer.jsm');
 
-Cu.import('resource://gre/modules/Task.jsm');
+// The main readinglist module.
+XPCOMUtils.defineLazyModuleGetter(this, 'ReadingList',
+  'resource:///modules/readinglist/ReadingList.jsm');
+
+// The "engine"
+XPCOMUtils.defineLazyModuleGetter(this, 'Sync',
+  'resource:///modules/readinglist/Sync.jsm');
+
 
 this.EXPORTED_SYMBOLS = ["ReadingListScheduler"];
 
 // A list of "external" observer topics that may cause us to change when we
 // sync.
 const OBSERVERS = [
   // We don't sync when offline and restart when online.
   "network:offline-status-changed",
   // FxA notifications also cause us to check if we should sync.
   "fxaccounts:onverified",
-  // When something notices a local change to an item.
-  "readinglist:item-changed",
   // some notifications the engine might send if we have been requested to backoff.
   "readinglist:backoff-requested",
   // request to sync now
   "readinglist:user-sync",
 
 ];
 
-///////// A temp object until we get our "engine"
-let engine = {
-  ERROR_AUTHENTICATION: "authentication error",
-  sync: Task.async(function* () {
-  }),
-}
-
 let prefs = new Preferences("readinglist.scheduler.");
 
 // A helper to manage our interval values.
 let intervals = {
   // Getters for our intervals.
   _fixupIntervalPref(prefName, def) {
     // All pref values are seconds, but we return ms.
     return prefs.get(prefName, def) * 1000;
   },
 
   // How long after startup do we do an initial sync?
-  get initial() this._fixupIntervalPref("initial", 20), // 20 seconds.
+  get initial() this._fixupIntervalPref("initial", 10), // 10 seconds.
   // Every interval after the first.
   get schedule() this._fixupIntervalPref("schedule", 2 * 60 * 60), // 2 hours
-  // After we've been told an item has changed
-  get dirty() this._fixupIntervalPref("dirty", 2 * 60), // 2 mins
   // After an error
   get retry() this._fixupIntervalPref("retry", 2 * 60), // 2 mins
 };
 
 // This is the implementation, but it's not exposed directly.
-function InternalScheduler() {
+function InternalScheduler(readingList = null) {
   // oh, I don't know what logs yet - let's guess!
-  let logs = ["readinglist", "FirefoxAccounts", "browserwindow.syncui"];
+  let logs = [
+    "browserwindow.syncui",
+    "FirefoxAccounts",
+    "readinglist.api",
+    "readinglist.serverclient",
+    "readinglist.sync",
+  ];
+
   this._logManager = new LogManager("readinglist.", logs, "readinglist");
   this.log = Log.repository.getLogger("readinglist.scheduler");
   this.log.info("readinglist scheduler created.")
   this.state = this.STATE_OK;
+  this.readingList = readingList || ReadingList; // hook point for tests.
 
   // don't this.init() here, but instead at the module level - tests want to
   // add hooks before it is called.
 }
 
 InternalScheduler.prototype = {
   // When the next scheduled sync should happen.  If we can sync, there will
   // be a timer set to fire then. If we can't sync there will not be a timer,
@@ -93,34 +98,55 @@ InternalScheduler.prototype = {
   // schedule a new timer before this.
   _backoffUntil: 0,
   // Our current timer.
   _timer: null,
   // Our timer fires a promise - _timerRunning is true until it resolves or
   // rejects.
   _timerRunning: false,
   // Our sync engine - XXX - maybe just a callback?
-  _engine: engine,
+  _engine: Sync,
 
   // Our state variable and constants.
   state: null,
   STATE_OK: "ok",
   STATE_ERROR_AUTHENTICATION: "authentication error",
   STATE_ERROR_OTHER: "other error",
 
   init() {
     this.log.info("scheduler initialzing");
+    this._setupRLListener();
     this._observe = this.observe.bind(this);
     for (let notification of OBSERVERS) {
       Services.obs.addObserver(this._observe, notification, false);
     }
     this._nextScheduledSync = Date.now() + intervals.initial;
     this._setupTimer();
   },
 
+  _setupRLListener() {
+    let maybeSync = () => {
+      if (this._timerRunning) {
+        // If a sync is currently running it is possible it will miss the change
+        // just made, so tell the timer the next sync should be 1 ms after
+        // it completes (we don't use zero as that has special meaning re backoffs)
+        this._maybeReschedule(1);
+      } else {
+        // Do the sync now.
+        this._syncNow();
+      }
+    };
+    let listener = {
+      onItemAdded: maybeSync,
+      onItemUpdated: maybeSync,
+      onItemDeleted: maybeSync,
+    }
+    this.readingList.addListener(listener);
+  },
+
   // Note: only called by tests.
   finalize() {
     this.log.info("scheduler finalizing");
     this._clearTimer();
     for (let notification of OBSERVERS) {
       Services.obs.removeObserver(this._observe, notification);
     }
     this._observe = null;
@@ -136,19 +162,16 @@ InternalScheduler.prototype = {
           this.log.warn("Backoff request had non-numeric value", data);
           return;
         }
         this.log.info("Received a request to backoff for ${} seconds", interval);
         this._backoffUntil = Date.now() + interval * 1000;
         this._maybeReschedule(0);
         break;
       }
-      case "readinglist:local:dirty":
-        this._maybeReschedule(intervals.dirty);
-        break;
       case "readinglist:user-sync":
         this._syncNow();
         break;
       case "fxaccounts:onverified":
         // If we were in an authentication error state, reset that now.
         if (this.state == this.STATE_ERROR_AUTHENTICATION) {
           this.state = this.STATE_OK;
         }
@@ -229,35 +252,35 @@ InternalScheduler.prototype = {
       return;
     }
     let now = Date.now();
     if (!this._nextScheduledSync) {
       this._nextScheduledSync = now + delay;
     }
     // If there is something currently scheduled before the requested delay,
     // keep the existing value (eg, if we have a timer firing in 1 second, and
-    // get a "dirty" notification that says we should sync in 2 seconds, we
-    // keep the 1 second value)
+    // get a notification that says we should sync in 2 seconds, we keep the 1
+    // second value)
     this._nextScheduledSync = Math.min(this._nextScheduledSync, now + delay);
     // But we still need to honor a backoff.
     this._nextScheduledSync = Math.max(this._nextScheduledSync, this._backoffUntil);
     // And always create a new timer next time _setupTimer is called.
     this._clearTimer();
   },
 
   // callback for when the timer fires.
   _doSync() {
     this.log.debug("starting sync");
     this._timer = null;
     this._timerRunning = true;
     // flag that there's no new schedule yet, so a request coming in while
     // we are running does the right thing.
     this._nextScheduledSync = 0;
     Services.obs.notifyObservers(null, "readinglist:sync:start", null);
-    this._engine.sync().then(() => {
+    this._engine.start().then(() => {
       this.log.info("Sync completed successfully");
       // Write a pref in the same format used to services/sync to indicate
       // the last success.
       prefs.set("lastSync", new Date().toString());
       this.state = this.STATE_OK;
       this._logManager.resetFileLog(this._logManager.REASON_SUCCESS);
       Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
       return intervals.schedule;
@@ -287,16 +310,21 @@ InternalScheduler.prototype = {
       this._timer = null;
     }
   },
 
   // A function to "sync now", but not allowing it to start if one is
   // already running, and rescheduling the timer.
   // To call this, just send a "readinglist:user-sync" notification.
   _syncNow() {
+    if (!prefs.get("enabled")) {
+      this.log.info("syncNow() but syncing is disabled - ignoring");
+      return;
+    }
+
     if (this._timerRunning) {
       this.log.info("syncNow() but a sync is already in progress - ignoring");
       return;
     }
     this._clearTimer();
     this._doSync();
   },
 
@@ -321,22 +349,22 @@ let ReadingListScheduler = {
   get STATE_ERROR_AUTHENTICATION() internalScheduler.STATE_ERROR_AUTHENTICATION,
   get STATE_ERROR_OTHER() internalScheduler.STATE_ERROR_OTHER,
 
   get state() internalScheduler.state,
 };
 
 // These functions are exposed purely for tests, which manage to grab them
 // via a BackstagePass.
-function createTestableScheduler() {
+function createTestableScheduler(readingList) {
   // kill the "real" scheduler as we don't want it listening to notifications etc.
   if (internalScheduler) {
     internalScheduler.finalize();
     internalScheduler = null;
   }
   // No .init() call - that's up to the tests after hooking.
-  return new InternalScheduler();
+  return new InternalScheduler(readingList);
 }
 
 // mochitests want the internal state of the real scheduler for various things.
 function getInternalScheduler() {
   return internalScheduler;
 }
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/Sync.jsm
@@ -0,0 +1,556 @@
+/* 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 = [
+  "Sync",
+];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
+  "resource://gre/modules/Preferences.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
+  "resource:///modules/readinglist/ReadingList.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ServerClient",
+  "resource:///modules/readinglist/ServerClient.jsm");
+
+// The Last-Modified header of server responses is stored here.
+const SERVER_LAST_MODIFIED_HEADER_PREF = "readinglist.sync.serverLastModified";
+
+// Maps local record properties to server record properties.
+const SERVER_PROPERTIES_BY_LOCAL_PROPERTIES = {
+  guid: "id",
+  serverLastModified: "last_modified",
+  url: "url",
+  preview: "preview",
+  title: "title",
+  resolvedURL: "resolved_url",
+  resolvedTitle: "resolved_title",
+  excerpt: "excerpt",
+  archived: "archived",
+  deleted: "deleted",
+  favorite: "favorite",
+  isArticle: "is_article",
+  wordCount: "word_count",
+  unread: "unread",
+  addedBy: "added_by",
+  addedOn: "added_on",
+  storedOn: "stored_on",
+  markedReadBy: "marked_read_by",
+  markedReadOn: "marked_read_on",
+  readPosition: "read_position",
+};
+
+// Local record properties that can be uploaded in new items.
+const NEW_RECORD_PROPERTIES = `
+  url
+  title
+  resolvedURL
+  resolvedTitle
+  excerpt
+  favorite
+  isArticle
+  wordCount
+  unread
+  addedBy
+  addedOn
+  markedReadBy
+  markedReadOn
+  readPosition
+  preview
+`.trim().split(/\s+/);
+
+// Local record properties that can be uploaded in changed items.
+const MUTABLE_RECORD_PROPERTIES = `
+  title
+  resolvedURL
+  resolvedTitle
+  excerpt
+  favorite
+  isArticle
+  wordCount
+  unread
+  markedReadBy
+  markedReadOn
+  readPosition
+  preview
+`.trim().split(/\s+/);
+
+let log = Log.repository.getLogger("readinglist.sync");
+
+
+/**
+ * An object that syncs reading list state with a server.  To sync, make a new
+ * SyncImpl object and then call start() on it.
+ *
+ * @param readingList The ReadingList to sync.
+ */
+function SyncImpl(readingList) {
+  this.list = readingList;
+  this._client = new ServerClient();
+}
+
+/**
+ * This implementation uses the sync algorithm described here:
+ * https://github.com/mozilla-services/readinglist/wiki/Client-phases
+ * The "phases" mentioned in the methods below refer to the phases in that
+ * document.
+ */
+SyncImpl.prototype = {
+
+  /**
+   * Starts sync, if it's not already started.
+   *
+   * @return Promise<null> this.promise, i.e., a promise that will be resolved
+   *         when sync completes, rejected on error.
+   */
+  start() {
+    if (!this.promise) {
+      this.promise = Task.spawn(function* () {
+        yield this._start();
+        delete this.promise;
+      }.bind(this));
+    }
+    return this.promise;
+  },
+
+  /**
+   * A Promise<null> that will be non-null when sync is ongoing.  Resolved when
+   * sync completes, rejected on error.
+   */
+  promise: null,
+
+  /**
+   * See the document linked above that describes the sync algorithm.
+   */
+  _start: Task.async(function* () {
+    log.info("Starting sync");
+    yield this._uploadStatusChanges();
+    yield this._uploadNewItems();
+    yield this._uploadDeletedItems();
+    yield this._downloadModifiedItems();
+
+    // TODO: "Repeat [this phase] until no conflicts occur," says the doc.
+    yield this._uploadMaterialChanges();
+
+    log.info("Sync done");
+  }),
+
+  /**
+   * Phase 1 part 1
+   *
+   * Uploads not-new items with status-only changes.  By design, status-only
+   * changes will never conflict with what's on the server.
+   */
+  _uploadStatusChanges: Task.async(function* () {
+    log.debug("Phase 1 part 1: Uploading status changes");
+    yield this._uploadChanges(ReadingList.SyncStatus.CHANGED_STATUS,
+                              ReadingList.SyncStatusProperties.STATUS);
+  }),
+
+  /**
+   * There are two phases for uploading changed not-new items: one for items
+   * with status-only changes, one for items with material changes.  The two
+   * work similarly mechanically, and this method is a helper for both.
+   *
+   * @param syncStatus Local items matching this sync status will be uploaded.
+   * @param localProperties An array of local record property names.  The
+   *        uploaded item records will include only these properties.
+   */
+  _uploadChanges: Task.async(function* (syncStatus, localProperties) {
+    // Get local items that match the given syncStatus.
+    let requests = [];
+    yield this.list.forEachItem(localItem => {
+      requests.push({
+        path: "/articles/" + localItem.guid,
+        body: serverRecordFromLocalItem(localItem, localProperties),
+      });
+    }, { syncStatus: syncStatus });
+    if (!requests.length) {
+      log.debug("No local changes to upload");
+      return;
+    }
+
+    // Send the request.
+    let request = {
+      method: "POST",
+      path: "/batch",
+      body: {
+        defaults: {
+          method: "PATCH",
+        },
+        requests: requests,
+      },
+      headers: {},
+    };
+    if (this._serverLastModifiedHeader) {
+      request.headers["If-Unmodified-Since"] = this._serverLastModifiedHeader;
+    }
+
+    let batchResponse = yield this._sendRequest(request);
+    if (batchResponse.status != 200) {
+      this._handleUnexpectedResponse("uploading changes", batchResponse);
+      return;
+    }
+
+    // Update local items based on the response.
+    for (let response of batchResponse.body.responses) {
+      if (response.status == 404) {
+        // item deleted
+        yield this._deleteItemForGUID(response.body.id);
+        continue;
+      }
+      if (response.status == 412 || response.status == 409) {
+        // 412 Precondition failed: The item was modified since the last sync.
+        // 409 Conflict: A change violated a uniqueness constraint.
+        // In either case, mark the item as having material changes, and
+        // reconcile and upload it in the material-changes phase.
+        // TODO
+        continue;
+      }
+      if (response.status != 200) {
+        this._handleUnexpectedResponse("uploading a change", response);
+        continue;
+      }
+      let item = yield this._itemForGUID(response.body.id);
+      yield this._updateItemWithServerRecord(item, response.body);
+    }
+  }),
+
+  /**
+   * Phase 1 part 2
+   *
+   * Uploads new items.
+   */
+  _uploadNewItems: Task.async(function* () {
+    log.debug("Phase 1 part 2: Uploading new items");
+
+    // Get new local items.
+    let requests = [];
+    yield this.list.forEachItem(localItem => {
+      requests.push({
+        body: serverRecordFromLocalItem(localItem, NEW_RECORD_PROPERTIES),
+      });
+    }, { syncStatus: ReadingList.SyncStatus.NEW });
+    if (!requests.length) {
+      log.debug("No new local items to upload");
+      return;
+    }
+
+    // Send the request.
+    let request = {
+      method: "POST",
+      path: "/batch",
+      body: {
+        defaults: {
+          method: "POST",
+          path: "/articles",
+        },
+        requests: requests,
+      },
+      headers: {},
+    };
+    if (this._serverLastModifiedHeader) {
+      request.headers["If-Unmodified-Since"] = this._serverLastModifiedHeader;
+    }
+
+    let batchResponse = yield this._sendRequest(request);
+    if (batchResponse.status != 200) {
+      this._handleUnexpectedResponse("uploading new items", batchResponse);
+      return;
+    }
+
+    // Update local items based on the response.
+    for (let response of batchResponse.body.responses) {
+      if (response.status == 303) {
+        // "See Other": An item with the URL already exists.  Mark the item as
+        // having material changes, and reconcile and upload it in the
+        // material-changes phase.
+        // TODO
+        continue;
+      }
+      // Note that the server seems to return a 200 if an identical item already
+      // exists, but we shouldn't be uploading identical items in this phase in
+      // normal usage, so treat 200 as an unexpected response.
+      if (response.status != 201) {
+        this._handleUnexpectedResponse("uploading a new item", response);
+        continue;
+      }
+      let item = yield this.list.itemForURL(response.body.url);
+      yield this._updateItemWithServerRecord(item, response.body);
+    }
+  }),
+
+  /**
+   * Phase 1 part 3
+   *
+   * Uploads deleted synced items.
+   */
+  _uploadDeletedItems: Task.async(function* () {
+    log.debug("Phase 1 part 3: Uploading deleted items");
+
+    // Get deleted synced local items.
+    let requests = [];
+    yield this.list.forEachSyncedDeletedItem(localItem => {
+      requests.push({
+        path: "/articles/" + localItem.guid,
+      });
+    });
+    if (!requests.length) {
+      log.debug("No local deleted synced items to upload");
+      return;
+    }
+
+    // Send the request.
+    let request = {
+      method: "POST",
+      path: "/batch",
+      body: {
+        defaults: {
+          method: "DELETE",
+        },
+        requests: requests,
+      },
+      headers: {},
+    };
+    if (this._serverLastModifiedHeader) {
+      request.headers["If-Unmodified-Since"] = this._serverLastModifiedHeader;
+    }
+
+    let batchResponse = yield this._sendRequest(request);
+    if (batchResponse.status != 200) {
+      this._handleUnexpectedResponse("uploading deleted items", batchResponse);
+      return;
+    }
+
+    // Delete local items based on the response.
+    for (let response of batchResponse.body.responses) {
+      if (response.status == 412) {
+        // "Precondition failed": The item was modified since the last sync.
+        // Mark the item as having material changes, and reconcile and upload it
+        // in the material-changes phase.
+        // TODO
+        continue;
+      }
+      // A 404 means the item was already deleted on the server, which is OK.
+      // We still need to make sure it's deleted locally, though.
+      if (response.status != 200 && response.status != 404) {
+        this._handleUnexpectedResponse("uploading a deleted item", response);
+        continue;
+      }
+      yield this._deleteItemForGUID(response.body.id);
+    }
+  }),
+
+  /**
+   * Phase 2
+   *
+   * Downloads items that were modified since the last sync.
+   */
+  _downloadModifiedItems: Task.async(function* () {
+    log.debug("Phase 2: Downloading modified items");
+
+    // Get modified items from the server.
+    let path = "/articles";
+    if (this._serverLastModifiedHeader) {
+      path += "?_since=" + this._serverLastModifiedHeader;
+    }
+    let request = {
+      method: "GET",
+      path: path,
+      headers: {},
+    };
+    if (this._serverLastModifiedHeader) {
+      request.headers["If-Modified-Since"] = this._serverLastModifiedHeader;
+    }
+
+    let response = yield this._sendRequest(request);
+    if (response.status == 304) {
+      // not modified
+      log.debug("No server changes");
+      return;
+    }
+    if (response.status != 200) {
+      this._handleUnexpectedResponse("downloading modified items", response);
+      return;
+    }
+
+    // Update local items based on the response.
+    for (let serverRecord of response.body.items) {
+      let localItem = yield this._itemForGUID(serverRecord.id);
+      if (localItem) {
+        if (localItem.serverLastModified == serverRecord.last_modified) {
+          // We just uploaded this item in the new-items phase.
+          continue;
+        }
+        // The local item may have materially changed.  In that case, don't
+        // overwrite the local changes with the server record.  Instead, mark
+        // the item as having material changes and reconcile and upload it in
+        // the material-changes phase.
+        // TODO
+
+        if (serverRecord.deleted) {
+          yield this._deleteItemForGUID(serverRecord.id);
+          continue;
+        }
+        yield this._updateItemWithServerRecord(localItem, serverRecord);
+        continue;
+      }
+      // new item
+      yield this.list.addItem(localRecordFromServerRecord(serverRecord));
+    }
+  }),
+
+  /**
+   * Phase 3 (material changes)
+   *
+   * Uploads not-new items with material changes.
+   */
+  _uploadMaterialChanges: Task.async(function* () {
+    log.debug("Phase 3: Uploading material changes");
+    yield this._uploadChanges(ReadingList.SyncStatus.CHANGED_MATERIAL,
+                              MUTABLE_RECORD_PROPERTIES);
+  }),
+
+  /**
+   * Gets the local ReadingListItem with the given GUID.
+   *
+   * @param guid The item's GUID.
+   * @return The matching ReadingListItem.
+   */
+  _itemForGUID: Task.async(function* (guid) {
+    return (yield this.list.item({ guid: guid }));
+  }),
+
+  /**
+   * Updates the given local ReadingListItem with the given server record.  The
+   * local item's sync status is updated to reflect the fact that the item has
+   * been synced and is up to date.
+   *
+   * @param item A local ReadingListItem.
+   * @param serverRecord A server record representing the item.
+   */
+  _updateItemWithServerRecord: Task.async(function* (localItem, serverRecord) {
+    if (!localItem) {
+      throw new Error("Item should exist");
+    }
+    localItem._record = localRecordFromServerRecord(serverRecord);
+    yield this.list.updateItem(localItem);
+  }),
+
+  /**
+   * Truly deletes the local ReadingListItem with the given GUID.
+   *
+   * @param guid The item's GUID.
+   */
+  _deleteItemForGUID: Task.async(function* (guid) {
+    let item = yield this._itemForGUID(guid);
+    if (item) {
+      // If item is non-null, then it hasn't been deleted locally.  Therefore
+      // it's important to delete it through its list so that the list and its
+      // consumers are notified properly.  Set the syncStatus to NEW so that the
+      // list truly deletes the item.
+      item._record.syncStatus = ReadingList.SyncStatus.NEW;
+      yield this.list.deleteItem(item);
+      return;
+    }
+    // If item is null, then it may not actually exist locally, or it may have
+    // been synced and then deleted so that it's marked as being deleted.  In
+    // that case, try to delete it directly from the store.  As far as the list
+    // is concerned, the item has already been deleted.
+    log.debug("Item not present in list, deleting it by GUID instead");
+    this.list._store.deleteItemByGUID(guid);
+  }),
+
+  /**
+   * Sends a request to the server.
+   *
+   * @param req The request object: { method, path, body, headers }.
+   * @return Promise<response> Resolved with the server's response object:
+   *         { status, body, headers }.
+   */
+  _sendRequest: Task.async(function* (req) {
+    log.debug("Sending request", req);
+    let response = yield this._client.request(req);
+    log.debug("Received response", response);
+    // Response header names are lowercase.
+    if (response.headers && "last-modified" in response.headers) {
+      this._serverLastModifiedHeader = response.headers["last-modified"];
+    }
+    return response;
+  }),
+
+  _handleUnexpectedResponse(contextMsgFragment, response) {
+    log.warn(`Unexpected response ${contextMsgFragment}`, response);
+  },
+
+  // TODO: Wipe this pref when user logs out.
+  get _serverLastModifiedHeader() {
+    if (!("__serverLastModifiedHeader" in this)) {
+      this.__serverLastModifiedHeader =
+        Preferences.get(SERVER_LAST_MODIFIED_HEADER_PREF, undefined);
+    }
+    return this.__serverLastModifiedHeader;
+  },
+  set _serverLastModifiedHeader(val) {
+    this.__serverLastModifiedHeader = val;
+    Preferences.set(SERVER_LAST_MODIFIED_HEADER_PREF, val);
+  },
+};
+
+
+/**
+ * Translates a local ReadingListItem into a server record.
+ *
+ * @param localItem The local ReadingListItem.
+ * @param localProperties An array of local item property names.  Only these
+ *        properties will be included in the server record.
+ * @return The server record.
+ */
+function serverRecordFromLocalItem(localItem, localProperties) {
+  let serverRecord = {};
+  for (let localProp of localProperties) {
+    let serverProp = SERVER_PROPERTIES_BY_LOCAL_PROPERTIES[localProp];
+    if (localProp in localItem._record) {
+      serverRecord[serverProp] = localItem._record[localProp];
+    }
+  }
+  return serverRecord;
+}
+
+/**
+ * Translates a server record into a local record.  The returned local record's
+ * syncStatus will reflect the fact that the local record is up-to-date synced.
+ *
+ * @param serverRecord The server record.
+ * @return The local record.
+ */
+function localRecordFromServerRecord(serverRecord) {
+  let localRecord = {
+    // Mark the record as being up-to-date synced.
+    syncStatus: ReadingList.SyncStatus.SYNCED,
+  };
+  for (let localProp in SERVER_PROPERTIES_BY_LOCAL_PROPERTIES) {
+    let serverProp = SERVER_PROPERTIES_BY_LOCAL_PROPERTIES[localProp];
+    if (serverProp in serverRecord) {
+      localRecord[localProp] = serverRecord[serverProp];
+    }
+  }
+  return localRecord;
+}
+
+Object.defineProperty(this, "Sync", {
+  get() {
+    if (!this._singleton) {
+      this._singleton = new SyncImpl(ReadingList);
+    }
+    return this._singleton;
+  },
+});
--- a/browser/components/readinglist/moz.build
+++ b/browser/components/readinglist/moz.build
@@ -4,16 +4,17 @@
 
 JAR_MANIFESTS += ['jar.mn']
 
 EXTRA_JS_MODULES.readinglist += [
     'ReadingList.jsm',
     'Scheduler.jsm',
     'ServerClient.jsm',
     'SQLiteStore.jsm',
+    'Sync.jsm',
 ]
 
 TESTING_JS_MODULES += [
     'test/ReadingListTestUtils.jsm',
 ]
 
 BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
 
--- a/browser/components/readinglist/sidebar.js
+++ b/browser/components/readinglist/sidebar.js
@@ -296,16 +296,21 @@ let RLSidebar = {
     log.debug(`Opening page ${url}`);
 
     let mainWindow = window.QueryInterface(Ci.nsIInterfaceRequestor)
                            .getInterface(Ci.nsIWebNavigation)
                            .QueryInterface(Ci.nsIDocShellTreeItem)
                            .rootTreeItem
                            .QueryInterface(Ci.nsIInterfaceRequestor)
                            .getInterface(Ci.nsIDOMWindow);
+
+    let currentUrl = mainWindow.gBrowser.currentURI.spec;
+    if (currentUrl.startsWith("about:reader"))
+      url = "about:reader?url=" + encodeURIComponent(url);
+
     mainWindow.openUILink(url, event);
   },
 
   /**
    * Get the ID of the Item associated with a given list item element.
    * @param {element} node - List item element to get an ID for.
    * @return {string} Assocated Item ID.
    */
@@ -397,37 +402,44 @@ let RLSidebar = {
    * Handle a keydown event on the list box.
    * @param {Event} event - Triggering event.
    */
   onListKeyDown(event) {
     if (event.keyCode == KeyEvent.DOM_VK_DOWN) {
       // TODO: Refactor this so we pass a direction to a generic method.
       // See autocomplete.xml's getNextIndex
       event.preventDefault();
+
+      if (!this.numItems) {
+        return;
+      }
       let index = this.selectedIndex + 1;
       if (index >= this.numItems) {
         index = 0;
       }
 
       this.selectedIndex = index;
       this.selectedItem.focus();
     } else if (event.keyCode == KeyEvent.DOM_VK_UP) {
       event.preventDefault();
 
+      if (!this.numItems) {
+        return;
+      }
       let index = this.selectedIndex - 1;
       if (index < 0) {
         index = this.numItems - 1;
       }
 
       this.selectedIndex = index;
       this.selectedItem.focus();
     } else if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
       let selectedItem = this.selectedItem;
       if (selectedItem) {
-        this.activeItem = this.selectedItem;
+        this.activeItem = selectedItem;
         this.openActiveItem(event);
       }
     }
   },
 
   /**
    * Handle a message, typically sent from browser-readinglist.js
    * @param {Event} event - Triggering event.
--- a/browser/components/readinglist/test/xpcshell/test_ReadingList.js
+++ b/browser/components/readinglist/test/xpcshell/test_ReadingList.js
@@ -33,17 +33,16 @@ add_task(function* prepare() {
   for (let i = 0; i < 3; i++) {
     gItems.push({
       guid: `guid${i}`,
       url: `http://example.com/${i}`,
       resolvedURL: `http://example.com/resolved/${i}`,
       title: `title ${i}`,
       excerpt: `excerpt ${i}`,
       unread: 0,
-      lastModified: Date.now(),
       favorite: 0,
       isArticle: 1,
       storedOn: Date.now(),
     });
   }
 
   for (let item of gItems) {
     let addedItem = yield gList.addItem(item);
@@ -132,17 +131,36 @@ add_task(function* constraints() {
   delete item.url;
   err = null;
   try {
     yield gList.addItem(item);
   }
   catch (e) {
     err = e;
   }
-  checkError(err);
+  Assert.ok(err);
+  Assert.ok(err instanceof Cu.getGlobalForObject(ReadingList).Error, err);
+  Assert.equal(err.message, "The item must have a url");
+
+  // update an item with no url
+  item = (yield gList.item({ guid: gItems[0].guid }));
+  Assert.ok(item);
+  let oldURL = item._record.url;
+  item._record.url = null;
+  err = null;
+  try {
+    yield gList.updateItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  item._record.url = oldURL;
+  Assert.ok(err);
+  Assert.ok(err instanceof Cu.getGlobalForObject(ReadingList).Error, err);
+  Assert.equal(err.message, "The item must have a url");
 
   // add an item with a bogus property
   item = kindOfClone(gItems[0]);
   item.bogus = "gnarly";
   err = null;
   try {
     yield gList.addItem(item);
   }
@@ -264,16 +282,29 @@ add_task(function* forEachItem() {
     title: gItems[1].title,
     sort: "guid",
   }, {
     guid: gItems[0].guid,
   });
   checkItems(items, [gItems[0], gItems[1]]);
 });
 
+add_task(function* forEachSyncedDeletedItem() {
+  let deletedItem = yield gList.addItem({
+    guid: "forEachSyncedDeletedItem",
+    url: "http://example.com/forEachSyncedDeletedItem",
+  });
+  deletedItem._record.syncStatus = gList.SyncStatus.SYNCED;
+  yield gList.deleteItem(deletedItem);
+  let items = [];
+  yield gList.forEachSyncedDeletedItem(item => items.push(item));
+  Assert.equal(items.length, 1);
+  Assert.equal(items[0].guid, deletedItem.guid);
+});
+
 add_task(function* forEachItem_promises() {
   // promises resolved immediately
   let items = [];
   yield gList.forEachItem(item => {
     items.push(item);
     return Promise.resolve();
   }, {
     sort: "guid",
@@ -535,46 +566,32 @@ add_task(function* updateItem() {
 add_task(function* item_setRecord() {
   // get an item
   let iter = gList.iterator({
     sort: "guid",
   });
   let item = (yield iter.items(1))[0];
   Assert.ok(item);
 
-  // Set item._record without an updateItem.  After fetching the item again, its
-  // title should be the old title.
-  let oldTitle = item.title;
+  // Set item._record followed by an updateItem.  After fetching the item again,
+  // its title should be the new title.
   let newTitle = "item_setRecord title 1";
-  Assert.notEqual(oldTitle, newTitle);
   item._record.title = newTitle;
+  yield gList.updateItem(item);
   Assert.equal(item.title, newTitle);
   iter = gList.iterator({
     sort: "guid",
   });
   let sameItem = (yield iter.items(1))[0];
   Assert.ok(item === sameItem);
-  Assert.equal(sameItem.title, oldTitle);
-
-  // Set item._record followed by an updateItem.  After fetching the item again,
-  // its title should be the new title.
-  newTitle = "item_setRecord title 2";
-  item._record.title = newTitle;
-  yield gList.updateItem(item);
-  Assert.equal(item.title, newTitle);
-  iter = gList.iterator({
-    sort: "guid",
-  });
-  sameItem = (yield iter.items(1))[0];
-  Assert.ok(item === sameItem);
   Assert.equal(sameItem.title, newTitle);
 
   // Set item.title directly and call updateItem.  After fetching the item
   // again, its title should be the new title.
-  newTitle = "item_setRecord title 3";
+  newTitle = "item_setRecord title 2";
   item.title = newTitle;
   yield gList.updateItem(item);
   Assert.equal(item.title, newTitle);
   iter = gList.iterator({
     sort: "guid",
   });
   sameItem = (yield iter.items(1))[0];
   Assert.ok(item === sameItem);
@@ -673,21 +690,19 @@ add_task(function* deleteItem() {
     sort: "guid",
   });
   checkItems(items, gItems.slice(3));
 });
 
 function checkItems(actualItems, expectedItems) {
   Assert.equal(actualItems.length, expectedItems.length);
   for (let i = 0; i < expectedItems.length; i++) {
-    for (let prop in expectedItems[i]) {
-      if (prop != "list") {
-        Assert.ok(prop in actualItems[i]._record, prop);
-        Assert.equal(actualItems[i]._record[prop], expectedItems[i][prop]);
-      }
+    for (let prop in expectedItems[i]._record) {
+      Assert.ok(prop in actualItems[i]._record, prop);
+      Assert.equal(actualItems[i]._record[prop], expectedItems[i][prop]);
     }
   }
 }
 
 function checkError(err) {
   Assert.ok(err);
   Assert.ok(err instanceof Cu.getGlobalForObject(Sqlite).Error, err);
 }
--- a/browser/components/readinglist/test/xpcshell/test_SQLiteStore.js
+++ b/browser/components/readinglist/test/xpcshell/test_SQLiteStore.js
@@ -156,152 +156,155 @@ add_task(function* constraints() {
   }
   Assert.ok(!err, err ? err.message : undefined);
   let url2 = item.url;
 
   // Delete both items since other tests assume the store contains only gItems.
   yield gStore.deleteItemByURL(url1);
   yield gStore.deleteItemByURL(url2);
   let items = [];
-  yield gStore.forEachItem(i => items.push(i), { url: [url1, url2] });
+  yield gStore.forEachItem(i => items.push(i), [{ url: [url1, url2] }]);
   Assert.equal(items.length, 0);
-
-  // add a new item with no url, which is not allowed
-  item = kindOfClone(gItems[0]);
-  delete item.url;
-  err = null;
-  try {
-    yield gStore.addItem(item);
-  }
-  catch (e) {
-    err = e;
-  }
-  checkError(err, "NOT NULL constraint failed: items.url");
 });
 
 add_task(function* count() {
   let count = yield gStore.count();
   Assert.equal(count, gItems.length);
 
-  count = yield gStore.count({
+  count = yield gStore.count([{
     guid: gItems[0].guid,
-  });
+  }]);
   Assert.equal(count, 1);
 });
 
 add_task(function* forEachItem() {
   // all items
   let items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems);
 
   // first item
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     limit: 1,
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems.slice(0, 1));
 
   // last item
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     limit: 1,
     sort: "guid",
     descending: true,
-  });
+  }]);
   checkItems(items, gItems.slice(gItems.length - 1, gItems.length));
 
   // match on a scalar property
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     guid: gItems[0].guid,
-  });
+  }]);
   checkItems(items, gItems.slice(0, 1));
 
   // match on an array
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     guid: gItems.map(i => i.guid),
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems);
 
   // match on AND'ed properties
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     guid: gItems.map(i => i.guid),
     title: gItems[0].title,
     sort: "guid",
-  });
+  }]);
   checkItems(items, [gItems[0]]);
 
   // match on OR'ed properties
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     guid: gItems[1].guid,
     sort: "guid",
   }, {
     guid: gItems[0].guid,
-  });
+  }]);
   checkItems(items, [gItems[0], gItems[1]]);
 
   // match on AND'ed and OR'ed properties
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     guid: gItems.map(i => i.guid),
     title: gItems[1].title,
     sort: "guid",
   }, {
     guid: gItems[0].guid,
-  });
+  }]);
   checkItems(items, [gItems[0], gItems[1]]);
 });
 
 add_task(function* updateItem() {
   let newTitle = "a new title";
   gItems[0].title = newTitle;
   yield gStore.updateItem(gItems[0]);
   let item;
-  yield gStore.forEachItem(i => item = i, {
+  yield gStore.forEachItem(i => item = i, [{
     guid: gItems[0].guid,
-  });
+  }]);
+  Assert.ok(item);
+  Assert.equal(item.title, gItems[0].title);
+});
+
+add_task(function* updateItemByGUID() {
+  let newTitle = "updateItemByGUID";
+  gItems[0].title = newTitle;
+  yield gStore.updateItemByGUID(gItems[0]);
+  let item;
+  yield gStore.forEachItem(i => item = i, [{
+    guid: gItems[0].guid,
+  }]);
   Assert.ok(item);
   Assert.equal(item.title, gItems[0].title);
 });
 
 // This test deletes items so it should probably run last.
 add_task(function* deleteItemByURL() {
   // delete first item
   yield gStore.deleteItemByURL(gItems[0].url);
   Assert.equal((yield gStore.count()), gItems.length - 1);
   let items = [];
-  yield gStore.forEachItem(i => items.push(i), {
+  yield gStore.forEachItem(i => items.push(i), [{
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems.slice(1));
 
   // delete second item
   yield gStore.deleteItemByURL(gItems[1].url);
   Assert.equal((yield gStore.count()), gItems.length - 2);
   items = [];
-  yield gStore.forEachItem(i => items.push(i), {
+  yield gStore.forEachItem(i => items.push(i), [{
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems.slice(2));
+});
 
+// This test deletes items so it should probably run last.
+add_task(function* deleteItemByGUID() {
   // delete third item
-  yield gStore.deleteItemByURL(gItems[2].url);
+  yield gStore.deleteItemByGUID(gItems[2].guid);
   Assert.equal((yield gStore.count()), gItems.length - 3);
-  items = [];
-  yield gStore.forEachItem(i => items.push(i), {
+  let items = [];
+  yield gStore.forEachItem(i => items.push(i), [{
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems.slice(3));
 });
 
 function checkItems(actualItems, expectedItems) {
   Assert.equal(actualItems.length, expectedItems.length);
   for (let i = 0; i < expectedItems.length; i++) {
     for (let prop in expectedItems[i]) {
       Assert.ok(prop in actualItems[i], prop);
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/test/xpcshell/test_Sync.js
@@ -0,0 +1,330 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let gProfildDirFile = do_get_profile();
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource:///modules/readinglist/Sync.jsm");
+
+let { localRecordFromServerRecord } =
+  Cu.import("resource:///modules/readinglist/Sync.jsm", {});
+
+let gList;
+let gSync;
+let gClient;
+let gLocalItems = [];
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function* prepare() {
+  gSync = Sync;
+  gList = Sync.list;
+  let dbFile = gProfildDirFile.clone();
+  dbFile.append(gSync.list._store.pathRelativeToProfileDir);
+  do_register_cleanup(function* () {
+    // Wait for the list's store to close its connection to the database.
+    yield gList.destroy();
+    if (dbFile.exists()) {
+      dbFile.remove(true);
+    }
+  });
+
+  gClient = new MockClient();
+  gSync._client = gClient;
+
+  let dumpAppender = new Log.DumpAppender();
+  dumpAppender.level = Log.Level.All;
+  let logNames = [
+    "readinglist.sync",
+  ];
+  for (let name of logNames) {
+    let log = Log.repository.getLogger(name);
+    log.level = Log.Level.All;
+    log.addAppender(dumpAppender);
+  }
+});
+
+add_task(function* uploadNewItems() {
+  // Add some local items.
+  for (let i = 0; i < 3; i++) {
+    let record = {
+      url: `http://example.com/${i}`,
+      title: `title ${i}`,
+      addedBy: "device name",
+    };
+    gLocalItems.push(yield gList.addItem(record));
+  }
+
+  Assert.ok(!("resolvedURL" in gLocalItems[0]._record));
+  yield gSync.start();
+
+  // The syncer should update local items with the items in the server response.
+  // e.g., the item didn't have a resolvedURL before sync, but after sync it
+  // should.
+  Assert.ok("resolvedURL" in gLocalItems[0]._record);
+
+  checkItems(gClient.items, gLocalItems);
+});
+
+add_task(function* uploadStatusChanges() {
+  // Change an item's unread from true to false.
+  Assert.ok(gLocalItems[0].unread === true);
+
+  gLocalItems[0].unread = false;
+  yield gList.updateItem(gLocalItems[0]);
+  yield gSync.start();
+
+  Assert.ok(gLocalItems[0].unread === false);
+  checkItems(gClient.items, gLocalItems);
+});
+
+add_task(function* downloadChanges() {
+  // Change an item on the server.
+  let newTitle = "downloadChanges new title";
+  let response = yield gClient.request({
+    method: "PATCH",
+    path: "/articles/1",
+    body: {
+      title: newTitle,
+    },
+  });
+  Assert.equal(response.status, 200);
+
+  // Add a new item on the server.
+  let newRecord = {
+    url: "http://example.com/downloadChanges-new-item",
+    title: "downloadChanges 2",
+    added_by: "device name",
+  };
+  response = yield gClient.request({
+    method: "POST",
+    path: "/articles",
+    body: newRecord,
+  });
+  Assert.equal(response.status, 201);
+
+  // Delete an item on the server.
+  response = yield gClient.request({
+    method: "DELETE",
+    path: "/articles/2",
+  });
+  Assert.equal(response.status, 200);
+
+  yield gSync.start();
+
+  // Refresh the list of local items.  The changed item should be changed
+  // locally, the deleted item should be deleted locally, and the new item
+  // should appear in the list.
+  gLocalItems = (yield gList.iterator({ sort: "guid" }).
+                 items(gLocalItems.length));
+
+  Assert.equal(gLocalItems[1].title, newTitle);
+  Assert.equal(gLocalItems[2].url, newRecord.url);
+  checkItems(gClient.items, gLocalItems);
+});
+
+
+function MockClient() {
+  this._items = [];
+  this._nextItemID = 0;
+  this._nextLastModifiedToken = 0;
+}
+
+MockClient.prototype = {
+
+  request(req) {
+    let response = this._routeRequest(req);
+    return new Promise(resolve => {
+      // Resolve the promise asyncly, just as if this were a real server, so
+      // that we don't somehow end up depending on sync behavior.
+      setTimeout(() => {
+        resolve(response);
+      }, 0);
+    });
+  },
+
+  get items() {
+    return this._items.slice().sort((item1, item2) => {
+      return item2.id < item1.id;
+    });
+  },
+
+  itemByID(id) {
+    return this._items.find(item => item.id == id);
+  },
+
+  itemByURL(url) {
+    return this._items.find(item => item.url == url);
+  },
+
+  _items: null,
+  _nextItemID: null,
+  _nextLastModifiedToken: null,
+
+  _routeRequest(req) {
+    for (let prop in this) {
+      let match = (new RegExp("^" + prop + "$")).exec(req.path);
+      if (match) {
+        let handler = this[prop];
+        let method = req.method.toLowerCase();
+        if (!(method in handler)) {
+          throw new Error(`Handler ${prop} does not support method ${method}`);
+        }
+        let response = handler[method].call(this, req.body, match);
+        // Make sure the response really is JSON'able (1) as a kind of sanity
+        // check, (2) to convert any non-primitives (e.g., new String()) into
+        // primitives, and (3) because that's what the real server returns.
+        response = JSON.parse(JSON.stringify(response));
+        return response;
+      }
+    }
+    throw new Error(`Unrecognized path: ${req.path}`);
+  },
+
+  // route handlers
+
+  "/articles": {
+
+    get(body) {
+      return new MockResponse(200, {
+        // No URL params supported right now.
+        items: this.items,
+      });
+    },
+
+    post(body) {
+      let existingItem = this.itemByURL(body.url);
+      if (existingItem) {
+        // The real server seems to return a 200 if the items are identical.
+        if (areSameItems(existingItem, body)) {
+          return new MockResponse(200);
+        }
+        // 303 see other
+        return new MockResponse(303, {
+          id: existingItem.id,
+        });
+      }
+      body.id = new String(this._nextItemID++);
+      let defaultProps = {
+        last_modified: this._nextLastModifiedToken,
+        preview: "",
+        resolved_url: body.url,
+        resolved_title: body.title,
+        excerpt: "",
+        archived: 0,
+        deleted: 0,
+        favorite: false,
+        is_article: true,
+        word_count: null,
+        unread: true,
+        added_on: null,
+        stored_on: this._nextLastModifiedToken,
+        marked_read_by: null,
+        marked_read_on: null,
+        read_position: null,
+      };
+      for (let prop in defaultProps) {
+        if (!(prop in body) || body[prop] === null) {
+          body[prop] = defaultProps[prop];
+        }
+      }
+      this._nextLastModifiedToken++;
+      this._items.push(body);
+      // 201 created
+      return new MockResponse(201, body);
+    },
+  },
+
+  "/articles/([^/]+)": {
+
+    get(body, routeMatch) {
+      let id = routeMatch[1];
+      let item = this.itemByID(id);
+      if (!item) {
+        return new MockResponse(404);
+      }
+      return new MockResponse(200, item);
+    },
+
+    patch(body, routeMatch) {
+      let id = routeMatch[1];
+      let item = this.itemByID(id);
+      if (!item) {
+        return new MockResponse(404);
+      }
+      for (let prop in body) {
+        item[prop] = body[prop];
+      }
+      item.last_modified = this._nextLastModifiedToken++;
+      return new MockResponse(200, item);
+    },
+
+    delete(body, routeMatch) {
+      let id = routeMatch[1];
+      let item = this.itemByID(id);
+      if (!item) {
+        return new MockResponse(404);
+      }
+      item.deleted = true;
+      return new MockResponse(200);
+    },
+  },
+
+  "/batch": {
+
+    post(body) {
+      let responses = [];
+      let defaults = body.defaults || {};
+      for (let request of body.requests) {
+        for (let prop in defaults) {
+          if (!(prop in request)) {
+            request[prop] = defaults[prop];
+          }
+        }
+        responses.push(this._routeRequest(request));
+      }
+      return new MockResponse(200, {
+        defaults: defaults,
+        responses: responses,
+      });
+    },
+  },
+};
+
+function MockResponse(status, body, headers={}) {
+  this.status = status;
+  this.body = body;
+  this.headers = headers;
+}
+
+function areSameItems(item1, item2) {
+  for (let prop in item1) {
+    if (!(prop in item2) || item1[prop] != item2[prop]) {
+      return false;
+    }
+  }
+  for (let prop in item2) {
+    if (!(prop in item1) || item1[prop] != item2[prop]) {
+      return false;
+    }
+  }
+  return true;
+}
+
+function checkItems(serverRecords, localItems) {
+  serverRecords = serverRecords.map(r => localRecordFromServerRecord(r));
+  serverRecords = serverRecords.filter(r => !r.deleted);
+  Assert.equal(serverRecords.length, localItems.length);
+  for (let i = 0; i < serverRecords.length; i++) {
+    for (let prop in localItems[i]._record) {
+      Assert.ok(prop in serverRecords[i], prop);
+      Assert.equal(serverRecords[i][prop], localItems[i]._record[prop]);
+    }
+  }
+}
--- a/browser/components/readinglist/test/xpcshell/test_scheduler.js
+++ b/browser/components/readinglist/test/xpcshell/test_scheduler.js
@@ -21,28 +21,40 @@ function promiseObserver(topic) {
     let obs = (subject, topic, data) => {
       Services.obs.removeObserver(obs, topic);
       resolve(data);
     }
     Services.obs.addObserver(obs, topic, false);
   });
 }
 
+function ReadingListMock() {
+  this.listener = null;
+}
+
+ReadingListMock.prototype = {
+  addListener(listener) {
+    ok(!this.listener, "mock only expects 1 listener");
+    this.listener = listener;
+  },
+}
+
 function createScheduler(options) {
   // avoid typos in the test and other footguns in the options.
   let allowedOptions = ["expectedDelay", "expectNewTimer", "syncFunction"];
   for (let key of Object.keys(options)) {
     if (allowedOptions.indexOf(key) == -1) {
       throw new Error("Invalid option " + key);
     }
   }
-  let scheduler = createTestableScheduler();
+  let rlMock = new ReadingListMock();
+  let scheduler = createTestableScheduler(rlMock);
   // make our hooks
   let syncFunction = options.syncFunction || Promise.resolve;
-  scheduler._engine.sync = syncFunction;
+  scheduler._engine.start = syncFunction;
   // we expect _setTimeout to be called *twice* - first is the initial sync,
   // and there's no need to test the delay used for that. options.expectedDelay
   // is to check the *subsequent* timer.
   let numCalls = 0;
   scheduler._setTimeout = function(delay) {
     ++numCalls;
     print("Test scheduler _setTimeout call number " + numCalls + " with delay=" + delay);
     switch (numCalls) {
@@ -85,16 +97,37 @@ add_task(function* testSuccess() {
   ];
   // New delay should be "as regularly scheduled".
   prefs.set("schedule", 100);
   let scheduler = createScheduler({expectedDelay: 100});
   yield Promise.all(allNotifications);
   scheduler.finalize();
 });
 
+// Test that if we get a reading list notification while we are syncing we
+// immediately start a new one when it complets.
+add_task(function* testImmediateResyncWhenChangedDuringSync() {
+  // promises which resolve once we've got all the expected notifications.
+  let allNotifications = [
+    promiseObserver("readinglist:sync:start"),
+    promiseObserver("readinglist:sync:finish"),
+  ];
+  prefs.set("schedule", 100);
+  // New delay should be "immediate".
+  let scheduler = createScheduler({
+    expectedDelay: 0,
+    syncFunction: () => {
+      // we are now syncing - pretend the readinglist has an item change
+      scheduler.readingList.listener.onItemAdded();
+      return Promise.resolve();
+    }});
+  yield Promise.all(allNotifications);
+  scheduler.finalize();
+});
+
 add_task(function* testOffline() {
   let scheduler = createScheduler({expectNewTimer: false});
   Services.io.offline = true;
   ok(!scheduler._canSync(), "_canSync is false when offline.")
   ok(!scheduler._timer, "there is no current timer while offline.")
   Services.io.offline = false;
   ok(scheduler._canSync(), "_canSync is true when online.")
   ok(scheduler._timer, "there is a new timer when back online.")
--- a/browser/components/readinglist/test/xpcshell/xpcshell.ini
+++ b/browser/components/readinglist/test/xpcshell/xpcshell.ini
@@ -1,8 +1,9 @@
 [DEFAULT]
 head = head.js
 firefox-appdir = browser
 
 [test_ReadingList.js]
 [test_ServerClient.js]
 [test_scheduler.js]
 [test_SQLiteStore.js]
+[test_Sync.js]
--- a/browser/components/translation/BingTranslator.jsm
+++ b/browser/components/translation/BingTranslator.jsm
@@ -8,17 +8,17 @@ const {classes: Cc, interfaces: Ci, util
 
 this.EXPORTED_SYMBOLS = [ "BingTranslator" ];
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://services-common/utils.js");
-Cu.import("resource://services-common/rest.js");
+Cu.import("resource://gre/modules/Http.jsm");
 
 // The maximum amount of net data allowed per request on Bing's API.
 const MAX_REQUEST_DATA = 5000; // Documentation says 10000 but anywhere
                                // close to that is refused by the service.
 
 // The maximum number of chunks allowed to be translated in a single
 // request.
 const MAX_REQUEST_CHUNKS = 1000; // Documentation says 2000.
@@ -124,23 +124,23 @@ this.BingTranslator.prototype = {
   /**
    * Function called when a request sent to the server has failed.
    * This function handles deciding if the error is transient or means the
    * service is unavailable (zero balance on the key or request credentials are
    * not in an active state) and calling the function to resolve the promise
    * returned by the public `translate()` method when there's no pending.
    * request left.
    *
-   * @param   aError   [optional] The RESTRequest that failed.
+   * @param   aError   [optional] The XHR object of the request that failed.
    */
   _chunkFailed: function(aError) {
-    if (aError instanceof RESTRequest &&
-        [400, 401].indexOf(aError.response.status) != -1) {
-      let body = aError.response.body;
-      if (body.contains("TranslateApiException") &&
+    if (aError instanceof Ci.nsIXMLHttpRequest &&
+        [400, 401].indexOf(aError.status) != -1) {
+      let body = aError.responseText;
+      if (body && body.contains("TranslateApiException") &&
           (body.contains("balance") || body.contains("active state")))
         this._serviceUnavailable = true;
     }
 
     this._checkIfFinished();
   },
 
   /**
@@ -173,23 +173,19 @@ this.BingTranslator.prototype = {
    * particular interest, the only part of the response that matters
    * are the <TranslatedText> nodes, which contains the resulting
    * items that were sent to be translated.
    *
    * @param   request      The request sent to the server.
    * @returns boolean      True if parsing of this chunk was successful.
    */
   _parseChunkResult: function(bingRequest) {
-    let domParser = Cc["@mozilla.org/xmlextras/domparser;1"]
-                      .createInstance(Ci.nsIDOMParser);
-
     let results;
     try {
-      let doc = domParser.parseFromString(bingRequest.networkRequest
-                                                     .response.body, "text/xml");
+      let doc = bingRequest.networkRequest.responseXML;
       results = doc.querySelectorAll("TranslatedText");
     } catch (e) {
       return false;
     }
 
     let len = results.length;
     if (len != bingRequest.translationData.length) {
       // This should never happen, but if the service returns a different number
@@ -286,25 +282,28 @@ function BingRequest(translationData, so
 }
 
 BingRequest.prototype = {
   /**
    * Initiates the request
    */
   fireRequest: function() {
     return Task.spawn(function *(){
+      // Prepare authentication.
       let token = yield BingTokenManager.getToken();
       let auth = "Bearer " + token;
+
+      // Prepare URL.
       let url = getUrlParam("https://api.microsofttranslator.com/v2/Http.svc/TranslateArray",
-                            "browser.translation.bing.translateArrayURL",
-                            false);
-      let request = new RESTRequest(url);
-      request.setHeader("Content-type", "text/xml");
-      request.setHeader("Authorization", auth);
+                            "browser.translation.bing.translateArrayURL");
 
+      // Prepare request headers.
+      let headers = [["Content-type", "text/xml"], ["Authorization", auth]];
+
+      // Prepare the request body.
       let requestString =
         '<TranslateArrayRequest>' +
           '<AppId/>' +
           '<From>' + this.sourceLanguage + '</From>' +
           '<Options>' +
             '<ContentType xmlns="http://schemas.datacontract.org/2004/07/Microsoft.MT.Web.Service.V2">text/html</ContentType>' +
             '<ReservedFlags xmlns="http://schemas.datacontract.org/2004/07/Microsoft.MT.Web.Service.V2" />' +
           '</Options>' +
@@ -314,26 +313,34 @@ BingRequest.prototype = {
         requestString += '<s:string>' + text + '</s:string>';
         this.characterCount += text.length;
       }
 
       requestString += '</Texts>' +
           '<To>' + this.targetLanguage + '</To>' +
         '</TranslateArrayRequest>';
 
-      let utf8 = CommonUtils.encodeUTF8(requestString);
-
+      // Set up request options.
       let deferred = Promise.defer();
-      request.post(utf8, function(err) {
-        if (request.error || !request.response.success)
-          deferred.reject(request);
+      let options = {
+        onLoad: (function(responseText, xhr) {
+          deferred.resolve(this);
+        }).bind(this),
+        onError: function(e, responseText, xhr) {
+          deferred.reject(xhr);
+        },
+        postData: requestString,
+        headers: headers
+      };
 
-        deferred.resolve(this);
-      }.bind(this));
+      // Fire the request.
+      let request = httpRequest(url, options);
 
+      // Override the response MIME type.
+      request.overrideMimeType("text/xml");
       this.networkRequest = request;
       return deferred.promise;
     }.bind(this));
   }
 };
 
 /**
  * Authentication Token manager for the API
@@ -368,55 +375,56 @@ let BingTokenManager = {
   /**
    * Generates a new token from the server.
    *
    * @returns {Promise}  A promise that resolves with the token
    *                     string once it is obtained.
    */
   _getNewToken: function() {
     let url = getUrlParam("https://datamarket.accesscontrol.windows.net/v2/OAuth2-13",
-                          "browser.translation.bing.authURL",
-                          false);
-    let request = new RESTRequest(url);
-    request.setHeader("Content-type", "application/x-www-form-urlencoded");
+                          "browser.translation.bing.authURL");
     let params = [
-      "grant_type=client_credentials",
-      "scope=" + encodeURIComponent("http://api.microsofttranslator.com"),
-      "client_id=" +
-      getUrlParam("%BING_API_CLIENTID%", "browser.translation.bing.clientIdOverride"),
-      "client_secret=" +
-      getUrlParam("%BING_API_KEY%", "browser.translation.bing.apiKeyOverride")
+      ["grant_type", "client_credentials"],
+      ["scope", "http://api.microsofttranslator.com"],
+      ["client_id",
+      getUrlParam("%BING_API_CLIENTID%", "browser.translation.bing.clientIdOverride")],
+      ["client_secret",
+      getUrlParam("%BING_API_KEY%", "browser.translation.bing.apiKeyOverride")]
     ];
 
     let deferred = Promise.defer();
-    this._pendingRequest = deferred.promise;
-    request.post(params.join("&"), function(err) {
-      BingTokenManager._pendingRequest = null;
+    let options = {
+      onLoad: function(responseText, xhr) {
+        BingTokenManager._pendingRequest = null;
+        try {
+          let json = JSON.parse(responseText);
 
-      if (err) {
-        deferred.reject(err);
-      }
-
-      try {
-        let json = JSON.parse(this.response.body);
+          if (json.error) {
+            deferred.reject(json.error);
+            return;
+          }
 
-        if (json.error) {
-          deferred.reject(json.error);
-          return;
+          let token = json.access_token;
+          let expires_in = json.expires_in;
+          BingTokenManager._currentToken = token;
+          BingTokenManager._currentExpiryTime = new Date(Date.now() + expires_in * 1000);
+          deferred.resolve(token);
+        } catch (e) {
+          deferred.reject(e);
         }
+      },
+      onError: function(e, responseText, xhr) {
+        BingTokenManager._pendingRequest = null;
+        deferred.reject(e);
+      },
+      postData: params
+    };
 
-        let token = json.access_token;
-        let expires_in = json.expires_in;
-        BingTokenManager._currentToken = token;
-        BingTokenManager._currentExpiryTime = new Date(Date.now() + expires_in * 1000);
-        deferred.resolve(token);
-      } catch (e) {
-        deferred.reject(e);
-      }
-    });
+    this._pendingRequest = deferred.promise;
+    let request = httpRequest(url, options);
 
     return deferred.promise;
   }
 };
 
 /**
  * Escape a string to be valid XML content.
  */
@@ -428,15 +436,14 @@ function escapeXML(aStr) {
              .replace(/</g, "&lt;")
              .replace(/>/g, "&gt;");
 }
 
 /**
  * Fetch an auth token (clientID or client secret), which may be overridden by
  * a pref if it's set.
  */
-function getUrlParam(paramValue, prefName, encode = true) {
+function getUrlParam(paramValue, prefName) {
   if (Services.prefs.getPrefType(prefName))
     paramValue = Services.prefs.getCharPref(prefName);
   paramValue = Services.urlFormatter.formatURL(paramValue);
-
-  return encode ? encodeURIComponent(paramValue) : paramValue;
+  return paramValue;
 }
--- a/browser/components/translation/test/bing.sjs
+++ b/browser/components/translation/test/bing.sjs
@@ -146,17 +146,17 @@ function reallyHandleRequest(req, res) {
 
   let body = getRequestBody(req);
   log("body: " + body);
 
   // First, we'll see if we're dealing with an XML body:
   let contentType = req.hasHeader("Content-Type") ? req.getHeader("Content-Type") : null;
   log("contentType: " + contentType);
 
-  if (contentType == "text/xml") {
+  if (contentType.startsWith("text/xml")) {
     try {
       // For all these requests the client needs to supply the correct
       // authentication headers.
       checkAuth(req);
 
       let xml = parseXml(body);
       let method = xml.documentElement.localName;
       log("invoking method: " + method);
--- a/browser/devtools/markupview/markup-view.css
+++ b/browser/devtools/markupview/markup-view.css
@@ -161,17 +161,17 @@ ul.children + .tag-line::before {
   margin-right: -1em;
   padding: 1px 0;
 }
 
 .newattr:focus {
   margin-right: 0;
 }
 
-.tag-state.flash-out {
+.flash-out {
   transition: background .5s;
 }
 
 .tag-line {
   cursor: default;
 }
 
 .markupview-events {
--- a/browser/devtools/markupview/markup-view.js
+++ b/browser/devtools/markupview/markup-view.js
@@ -22,16 +22,17 @@ const DRAG_DROP_MAX_AUTOSCROLL_SPEED = 1
 const {UndoStack} = require("devtools/shared/undo");
 const {editableField, InplaceEditor} = require("devtools/shared/inplace-editor");
 const {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
 const {HTMLEditor} = require("devtools/markupview/html-editor");
 const promise = require("resource://gre/modules/Promise.jsm").Promise;
 const {Tooltip} = require("devtools/shared/widgets/Tooltip");
 const EventEmitter = require("devtools/toolkit/event-emitter");
 const Heritage = require("sdk/core/heritage");
+const {setTimeout, clearTimeout, setInterval, clearInterval} = require("sdk/timers");
 const ELLIPSIS = Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data;
 
 Cu.import("resource://gre/modules/devtools/LayoutHelpers.jsm");
 Cu.import("resource://gre/modules/devtools/Templater.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 loader.lazyGetter(this, "DOMParser", function() {
@@ -169,42 +170,42 @@ MarkupView.prototype = {
   _onMouseMove: function(event) {
     if (this.isDragging) {
       event.preventDefault();
       this._dragStartEl = event.target;
 
       let docEl = this.doc.documentElement;
 
       if (this._scrollInterval) {
-        this.win.clearInterval(this._scrollInterval);
+        clearInterval(this._scrollInterval);
       }
 
       // Auto-scroll when the mouse approaches top/bottom edge
       let distanceFromBottom = docEl.clientHeight - event.pageY + this.win.scrollY,
           distanceFromTop = event.pageY - this.win.scrollY;
 
       if (distanceFromBottom <= DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE) {
         // Map our distance from 0-50 to 5-15 range so the speed is kept
         // in a range not too fast, not too slow
         let speed = map(distanceFromBottom, 0, DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE,
                         DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED);
         // Here, we use minus because the value of speed - 15 is always negative
         // and it makes the speed relative to the distance between mouse and edge
         // the closer to the edge, the faster
-        this._scrollInterval = this.win.setInterval(() => {
+        this._scrollInterval = setInterval(() => {
           docEl.scrollTop -= speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED;
         }, 0);
       }
 
       if (distanceFromTop <= DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE) {
         // refer to bottom edge's comments for more info
         let speed = map(distanceFromTop, 0, DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE,
                         DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED);
 
-        this._scrollInterval = this.win.setInterval(() => {
+        this._scrollInterval = setInterval(() => {
           docEl.scrollTop += speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED;
         }, 0);
       }
 
       return;
     };
 
     let target = event.target;
@@ -251,17 +252,17 @@ MarkupView.prototype = {
   _onMouseUp: function() {
     if (this._lastDropTarget) {
       this.indicateDropTarget(null);
     }
     if (this._lastDragTarget) {
       this.indicateDragTarget(null);
     }
     if (this._scrollInterval) {
-      this.win.clearInterval(this._scrollInterval);
+      clearInterval(this._scrollInterval);
     }
   },
 
   _hoveredNode: null,
 
   /**
    * Show a NodeFront's container as being hovered
    * @param {NodeFront} nodeFront The node to show as hovered
@@ -276,17 +277,17 @@ MarkupView.prototype = {
     }
 
     this.getContainer(nodeFront).hovered = true;
     this._hoveredNode = nodeFront;
   },
 
   _onMouseLeave: function() {
     if (this._scrollInterval) {
-      this.win.clearInterval(this._scrollInterval);
+      clearInterval(this._scrollInterval);
     }
     if (this.isDragging) return;
 
     this._hideBoxModel(true);
     if (this._hoveredNode) {
       this.getContainer(this._hoveredNode).hovered = false;
     }
     this._hoveredNode = null;
@@ -315,23 +316,23 @@ MarkupView.prototype = {
     return this._inspector.toolbox.highlighterUtils.unhighlight(forceHide);
   },
 
   _briefBoxModelTimer: null,
   _brieflyShowBoxModel: function(nodeFront) {
     let win = this._frame.contentWindow;
 
     if (this._briefBoxModelTimer) {
-      win.clearTimeout(this._briefBoxModelTimer);
+      clearTimeout(this._briefBoxModelTimer);
       this._briefBoxModelTimer = null;
     }
 
     this._showBoxModel(nodeFront);
 
-    this._briefBoxModelTimer = this._frame.contentWindow.setTimeout(() => {
+    this._briefBoxModelTimer = setTimeout(() => {
       this._hideBoxModel();
     }, NEW_SELECTION_HIGHLIGHTER_TIMER);
   },
 
   template: function(aName, aDest, aOptions={stack: "markup-view.xhtml"}) {
     let node = this.doc.getElementById("template-" + aName).cloneNode(true);
     node.removeAttribute("id");
     template(node, aDest, aOptions);
@@ -775,21 +776,26 @@ MarkupView.prototype = {
   /**
    * Given a list of mutations returned by the mutation observer, flash the
    * corresponding containers to attract attention.
    */
   _flashMutatedNodes: function(aMutations) {
     let addedOrEditedContainers = new Set();
     let removedContainers = new Set();
 
-    for (let {type, target, added, removed} of aMutations) {
+    for (let {type, target, added, removed, newValue} of aMutations) {
       let container = this.getContainer(target);
 
       if (container) {
-        if (type === "attributes" || type === "characterData") {
+        if (type === "characterData") {
+          addedOrEditedContainers.add(container);
+        } else if (type === "attributes" && newValue === null) {
+          // Removed attributes should flash the entire node.
+          // New or changed attributes will flash the attribute itself
+          // in ElementEditor.flashAttribute.
           addedOrEditedContainers.add(container);
         } else if (type === "childList") {
           // If there has been removals, flash the parent
           if (removed.length) {
             removedContainers.add(container);
           }
 
           // If there has been additions, flash the nodes if their associated
@@ -1514,19 +1520,19 @@ MarkupView.prototype = {
    * Hide the preview while resizing, to avoid slowness.
    */
   _resizePreview: function() {
     if (!this._previewEnabled) {
       return;
     }
     let win = this._frame.contentWindow;
     this._previewBar.classList.add("hide");
-    win.clearTimeout(this._resizePreviewTimeout);
-
-    win.setTimeout(() => {
+    clearTimeout(this._resizePreviewTimeout);
+
+    setTimeout(() => {
       this._updatePreview();
       this._previewBar.classList.remove("hide");
     }, 1000);
   },
 
   /**
    * Takes an element as it's only argument and marks the element
    * as the drop target
@@ -1813,17 +1819,17 @@ MarkupContainer.prototype = {
     // target is the MarkupContainer itself.
     this._isMouseDown = true;
     this.hovered = false;
     this.markup.navigate(this);
     event.stopPropagation();
 
     // Start dragging the container after a delay.
     this.markup._dragStartEl = target;
-    this.win.setTimeout(() => {
+    setTimeout(() => {
       // Make sure the mouse is still down and on target.
       if (!this._isMouseDown || this.markup._dragStartEl !== target ||
           this.node.isPseudoElement || this.node.isAnonymous ||
           !this.win.getSelection().isCollapsed) {
         return;
       }
       this.isDragging = true;
 
@@ -1878,57 +1884,27 @@ MarkupContainer.prototype = {
 
   /**
    * Temporarily flash the container to attract attention.
    * Used for markup mutations.
    */
   flashMutation: function() {
     if (!this.selected) {
       let contentWin = this.win;
-      this.flashed = true;
+      flashElementOn(this.tagState, this.editor.elt);
       if (this._flashMutationTimer) {
-        contentWin.clearTimeout(this._flashMutationTimer);
+        clearTimeout(this._flashMutationTimer);
         this._flashMutationTimer = null;
       }
-      this._flashMutationTimer = contentWin.setTimeout(() => {
-        this.flashed = false;
+      this._flashMutationTimer = setTimeout(() => {
+        flashElementOff(this.tagState, this.editor.elt);
       }, this.markup.CONTAINER_FLASHING_DURATION);
     }
   },
 
-  set flashed(aValue) {
-    if (aValue) {
-      // Make sure the animation class is not here
-      this.tagState.classList.remove("flash-out");
-
-      // Change the background
-      this.tagState.classList.add("theme-bg-contrast");
-
-      // Change the text color
-      this.editor.elt.classList.add("theme-fg-contrast");
-      [].forEach.call(
-        this.editor.elt.querySelectorAll("[class*=theme-fg-color]"),
-        span => span.classList.add("theme-fg-contrast")
-      );
-    } else {
-      // Add the animation class to smoothly remove the background
-      this.tagState.classList.add("flash-out");
-
-      // Remove the background
-      this.tagState.classList.remove("theme-bg-contrast");
-
-      // Remove the text color
-      this.editor.elt.classList.remove("theme-fg-contrast");
-      [].forEach.call(
-        this.editor.elt.querySelectorAll("[class*=theme-fg-color]"),
-        span => span.classList.remove("theme-fg-contrast")
-      );
-    }
-  },
-
   _hovered: false,
 
   /**
    * Highlight the currently hovered tag + its closing tag if necessary
    * (that is if the tag is expanded)
    */
   set hovered(aValue) {
     this.tagState.classList.remove("flash-out");
@@ -2345,16 +2321,17 @@ TextEditor.prototype = {
 function ElementEditor(aContainer, aNode) {
   this.container = aContainer;
   this.node = aNode;
   this.markup = this.container.markup;
   this.template = this.markup.template.bind(this.markup);
   this.doc = this.markup.doc;
 
   this.attrs = {};
+  this.animationTimers = {};
 
   // The templates will fill the following properties
   this.elt = null;
   this.tag = null;
   this.closeTag = null;
   this.attrList = null;
   this.newAttr = null;
   this.closeElt = null;
@@ -2402,45 +2379,66 @@ function ElementEditor(aContainer, aNode
   });
 
   let tagName = this.node.nodeName.toLowerCase();
   this.tag.textContent = tagName;
   this.closeTag.textContent = tagName;
   this.eventNode.style.display = this.node.hasEventListeners ? "inline-block" : "none";
 
   this.update();
+  this.initialized = true;
 }
 
 ElementEditor.prototype = {
+
+  flashAttribute: function(attrName) {
+    if (this.animationTimers[attrName]) {
+      clearTimeout(this.animationTimers[attrName]);
+    }
+
+    flashElementOn(this.getAttributeElement(attrName));
+
+    this.animationTimers[attrName] = setTimeout(() => {
+      flashElementOff(this.getAttributeElement(attrName));
+    }, this.markup.CONTAINER_FLASHING_DURATION);
+  },
+
   /**
    * Update the state of the editor from the node.
    */
   update: function() {
     let attrs = this.node.attributes || [];
     let attrsToRemove = new Set(this.attrList.querySelectorAll(".attreditor"));
 
     // Only loop through the current attributes on the node, anything that's
     // been removed will be removed from this DOM because it will be part of
     // the attrsToRemove set.
     for (let attr of attrs) {
       let el = this.attrs[attr.name];
       let valueChanged = el && el.querySelector(".attr-value").innerHTML !== attr.value;
       let isEditing = el && el.querySelector(".editable").inplaceEditor;
-      let needToCreateAttributeEditor = el && (!valueChanged || isEditing);
-
-      if (needToCreateAttributeEditor) {
+      let canSimplyShowEditor = el && (!valueChanged || isEditing);
+
+      if (canSimplyShowEditor) {
         // Element already exists and doesn't need to be recreated.
         // Just show it (it's hidden by default due to the template).
         attrsToRemove.delete(el);
         el.style.removeProperty("display");
       } else {
         // Create a new editor, because the value of an existing attribute
         // has changed.
         let attribute = this._createAttribute(attr);
         attribute.style.removeProperty("display");
+
+        // Temporarily flash the attribute to highlight the change.
+        // But not if this is the first time the editor instance has
+        // been created.
+        if (this.initialized) {
+          this.flashAttribute(attr.name);
+        }
       }
     }
 
     for (let el of attrsToRemove) {
       el.remove();
     }
   },
 
@@ -2703,17 +2701,22 @@ ElementEditor.prototype = {
     // selected afterwards.
     this.markup.reselectOnRemoved(this.node, "edittagname");
     this.markup.walker.editTagName(this.node, newTagName).then(null, () => {
       // Failed to edit the tag name, cancel the reselection.
       this.markup.cancelReselectOnRemoved();
     });
   },
 
-  destroy: function() {}
+  destroy: function() {
+    for (let key in this.animationTimers) {
+      clearTimeout(this.animationTimers[key]);
+    }
+    this.animationTimers = null;
+  }
 };
 
 function nodeDocument(node) {
   return node.ownerDocument ||
     (node.nodeType == Ci.nsIDOMNode.DOCUMENT_NODE ? node : null);
 }
 
 function truncateString(str, maxLength) {
@@ -2758,16 +2761,72 @@ function parseAttributeValues(attr, doc)
     catch(e) { }
   }
 
   // Attributes return from DOMParser in reverse order from how they are entered.
   return attributes.reverse();
 }
 
 /**
+ * Apply a 'flashed' background and foreground color to elements.  Intended
+ * to be used with flashElementOff as a way of drawing attention to an element.
+ *
+ * @param  {Node} backgroundElt
+ *         The element to set the highlighted background color on.
+ * @param  {Node} foregroundElt
+ *         The element to set the matching foreground color on.
+ *         Optional.  This will equal backgroundElt if not set.
+ */
+function flashElementOn(backgroundElt, foregroundElt=backgroundElt) {
+  if (!backgroundElt || !foregroundElt) {
+    return;
+  }
+
+  // Make sure the animation class is not here
+  backgroundElt.classList.remove("flash-out");
+
+  // Change the background
+  backgroundElt.classList.add("theme-bg-contrast");
+
+  foregroundElt.classList.add("theme-fg-contrast");
+  [].forEach.call(
+    foregroundElt.querySelectorAll("[class*=theme-fg-color]"),
+    span => span.classList.add("theme-fg-contrast")
+  );
+}
+
+/**
+ * Remove a 'flashed' background and foreground color to elements.
+ * See flashElementOn.
+ *
+ * @param  {Node} backgroundElt
+ *         The element to reomve the highlighted background color on.
+ * @param  {Node} foregroundElt
+ *         The element to remove the matching foreground color on.
+ *         Optional.  This will equal backgroundElt if not set.
+ */
+function flashElementOff(backgroundElt, foregroundElt=backgroundElt) {
+  if (!backgroundElt || !foregroundElt) {
+    return;
+  }
+
+  // Add the animation class to smoothly remove the background
+  backgroundElt.classList.add("flash-out");
+
+  // Remove the background
+  backgroundElt.classList.remove("theme-bg-contrast");
+
+  foregroundElt.classList.remove("theme-fg-contrast");
+  [].forEach.call(
+    foregroundElt.querySelectorAll("[class*=theme-fg-color]"),
+    span => span.classList.remove("theme-fg-contrast")
+  );
+}
+
+/**
  * Map a number from one range to another.
  */
 function map(value, oldMin, oldMax, newMin, newMax) {
   let ratio = oldMax - oldMin;
   if (ratio == 0) {
     return value;
   }
   return newMin + (newMax - newMin) * ((value - oldMin) / ratio);
--- a/browser/devtools/markupview/test/browser_markupview_mutation_02.js
+++ b/browser/devtools/markupview/test/browser_markupview_mutation_02.js
@@ -8,16 +8,18 @@
 // corresponding DOM nodes mutate
 
 const TEST_URL = TEST_URL_ROOT + "doc_markup_flashing.html";
 
 // The test data contains a list of mutations to test.
 // Each item is an object:
 // - desc: a description of the test step, for better logging
 // - mutate: a function that should make changes to the content DOM
+// - attribute: if set, the test will expect the corresponding attribute to flash
+//   instead of the whole node
 // - flashedNode: [optional] the css selector of the node that is expected to
 //   flash in the markup-view as a result of the mutation.
 //   If missing, the rootNode (".list") will be expected to flash
 const TEST_DATA = [{
   desc: "Adding a new node should flash the new node",
   mutate: (doc, rootNode) => {
     let newLi = doc.createElement("LI");
     newLi.textContent = "new list item";
@@ -31,26 +33,39 @@ const TEST_DATA = [{
   }
 }, {
   desc: "Re-appending an existing node should only flash this node",
   mutate: (doc, rootNode) => {
     rootNode.appendChild(rootNode.firstElementChild);
   },
   flashedNode: ".list .item:last-child"
 }, {
-  desc: "Adding an attribute should flash the node",
+  desc: "Adding an attribute should flash the attribute",
+  attribute: "test-name",
   mutate: (doc, rootNode) => {
-    rootNode.setAttribute("name-" + Date.now(), "value-" + Date.now());
+    rootNode.setAttribute("test-name", "value-" + Date.now());
   }
 }, {
-  desc: "Editing an attribute should flash the node",
+  desc: "Editing an attribute should flash the attribute",
+  attribute: "class",
   mutate: (doc, rootNode) => {
     rootNode.setAttribute("class", "list value-" + Date.now());
   }
 }, {
+  desc: "Multiple changes to an attribute should flash the attribute",
+  attribute: "class",
+  mutate: (doc, rootNode) => {
+    rootNode.removeAttribute("class");
+    rootNode.setAttribute("class", "list value-" + Date.now());
+    rootNode.setAttribute("class", "list value-" + Date.now());
+    rootNode.removeAttribute("class");
+    rootNode.setAttribute("class", "list value-" + Date.now());
+    rootNode.setAttribute("class", "list value-" + Date.now());
+  }
+}, {
   desc: "Removing an attribute should flash the node",
   mutate: (doc, rootNode) => {
     rootNode.removeAttribute("class");
   }
 }];
 
 add_task(function*() {
   let {inspector} = yield addTab(TEST_URL).then(openInspector);
@@ -61,40 +76,59 @@ add_task(function*() {
 
   info("Getting the <ul.list> root node to test mutations on");
   let rootNode = getNode(".list");
   let rootNodeFront = yield getNodeFront(".list", inspector);
 
   info("Selecting the last element of the root node before starting");
   yield selectNode(".list .item:nth-child(2)", inspector);
 
-  for (let {mutate, flashedNode, desc} of TEST_DATA) {
+  for (let {mutate, flashedNode, desc, attribute} of TEST_DATA) {
     info("Starting test: " + desc);
 
     info("Mutating the DOM and listening for markupmutation event");
     let mutated = inspector.once("markupmutation");
     let updated = inspector.once("inspector-updated");
     mutate(content.document, rootNode);
     yield mutated;
 
     info("Asserting that the correct markup-container is flashing");
     let flashingNodeFront = rootNodeFront;
     if (flashedNode) {
       flashingNodeFront = yield getNodeFront(flashedNode, inspector);
     }
-    yield assertNodeFlashing(flashingNodeFront, inspector);
+
+    if (attribute) {
+      yield assertAttributeFlashing(flashingNodeFront, attribute, inspector);
+    } else {
+      yield assertNodeFlashing(flashingNodeFront, inspector);
+    }
 
     // Making sure the inspector has finished updating before moving on
     yield updated;
   }
 });
 
 function* assertNodeFlashing(nodeFront, inspector) {
   let container = getContainerForNodeFront(nodeFront, inspector);
   ok(container, "Markup container for node found");
   ok(container.tagState.classList.contains("theme-bg-contrast"),
     "Markup container for node is flashing");
 
   // Clear the mutation flashing timeout now that we checked the node was flashing
   let markup = inspector.markup;
-  markup._frame.contentWindow.clearTimeout(container._flashMutationTimer);
+  clearTimeout(container._flashMutationTimer);
   container._flashMutationTimer = null;
+  container.tagState.classList.remove("theme-bg-contrast");
 }
+
+function* assertAttributeFlashing(nodeFront, attribute, inspector) {
+  let container = getContainerForNodeFront(nodeFront, inspector);
+  ok(container, "Markup container for node found");
+  ok(container.editor.attrs[attribute], "Attribute exists on editor");
+
+  let attributeElement = container.editor.getAttributeElement(attribute);
+
+  ok(attributeElement.classList.contains("theme-bg-contrast"),
+    "Element for " + attribute + " attribute is flashing");
+
+  attributeElement.classList.remove("theme-bg-contrast");
+}
--- a/browser/devtools/markupview/test/browser_markupview_tag_edit_03.js
+++ b/browser/devtools/markupview/test/browser_markupview_tag_edit_03.js
@@ -9,31 +9,42 @@
 const TEST_URL = "data:text/html;charset=utf-8,<div id='retag-me'><div id='retag-me-2'></div></div>";
 
 add_task(function*() {
   let {toolbox, inspector} = yield addTab(TEST_URL).then(openInspector);
 
   yield inspector.markup.expandAll();
 
   info("Selecting the test node");
-  let node = content.document.querySelector("#retag-me");
-  let child = content.document.querySelector("#retag-me-2");
   yield selectNode("#retag-me", inspector);
 
+  info("Getting the markup-container for the test node");
   let container = yield getContainerForSelector("#retag-me", inspector);
-  is(node.tagName, "DIV", "We've got #retag-me element, it's a DIV");
-  ok(container.expanded, "It is expanded");
-  is(child.parentNode, node, "Child #retag-me-2 is inside #retag-me");
+  ok(container.expanded, "The container is expanded");
 
-  info("Changing the tagname");
+  let parentInfo = yield getNodeInfo("#retag-me");
+  is(parentInfo.tagName.toLowerCase(), "div",
+     "We've got #retag-me element, it's a DIV");
+  is(parentInfo.numChildren, 1, "#retag-me has one child");
+  let childInfo = yield getNodeInfo("#retag-me > *");
+  is(childInfo.attributes[0].value, "retag-me-2",
+     "#retag-me's only child is #retag-me-2");
+
+  info("Changing #retag-me's tagname in the markup-view");
   let mutated = inspector.once("markupmutation");
   let tagEditor = container.editor.tag;
   setEditableFieldValue(tagEditor, "p", inspector);
   yield mutated;
 
-  info("Checking that the tagname change was done");
-  node = content.document.querySelector("#retag-me");
+  info("Checking that the markup-container exists and is correct");
   container = yield getContainerForSelector("#retag-me", inspector);
-  is(node.tagName, "P", "We've got #retag-me, it should now be a P");
-  ok(container.expanded, "It is still expanded");
-  ok(container.selected, "It is still selected");
-  is(child.parentNode, node, "Child #retag-me-2 is still inside #retag-me");
+  ok(container.expanded, "The container is still expanded");
+  ok(container.selected, "The container is still selected");
+
+  info("Checking that the tagname change was done");
+  parentInfo = yield getNodeInfo("#retag-me");
+  is(parentInfo.tagName.toLowerCase(), "p",
+     "The #retag-me element is now a P");
+  is(parentInfo.numChildren, 1, "#retag-me still has one child");
+  childInfo = yield getNodeInfo("#retag-me > *");
+  is(childInfo.attributes[0].value, "retag-me-2",
+     "#retag-me's only child is #retag-me-2");
 });
--- a/browser/devtools/markupview/test/head.js
+++ b/browser/devtools/markupview/test/head.js
@@ -4,16 +4,17 @@
 
 const Cu = Components.utils;
 let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 let TargetFactory = devtools.TargetFactory;
 let {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
 let promise = devtools.require("resource://gre/modules/Promise.jsm").Promise;
 let {getInplaceEditorForSpan: inplaceEditor} = devtools.require("devtools/shared/inplace-editor");
 let clipboard = devtools.require("sdk/clipboard");
+let {setTimeout, clearTimeout} = devtools.require("sdk/timers");
 
 // All test are asynchronous
 waitForExplicitFinish();
 
 // If a test times out we want to see the complete log and not just the last few
 // lines.
 SimpleTest.requestCompleteLog();
 
@@ -42,16 +43,17 @@ registerCleanupFunction(function*() {
 
   while (gBrowser.tabs.length > 1) {
     gBrowser.removeCurrentTab();
   }
 });
 
 const TEST_URL_ROOT = "http://mochi.test:8888/browser/browser/devtools/markupview/test/";
 const CHROME_BASE = "chrome://mochitests/content/browser/browser/devtools/markupview/test/";
+const COMMON_FRAME_SCRIPT_URL = "chrome://browser/content/devtools/frame-script-utils.js";
 
 /**
  * Add a new test tab in the browser and load the given url.
  * @param {String} url The url to be loaded in the new tab
  * @return a promise that resolves to the tab object when the url is loaded
  */
 function addTab(url) {
   info("Adding a new tab with URL: '" + url + "'");
@@ -60,16 +62,19 @@ function addTab(url) {
   // Bug 921935 should bring waitForFocus() support to e10s, which would
   // probably cover the case of the test losing focus when the page is loading.
   // For now, we just make sure the window is focused.
   window.focus();
 
   let tab = window.gBrowser.selectedTab = window.gBrowser.addTab(url);
   let linkedBrowser = tab.linkedBrowser;
 
+  info("Loading the helper frame script " + COMMON_FRAME_SCRIPT_URL);
+  linkedBrowser.messageManager.loadFrameScript(COMMON_FRAME_SCRIPT_URL, false);
+
   linkedBrowser.addEventListener("load", function onload() {
     linkedBrowser.removeEventListener("load", onload, true);
     info("URL '" + url + "' loading complete");
     def.resolve(tab);
   }, true);
 
   return def.promise;
 }
@@ -119,16 +124,60 @@ function openInspector() {
       def.resolve({toolbox: toolbox, inspector: inspector});
     });
   }).then(null, console.error);
 
   return def.promise;
 }
 
 /**
+ * Wait for a content -> chrome message on the message manager (the window
+ * messagemanager is used).
+ * @param {String} name The message name
+ * @return {Promise} A promise that resolves to the response data when the
+ * message has been received
+ */
+function waitForContentMessage(name) {
+  info("Expecting message " + name + " from content");
+
+  let mm = gBrowser.selectedBrowser.messageManager;
+
+  let def = promise.defer();
+  mm.addMessageListener(name, function onMessage(msg) {
+    mm.removeMessageListener(name, onMessage);
+    def.resolve(msg.data);
+  });
+  return def.promise;
+}
+
+/**
+ * Send an async message to the frame script (chrome -> content) and wait for a
+ * response message with the same name (content -> chrome).
+ * @param {String} name The message name. Should be one of the messages defined
+ * in doc_frame_script.js
+ * @param {Object} data Optional data to send along
+ * @param {Object} objects Optional CPOW objects to send along
+ * @param {Boolean} expectResponse If set to false, don't wait for a response
+ * with the same name from the content script. Defaults to true.
+ * @return {Promise} Resolves to the response data if a response is expected,
+ * immediately resolves otherwise
+ */
+function executeInContent(name, data={}, objects={}, expectResponse=true) {
+  info("Sending message " + name + " to content");
+  let mm = gBrowser.selectedBrowser.messageManager;
+
+  mm.sendAsyncMessage(name, data, objects);
+  if (expectResponse) {
+    return waitForContentMessage(name);
+  } else {
+    return promise.resolve();
+  }
+}
+
+/**
  * Simple DOM node accesor function that takes either a node or a string css
  * selector as argument and returns the corresponding node
  * @param {String|DOMNode} nodeOrSelector
  * @return {DOMNode|CPOW} Note that in e10s mode a CPOW object is returned which
  * doesn't implement *all* of the DOMNode's properties
  */
 function getNode(nodeOrSelector) {
   info("Getting the node for '" + nodeOrSelector + "'");
@@ -147,16 +196,25 @@ function getNode(nodeOrSelector) {
 function getNodeFront(selector, {walker}) {
   if (selector._form) {
     return selector;
   }
   return walker.querySelector(walker.rootNode, selector);
 }
 
 /**
+ * Get information about a DOM element, identified by its selector.
+ * @param {String} selector.
+ * @return {Promise} a promise that resolves to the element's information.
+ */
+function getNodeInfo(selector) {
+  return executeInContent("devtools:test:getDomElementInfo", {selector});
+}
+
+/**
  * Highlight a node and set the inspector's current selection to the node or
  * the first match of the given css selector.
  * @param {String|DOMNode} nodeOrSelector
  * @param {InspectorPanel} inspector
  *        The instance of InspectorPanel currently loaded in the toolbox
  * @return a promise that resolves when the inspector is updated with the new
  * node
  */
--- a/browser/devtools/shared/frame-script-utils.js
+++ b/browser/devtools/shared/frame-script-utils.js
@@ -129,16 +129,46 @@ addMessageListener("devtools:test:setSty
   }
 
   node.style[propertyName] = propertyValue;
 
   sendAsyncMessage("devtools:test:setStyle");
 });
 
 /**
+ * Get information about a DOM element, identified by a selector.
+ * @param {Object} data
+ * - {String} selector The CSS selector to get the node (can be a "super"
+ *   selector).
+ * @return {Object} data Null if selector didn't match any node, otherwise:
+ * - {String} tagName.
+ * - {String} namespaceURI.
+ * - {Number} numChildren The number of children in the element.
+ * - {Array} attributes An array of {name, value, namespaceURI} objects.
+ */
+addMessageListener("devtools:test:getDomElementInfo", function(msg) {
+  let {selector} = msg.data;
+  let node = superQuerySelector(selector);
+
+  let info = null;
+  if (node) {
+    info = {
+      tagName: node.tagName,
+      namespaceURI: node.namespaceURI,
+      numChildren: node.children.length,
+      attributes: [...node.attributes].map(({name, value, namespaceURI}) => {
+        return {name, value, namespaceURI};
+      })
+    };
+  }
+
+  sendAsyncMessage("devtools:test:getDomElementInfo", info);
+});
+
+/**
  * Set a given attribute value on a node.
  * @param {Object} data
  * - {String} selector The CSS selector to get the node (can be a "super"
  *   selector).
  * - {String} attributeName The name of the attribute to set.
  * - {String} attributeValue The value for the attribute.
  */
 addMessageListener("devtools:test:setAttribute", function(msg) {
--- a/browser/devtools/webide/content/webide.xul
+++ b/browser/devtools/webide/content/webide.xul
@@ -188,17 +188,17 @@
           <toolbarbutton class="panel-item" id="runtime-disconnect"  command="cmd_disconnectRuntime"/>
         </vbox>
       </vbox>
     </panel>
 
   </popupset>
 
   <notificationbox flex="1" id="notificationbox">
-    <hbox flex="1">
+    <hbox flex="1" id="deck-panels">
       <vbox id="project-listing-panel" class="project-listing" flex="1">
         <div id="project-listing-wrapper">
           <iframe id="project-listing-panel-details" flex="1" src="project-listing.xhtml"/>
         </div>
       </vbox>
       <splitter class="devtools-side-splitter" id="project-listing-splitter"/>
       <deck flex="1" id="deck" selectedIndex="-1">
         <iframe id="deck-panel-details" flex="1" src="details.xhtml"/>
--- a/browser/devtools/webide/themes/webide.css
+++ b/browser/devtools/webide/themes/webide.css
@@ -319,18 +319,20 @@ panel > .panel-arrowcontainer > .panel-a
 
 .devtools-horizontal-splitter {
   position: relative;
   border-bottom: 1px solid #aaa;
 }
 
 /* Toolbox */
 
-#notificationbox[toolboxfullscreen] > .devtools-horizontal-splitter,
-#notificationbox[toolboxfullscreen] > #deck,
-#notificationbox[toolboxfullscreen] > #deck > iframe {
+#notificationbox[toolboxfullscreen] > .devtools-horizontal-splitter {
   min-height: 0;
   max-height: 0;
 }
 
+#notificationbox[toolboxfullscreen] > #deck-panels {
+  display: none;
+}
+
 #notificationbox[toolboxfullscreen] > #toolbox {
   -moz-box-flex: 1;
 }
--- a/browser/locales/en-US/chrome/browser/browser.properties
+++ b/browser/locales/en-US/chrome/browser/browser.properties
@@ -14,36 +14,48 @@ droponhomemsg=Do you want this document 
 # %2$S is the selection string.
 contextMenuSearch=Search %1$S for "%2$S"
 contextMenuSearch.accesskey=S
 
 # bookmark dialog strings
 
 bookmarkAllTabsDefault=[Folder Name]
 
-xpinstallPromptWarning=%S prevented this site (%S) from asking you to install software on your computer.
+xpinstallPromptMessage=%S prevented this site from asking you to install software on your computer.
 xpinstallPromptAllowButton=Allow
 # Accessibility Note:
 # Be sure you do not choose an accesskey that is used elsewhere in the active context (e.g. main menu bar, submenu of the warning popup button)
 # See http://www.mozilla.org/access/keyboard/accesskey for details
 xpinstallPromptAllowButton.accesskey=A
 xpinstallDisabledMessageLocked=Software installation has been disabled by your system administrator.
 xpinstallDisabledMessage=Software installation is currently disabled. Click Enable and try again.
 xpinstallDisabledButton=Enable
 xpinstallDisabledButton.accesskey=n
 
-# LOCALIZATION NOTE (addonDownloading, addonDownloadCancelled, addonDownloadRestart):
+# LOCALIZATION NOTE (addonDownloadingAndVerifying):
 # Semicolon-separated list of plural forms. See:
 # http://developer.mozilla.org/en/docs/Localization_and_Plurals
 # Also see https://bugzilla.mozilla.org/show_bug.cgi?id=570012 for mockups
-addonDownloading=Add-on downloading;Add-ons downloading
-addonDownloadCancelled=Add-on download cancelled.;Add-on downloads cancelled.
-addonDownloadRestart=Restart Download;Restart Downloads
-addonDownloadRestart.accessKey=R
-addonDownloadCancelTooltip=Cancel
+addonDownloadingAndVerifying=Downloading and verifying add-on…;Downloading and verifying #1 add-ons…
+addonDownloadVerifying=Verifying
+
+addonInstall.cancelButton.label=Cancel
+addonInstall.cancelButton.accesskey=C
+addonInstall.acceptButton.label=Install
+addonInstall.acceptButton.accesskey=I
+
+# LOCALIZATION NOTE (addonConfirmInstallMessage):
+# Semicolon-separated list of plural forms. See:
+# http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 is brandShortName
+# #2 is the number of add-ons being installed
+addonConfirmInstall.message=This site would like to install an add-on in #1:;This site would like to install #2 add-ons in #1:
+# LOCALIZATION NOTE (addonConfirmInstall.author):
+# %S is the add-on author's name
+addonConfirmInstall.author=by %S
 
 addonwatch.slow=%1$S might be making %2$S run slowly
 addonwatch.disable.label=Disable %S
 addonwatch.disable.accesskey=D
 addonwatch.ignoreSession.label=Ignore for now
 addonwatch.ignoreSession.accesskey=I
 addonwatch.ignorePerm.label=Ignore permanently
 addonwatch.ignorePerm.accesskey=p
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -326,8 +326,11 @@ rooms_signout_alert=Open conversations w
 
 # Infobar strings
 
 infobar_screenshare_browser_message=Users in your conversation will now be able to see the contents of any tab you click on.
 infobar_button_gotit_label=Got it!
 infobar_button_gotit_accesskey=G
 infobar_menuitem_dontshowagain_label=Don't show this again
 infobar_menuitem_dontshowagain_accesskey=D
+
+# Context in conversation strings
+context_offer_label=Let's talk about this page
--- a/browser/themes/linux/browser.css
+++ b/browser/themes/linux/browser.css
@@ -1127,73 +1127,59 @@ toolbarbutton[sdk-button="true"][cui-are
 }
 
 #identity-popup-button-container {
   background: linear-gradient(to bottom, rgba(0,0,0,0.04) 60%, transparent);
   padding: 10px;
   margin-top: 5px;
 }
 
-/* Notification popup */
-#notification-popup {
-  min-width: 280px;
-}
-
 .popup-notification-icon {
   width: 64px;
   height: 64px;
   -moz-margin-end: 10px;
 }
 
 .popup-notification-icon[popupid="geolocation"] {
   list-style-image: url(chrome://browser/skin/Geolocation-64.png);
 }
 
 .popup-notification-icon[popupid="xpinstall-disabled"],
 .popup-notification-icon[popupid="addon-progress"],
-.popup-notification-icon[popupid="addon-install-cancelled"],
 .popup-notification-icon[popupid="addon-install-blocked"],
 .popup-notification-icon[popupid="addon-install-failed"],
+.popup-notification-icon[popupid="addon-install-confirmation"],
 .popup-notification-icon[popupid="addon-install-complete"] {
   list-style-image: url(chrome://mozapps/skin/extensions/extensionGeneric.png);
   width: 32px;
   height: 32px;
 }
 
+.popup-notification-description[popupid="addon-progress"],
+.popup-notification-description[popupid="addon-install-confirmation"] {
+  width: 27em;
+  max-width: 27em;
+}
+
+.popup-progress-meter {
+  margin-top: .5em;
+}
+
+.addon-install-confirmation-name {
+  font-weight: bold;
+}
+
 .popup-notification-icon[popupid="click-to-play-plugins"] {
   list-style-image: url(chrome://mozapps/skin/plugins/pluginBlocked-64.png);
 }
 
 .popup-notification-icon[popupid="web-notifications"] {
   list-style-image: url(chrome://browser/skin/notification-64.png);
 }
 
-.addon-progress-description {
-  width: 350px;
-  max-width: 350px;
-}
-
-.popup-progress-label,
-.popup-progress-meter {
-  -moz-margin-start: 0;
-  -moz-margin-end: 0;
-}
-
-.popup-progress-cancel {
-  -moz-appearance: none;
-  background: transparent;
-  border: none;
-  padding: 0;
-  margin: 0;
-  -moz-margin-start: 5px;
-  min-height: 0;
-  min-width: 0;
-  list-style-image: url("moz-icon://stock/gtk-cancel?size=menu");
-}
-
 .popup-notification-icon[popupid="indexedDB-permissions-prompt"],
 .popup-notification-icon[popupid*="offline-app-requested"],
 .popup-notification-icon[popupid="offline-app-usage"] {
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
 
 .popup-notification-icon[popupid="password"] {
   list-style-image: url(chrome://mozapps/skin/passwordmgr/key-64.png);
--- a/browser/themes/linux/readinglist/sidebar.css
+++ b/browser/themes/linux/readinglist/sidebar.css
@@ -1,18 +1,30 @@
 /* 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/. */
 
 %include ../../shared/readinglist/sidebar.inc.css
 
+html {
+  border: 1px solid ThreeDShadow;
+  background-color: -moz-Field;
+  color: -moz-FieldText;
+  box-sizing: border-box;
+}
+
 .item {
   -moz-padding-end: 0;
 }
 
+.item.active {
+  background-color: -moz-cellhighlight;
+  color: -moz-cellhighlighttext;
+}
+
 .item-title {
   margin: 1px 0 0;
 }
 
 .item-title, .item-domain {
   -moz-margin-end: 6px;
 }
 
--- a/browser/themes/osx/browser.css
+++ b/browser/themes/osx/browser.css
@@ -4145,58 +4145,49 @@ notification[value="loop-sharing-notific
 @media (min-resolution: 2dppx) {
   .popup-notification-icon[popupid="web-notifications"] {
     list-style-image: url(chrome://browser/skin/notification-64@2x.png);
   }
 }
 
 .popup-notification-icon[popupid="xpinstall-disabled"],
 .popup-notification-icon[popupid="addon-progress"],
-.popup-notification-icon[popupid="addon-install-cancelled"],
 .popup-notification-icon[popupid="addon-install-blocked"],
 .popup-notification-icon[popupid="addon-install-failed"],
+.popup-notification-icon[popupid="addon-install-confirmation"],
 .popup-notification-icon[popupid="addon-install-complete"] {
   list-style-image: url(chrome://mozapps/skin/extensions/extensionGeneric.png);
   width: 32px;
   height: 32px;
 }
 
-.popup-notification-icon[popupid="click-to-play-plugins"] {
-  list-style-image: url(chrome://mozapps/skin/plugins/pluginBlocked-64.png);
-}
-
-.addon-progress-description {
-  width: 350px;
-  max-width: 350px;
+.popup-notification-description[popupid="addon-progress"],
+.popup-notification-description[popupid="addon-install-confirmation"] {
+  width: 27em;
+  max-width: 27em;
 }
 
 .popup-progress-label,
 .popup-progress-meter {
   -moz-margin-start: 0;
   -moz-margin-end: 0;
 }
 
-.popup-progress-cancel {
-  -moz-appearance: none;
-  min-height: 16px;
-  min-width: 16px;
-  max-height: 16px;
-  max-width: 16px;
-  padding: 0;
-  margin: 0 1px 0 1px;
-  list-style-image: url(chrome://mozapps/skin/downloads/buttons.png);
-  -moz-image-region: rect(0px, 16px, 16px, 0px);
-}
-
-.popup-progress-cancel:hover {
-  -moz-image-region: rect(0px, 32px, 16px, 16px);
-}
-
-.popup-progress-cancel:active {
-  -moz-image-region: rect(0px, 48px, 16px, 32px);
+.popup-progress-meter,
+#addon-install-confirmation-content {
+  margin-top: 1em;
+}
+
+.addon-install-confirmation-name {
+  font-weight: bold;
+  -moz-margin-start: 0 !important; /* override default label margin to match description margin */
+}
+
+.popup-notification-icon[popupid="click-to-play-plugins"] {
+  list-style-image: url(chrome://mozapps/skin/plugins/pluginBlocked-64.png);
 }
 
 .popup-notification-icon[popupid="indexedDB-permissions-prompt"],
 .popup-notification-icon[popupid*="offline-app-requested"],
 .popup-notification-icon[popupid="offline-app-usage"] {
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
 
--- a/browser/themes/osx/readinglist/sidebar.css
+++ b/browser/themes/osx/readinglist/sidebar.css
@@ -1,14 +1,18 @@
 /* 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/. */
 
 %include ../../shared/readinglist/sidebar.inc.css
 
+html {
+  border-top: 1px solid #bdbdbd;
+}
+
 .item-title {
   margin: 4px 0 0;
 }
 
 .remove-button {
   background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 16, 16, 0);
 }
 
--- a/browser/themes/shared/readinglist/sidebar.inc.css
+++ b/browser/themes/shared/readinglist/sidebar.inc.css
@@ -5,17 +5,16 @@
 :root, body {
   height: 100%;
   overflow-x: hidden;
 }
 
 body {
   margin: 0;
   font: message-box;
-  background: #F8F7F8;
   color: #333333;
   -moz-user-select: none;
   overflow: hidden;
 }
 
 #emptyListInfo {
   cursor: default;
   padding: 3em 1em;
--- a/browser/themes/windows/browser.css
+++ b/browser/themes/windows/browser.css
@@ -2153,64 +2153,48 @@ toolbarbutton.bookmark-item[dragover="tr
 }
 
 .popup-notification-icon[popupid="geolocation"] {
   list-style-image: url(chrome://browser/skin/Geolocation-64.png);
 }
 
 .popup-notification-icon[popupid="xpinstall-disabled"],
 .popup-notification-icon[popupid="addon-progress"],
-.popup-notification-icon[popupid="addon-install-cancelled"],
 .popup-notification-icon[popupid="addon-install-blocked"],
 .popup-notification-icon[popupid="addon-install-failed"],
+.popup-notification-icon[popupid="addon-install-confirmation"],
 .popup-notification-icon[popupid="addon-install-complete"] {
   list-style-image: url(chrome://mozapps/skin/extensions/extensionGeneric.png);
   width: 32px;
   height: 32px;
 }
 
+.popup-notification-description[popupid="addon-progress"],
+.popup-notification-description[popupid="addon-install-confirmation"] {
+  width: 27em;
+  max-width: 27em;
+}
+
+.popup-progress-meter,
+#addon-install-confirmation-content {
+  margin-top: 1em;
+}
+
+.addon-install-confirmation-name {
+  font-weight: bold;
+}
+
 .popup-notification-icon[popupid="click-to-play-plugins"] {
   list-style-image: url(chrome://mozapps/skin/plugins/pluginBlocked-64.png);
 }
 
 .popup-notification-icon[popupid="web-notifications"] {
   list-style-image: url(chrome://browser/skin/notification-64.png);
 }
 
-.addon-progress-description {
-  width: 350px;
-  max-width: 350px;
-}
-
-.popup-progress-label,
-.popup-progress-meter {
-  -moz-margin-start: 0;
-  -moz-margin-end: 0;
-}
-
-.popup-progress-cancel {
-  -moz-appearance: none;
-  background: transparent;
-  border: none;
-  padding: 0;
-  margin: 0;
-  min-height: 0;
-  min-width: 0;
-  list-style-image: url(chrome://mozapps/skin/downloads/downloadButtons.png);
-  -moz-image-region: rect(0px, 32px, 16px, 16px);
-}
-
-.popup-progress-cancel:hover {
-  -moz-image-region: rect(16px, 32px, 32px, 16px);
-}
-
-.popup-progress-cancel:active {
-  -moz-image-region: rect(32px, 32px, 48px, 16px);
-}
-
 .popup-notification-icon[popupid="indexedDB-permissions-prompt"],
 .popup-notification-icon[popupid*="offline-app-requested"],
 .popup-notification-icon[popupid="offline-app-usage"] {
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
 
 .popup-notification-icon[popupid="password"] {
   list-style-image: url(chrome://mozapps/skin/passwordmgr/key-64.png);
--- a/browser/themes/windows/readinglist/sidebar.css
+++ b/browser/themes/windows/readinglist/sidebar.css
@@ -1,14 +1,18 @@
 /* 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/. */
 
 %include ../../shared/readinglist/sidebar.inc.css
 
+html {
+  background-color: #EEF3FA;
+}
+
 .item {
   -moz-padding-end: 0;
 }
 
 .item-title {
   margin: 1px 0 0;
 }
 
--- a/docshell/test/browser/browser.ini
+++ b/docshell/test/browser/browser.ini
@@ -89,17 +89,16 @@ skip-if = e10s # Bug ?????? - PlacesUtil
 skip-if = e10s # Bug 916974 - browser.sessionHistory is null
 [browser_bug673467.js]
 skip-if = e10s # Bug ?????? - test touches content (adds event listener to content document's iframe)
 [browser_bug852909.js]
 skip-if = e10s # Bug ?????? - event handler checks event.target is the content document and test e10s-utils doesn't do that.
 [browser_bug92473.js]
 skip-if = e10s # Bug ?????? - event handler checks event.target is the content document and test e10s-utils doesn't do that.
 [browser_uriFixupIntegration.js]
-skip-if = e10s
 [browser_loadDisallowInherit.js]
 skip-if = e10s
 [browser_loadURI.js]
 skip-if = e10s # Bug ?????? - event handler checks event.target is the content document and test e10s-utils doesn't do that.
 [browser_onbeforeunload_navigation.js]
 skip-if = e10s
 [browser_search_notification.js]
 [browser_timelineMarkers-01.js]
--- a/docshell/test/browser/browser_uriFixupIntegration.js
+++ b/docshell/test/browser/browser_uriFixupIntegration.js
@@ -1,80 +1,51 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
-const Cc = Components.classes;
-const Ci = Components.interfaces;
+"use strict";
 
 const kSearchEngineID = "browser_urifixup_search_engine";
 const kSearchEngineURL = "http://example.com/?search={searchTerms}";
-Services.search.addEngineWithDetails(kSearchEngineID, "", "", "", "get",
-                                     kSearchEngineURL);
+
+add_task(function* setup() {
+  // Add a new fake search engine.
+  Services.search.addEngineWithDetails(kSearchEngineID, "", "", "", "get",
+                                       kSearchEngineURL);
 
-let oldDefaultEngine = Services.search.defaultEngine;
-Services.search.defaultEngine = Services.search.getEngineByName(kSearchEngineID);
+  let oldDefaultEngine = Services.search.defaultEngine;
+  Services.search.defaultEngine = Services.search.getEngineByName(kSearchEngineID);
 
-let tab;
-let searchParams;
+  // Remove the fake engine when done.
+  registerCleanupFunction(() => {
+    if (oldDefaultEngine) {
+      Services.search.defaultEngine = oldDefaultEngine;
+    }
 
-function checkURL() {
-  let escapedParams = encodeURIComponent(searchParams).replace("%20", "+");
-  let expectedURL = kSearchEngineURL.replace("{searchTerms}", escapedParams);
-  is(tab.linkedBrowser.currentURI.spec, expectedURL,
-     "New tab should have loaded with expected url.");
-}
+    let engine = Services.search.getEngineByName(kSearchEngineID);
+    if (engine) {
+      Services.search.removeEngine(engine);
+    }
+  });
+});
 
-function addPageShowListener(aFunc) {
-  gBrowser.selectedBrowser.addEventListener("pageshow", function loadListener() {
-    gBrowser.selectedBrowser.removeEventListener("pageshow", loadListener, false);
-    aFunc();
-  });
-}
+add_task(function* test() {
+  for (let searchParams of ["foo bar", "brokenprotocol:somethingelse"]) {
+    // Add a new blank tab.
+    gBrowser.selectedTab = gBrowser.addTab("about:blank");
+    yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
-function locationBarEnter(aCallback) {
-  executeSoon(function() {
+    // Enter search terms and start a search.
+    gURLBar.value = searchParams;
     gURLBar.focus();
     EventUtils.synthesizeKey("VK_RETURN", {});
-    addPageShowListener(aCallback);
-  });
-}
-
-let urlbarInput = [
-  "foo bar",
-  "brokenprotocol:somethingelse"
-];
-function test() {
-  waitForExplicitFinish();
-
-  nextTest();
-}
-
-function nextTest() {
-  searchParams = urlbarInput.pop();
-  tab = gBrowser.selectedTab = gBrowser.addTab();
+    yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
-  gURLBar.value = searchParams;
-  locationBarEnter(function() {
-    checkURL();
-    gBrowser.removeTab(tab);
-    tab = null;
-    if (urlbarInput.length) {
-      nextTest();
-    } else {
-      finish();
-    }
-  });
-}
+    // Check that we arrived at the correct URL.
+    let escapedParams = encodeURIComponent(searchParams).replace("%20", "+");
+    let expectedURL = kSearchEngineURL.replace("{searchTerms}", escapedParams);
+    is(gBrowser.selectedBrowser.currentURI.spec, expectedURL,
+       "New tab should have loaded with expected url.");
 
-registerCleanupFunction(function () {
-  if (tab) {
-    gBrowser.removeTab(tab);
-  }
-
-  if (oldDefaultEngine) {
-    Services.search.defaultEngine = oldDefaultEngine;
-  }
-  let engine = Services.search.getEngineByName(kSearchEngineID);
-  if (engine) {
-    Services.search.removeEngine(engine);
+    // Cleanup.
+    gBrowser.removeCurrentTab();
   }
 });
-
--- a/dom/system/gonk/RadioInterfaceLayer.js
+++ b/dom/system/gonk/RadioInterfaceLayer.js
@@ -1847,38 +1847,29 @@ RadioInterface.prototype = {
         break;
     }
     return null;
   },
 
   handleUnsolicitedWorkerMessage: function(message) {
     let connHandler = gDataConnectionManager.getConnectionHandler(this.clientId);
     switch (message.rilMessageType) {
-      case "audioStateChanged":
-        gTelephonyService.notifyAudioStateChanged(this.clientId, message.state);
-        break;
       case "callRing":
         gTelephonyService.notifyCallRing();
         break;
-      case "callStateChange":
-        gTelephonyService.notifyCallStateChanged(this.clientId, message.call);
-        break;
-      case "callDisconnected":
-        gTelephonyService.notifyCallDisconnected(this.clientId, message.call);
-        break;
-      case "conferenceCallStateChanged":
-        gTelephonyService.notifyConferenceCallStateChanged(message.state);
+      case "currentCalls":
+        gTelephonyService.notifyCurrentCalls(this.clientId, message.calls);
         break;
       case "cdmaCallWaiting":
         gTelephonyService.notifyCdmaCallWaiting(this.clientId,
                                                 message.waitingCall);
         break;
       case "suppSvcNotification":
         gTelephonyService.notifySupplementaryService(this.clientId,
-                                                     message.callIndex,
+                                                     message.number,
                                                      message.notification);
         break;
       case "ussdreceived":
         gTelephonyService.notifyUssdReceived(this.clientId, message.message,
                                              message.sessionEnded);
         break;
       case "datacalllistchanged":
         connHandler.handleDataCallListChanged(message.datacalls);
--- a/dom/system/gonk/ril_consts.js
+++ b/dom/system/gonk/ril_consts.js
@@ -459,21 +459,16 @@ this.NETWORK_CREG_TECH_GSM = 16;
 this.NETWORK_CREG_TECH_DCHSPAP_1 = 18; // Some devices reports as 18
 this.NETWORK_CREG_TECH_DCHSPAP_2 = 19; // Some others report it as 19
 
 this.CELL_INFO_TYPE_GSM = 1;
 this.CELL_INFO_TYPE_CDMA = 2;
 this.CELL_INFO_TYPE_LTE = 3;
 this.CELL_INFO_TYPE_WCDMA = 4;
 
-// Order matters.
-this.AUDIO_STATE_NO_CALL  = 0;
-this.AUDIO_STATE_INCOMING = 1;
-this.AUDIO_STATE_IN_CALL  = 2;
-
 this.CALL_STATE_UNKNOWN = -1;
 this.CALL_STATE_ACTIVE = 0;
 this.CALL_STATE_HOLDING = 1;
 this.CALL_STATE_DIALING = 2;
 this.CALL_STATE_ALERTING = 3;
 this.CALL_STATE_INCOMING = 4;
 this.CALL_STATE_WAITING = 5;
 
--- a/dom/system/gonk/ril_worker.js
+++ b/dom/system/gonk/ril_worker.js
@@ -218,43 +218,35 @@ TelephonyRequestQueue.prototype = {
  * This object communicates with rild via parcels and with the main thread
  * via post messages. It maintains state about the radio, ICC, calls, etc.
  * and acts upon state changes accordingly.
  */
 function RilObject(aContext) {
   this.context = aContext;
 
   this.telephonyRequestQueue = new TelephonyRequestQueue(this);
-  this.currentCalls = {};
   this.currentConferenceState = CALL_STATE_UNKNOWN;
   this._pendingSentSmsMap = {};
   this.pendingNetworkType = {};
   this._receivedSmsCbPagesMap = {};
   this._getCurrentCallsRetryCount = 0;
 
   // Init properties that are only initialized once.
   this.v5Legacy = RILQUIRKS_V5_LEGACY;
-
-  this.pendingMO = null;
 }
 RilObject.prototype = {
   context: null,
 
   /**
    * RIL version.
    */
   version: null,
   v5Legacy: null,
 
   /**
-   * Valid calls.
-   */
-  currentCalls: null,
-
-  /**
    * Call state of current conference group.
    */
   currentConferenceState: null,
 
   /**
    * Outgoing messages waiting for SMS-STATUS-REPORT.
    */
   _pendingSentSmsMap: null,
@@ -349,21 +341,21 @@ RilObject.prototype = {
      */
     this.operator = null;
 
     /**
      * String containing the baseband version.
      */
     this.basebandVersion = null;
 
-    // Clean up this.currentCalls: rild might have restarted.
-    for each (let currentCall in this.currentCalls) {
-      delete this.currentCalls[currentCall.callIndex];
-      this._handleDisconnectedCall(currentCall);
-    }
+    // Clean up currentCalls: rild might have restarted.
+    this.sendChromeMessage({
+      rilMessageType: "currentCalls",
+      calls: {}
+    });
 
     // Don't clean up this._pendingSentSmsMap
     // because on rild restart: we may continue with the pending segments.
 
     /**
      * Whether or not the multiple requests in requestNetworkInfo() are currently
      * being processed
      */
@@ -397,22 +389,16 @@ RilObject.prototype = {
      */
     let cbmmi = this.cellBroadcastConfigs && this.cellBroadcastConfigs.MMI;
     this.cellBroadcastConfigs = {
       MMI: cbmmi || null
     };
     this.mergedCellBroadcastConfig = null;
 
     /**
-     * A successful dialing request.
-     * { options: options of the corresponding dialing request }
-     */
-    this.pendingMO = null;
-
-    /**
      * True when the request to report SMS Memory Status is pending.
      */
     this.pendingToReportSmsMemoryStatus = false;
     this.smsStorageAvailable = true;
   },
 
   /**
    * Parse an integer from a string, falling back to a default value
@@ -1433,76 +1419,41 @@ RilObject.prototype = {
     if (!options) {
       options = {internal: true};
     }
     this._cancelEmergencyCbModeTimeout();
     this.sendExitEmergencyCbModeRequest(options);
   },
 
   /**
-   * Cache the request for making an emergency call when radio is off. The
-   * request shall include two types of callback functions. 'callback' is
-   * called when radio is ready, and 'onerror' is called when turning radio
-   * on fails.
-   */
-  cachedDialRequest : null,
-
-  /**
    * Dial a non-emergency number.
    *
    * @param isEmergency
    *        Whether the number is an emergency number.
    * @param number
    *        String containing the number to dial.
    * @param clirMode
    *        Integer for showing/hidding the caller Id to the called party.
    * @param uusInfo
    *        Integer doing something XXX TODO
    */
   dial: function(options) {
-    let onerror = (function onerror(options, errorMsg) {
-      options.success = false;
-      options.errorMsg = errorMsg;
-      this.sendChromeMessage(options);
-    }).bind(this, options);
-
-    let isRadioOff = (this.radioState === GECKO_RADIOSTATE_DISABLED);
-
     if (options.isEmergency) {
       options.request = RILQUIRKS_REQUEST_USE_DIAL_EMERGENCY_CALL ?
                         REQUEST_DIAL_EMERGENCY_CALL : REQUEST_DIAL;
 
-      if (isRadioOff) {
-        if (DEBUG) {
-          this.context.debug("Automatically enable radio for an emergency call.");
-        }
-
-        this.cachedDialRequest = {
-          callback: this.dialInternal.bind(this, options),
-          onerror: onerror
-        };
-
-        this.setRadioEnabled({enabled: true});
-        return;
-      }
-
-      this.dialInternal(options);
     } else {
+      options.request = REQUEST_DIAL;
+
       // Exit emergency callback mode when user dial a non-emergency call.
       if (this._isInEmergencyCbMode) {
         this.exitEmergencyCbMode();
       }
-
-      options.request = REQUEST_DIAL;
-
-      this.dialInternal(options);
-    }
-  },
-
-  dialInternal: function(options) {
+    }
+
     this.telephonyRequestQueue.push(options.request, () => {
       let Buf = this.context.Buf;
       Buf.newParcel(options.request, options);
       Buf.writeString(options.number);
       Buf.writeInt32(options.clirMode || 0);
       Buf.writeInt32(options.uusInfo || 0);
       // TODO Why do we need this extra 0? It was put it in to make this
       // match the format of the binary message.
@@ -1527,76 +1478,33 @@ RilObject.prototype = {
 
   /**
    * Hang up the phone.
    *
    * @param callIndex
    *        Call index (1-based) as reported by REQUEST_GET_CURRENT_CALLS.
    */
   hangUpCall: function(options) {
-    let call = this.currentCalls[options.callIndex];
-    if (!call) {
-      // |hangUpCall()| is used to remove a call from the current call list,
-      // so we consider it as an successful case when hanging up a call that
-      // doesn't exist in the current call list.
-      options.success = true;
-      this.sendChromeMessage(options);
-      return;
-    }
-
-    call.hangUpLocal = true;
-    if (call.state === CALL_STATE_HOLDING) {
-      this.hangUpBackground(options);
-    } else {
-      this.telephonyRequestQueue.push(REQUEST_HANGUP, () => {
-        let Buf = this.context.Buf;
-        Buf.newParcel(REQUEST_HANGUP, options);
-        Buf.writeInt32(1);
-        Buf.writeInt32(options.callIndex);
-        Buf.sendParcel();
-      });
-    }
+    this.telephonyRequestQueue.push(REQUEST_HANGUP, () => {
+      let Buf = this.context.Buf;
+      Buf.newParcel(REQUEST_HANGUP, options);
+      Buf.writeInt32(1);
+      Buf.writeInt32(options.callIndex);
+      Buf.sendParcel();
+    });
   },
 
   hangUpForeground: function(options) {
-    for each (let currentCall in this.currentCalls) {
-      if (currentCall.state == CALL_STATE_ACTIVE) {
-        currentCall.hangUpLocal = true;
-      }
-    }
-
     this.telephonyRequestQueue.push(REQUEST_HANGUP_FOREGROUND_RESUME_BACKGROUND, () => {
       this.context.Buf.simpleRequest(REQUEST_HANGUP_FOREGROUND_RESUME_BACKGROUND,
                                      options);
     });
   },
 
   hangUpBackground: function(options) {
-    let waitingCalls = [];
-    let heldCalls = [];
-
-    for each (let currentCall in this.currentCalls) {
-      switch (currentCall.state) {
-        case CALL_STATE_WAITING:
-          waitingCalls.push(currentCall);
-          break;
-        case CALL_STATE_HOLDING:
-          heldCalls.push(currentCall);
-          break;
-      }
-    }
-
-    // When both a held and a waiting call exist, the request shall apply to
-    // the waiting call.
-    if (waitingCalls.length) {
-      waitingCalls.forEach(call => call.hangUpLocal = true);
-    } else {
-      heldCalls.forEach(call => call.hangUpLocal = true);
-    }
-
     this.telephonyRequestQueue.push(REQUEST_HANGUP_WAITING_OR_BACKGROUND, () => {
       this.context.Buf.simpleRequest(REQUEST_HANGUP_WAITING_OR_BACKGROUND,
                                      options);
     });
   },
 
   switchActiveCall: function(options) {
     this.telephonyRequestQueue.push(REQUEST_SWITCH_WAITING_OR_HOLDING_AND_ACTIVE, () => {
@@ -1606,98 +1514,20 @@ RilObject.prototype = {
   },
 
   udub: function(options) {
     this.telephonyRequestQueue.push(REQUEST_UDUB, () => {
       this.context.Buf.simpleRequest(REQUEST_UDUB, options);
     });
   },
 
-  /**
-   * Answer an incoming/waiting call.
-   *
-   * @param callIndex
-   *        Call index of the call to answer.
-   */
   answerCall: function(options) {
-    let call = this.currentCalls[options.callIndex];
-    if (!call) {
-      options.success = false;
-      options.errorMsg = GECKO_ERROR_GENERIC_FAILURE;
-      this.sendChromeMessage(options);
-      return;
-    }
-
-    // Check for races. Since we dispatched the incoming/waiting call
-    // notification the incoming/waiting call may have changed. The main
-    // thread thinks that it is answering the call with the given index,
-    // so only answer if that is still incoming/waiting.
-    switch (call.state) {
-      case CALL_STATE_INCOMING:
-        this.telephonyRequestQueue.push(REQUEST_ANSWER, () => {
-          this.context.Buf.simpleRequest(REQUEST_ANSWER, options);
-        });
-        break;
-      case CALL_STATE_WAITING:
-        // Answer the waiting (second) call, and hold the first call.
-        this.switchActiveCall(options);
-        break;
-      default:
-        if (DEBUG) this.context.debug("AnswerCall: Invalid call state");
-
-        options.success = false;
-        options.errorMsg = GECKO_ERROR_GENERIC_FAILURE;
-        this.sendChromeMessage(options);
-    }
-  },
-
-  /**
-   * Reject an incoming/waiting call.
-   *
-   * @param callIndex
-   *        Call index of the call to reject.
-   */
-  rejectCall: function(options) {
-    let call = this.currentCalls[options.callIndex];
-    if (!call) {
-      // |hangUpCall()| is used to remove an imcoming call from the current
-      // call list, so we consider it as an successful case when rejecting
-      // a call that doesn't exist in the current call list.
-      options.success = true;
-      this.sendChromeMessage(options);
-      return;
-    }
-
-    call.hangUpLocal = true;
-
-    if (this._isCdma) {
-      // AT+CHLD=0 means "release held or UDUB."
-      this.hangUpBackground(options);
-      return;
-    }
-
-    // Check for races. Since we dispatched the incoming/waiting call
-    // notification the incoming/waiting call may have changed. The main
-    // thread thinks that it is rejecting the call with the given index,
-    // so only reject if that is still incoming/waiting.
-    switch (call.state) {
-      case CALL_STATE_INCOMING:
-        this.udub(options);
-        break;
-      case CALL_STATE_WAITING:
-        // Reject the waiting (second) call, and remain the first call.
-        this.hangUpBackground(options);
-        break;
-      default:
-        if (DEBUG) this.context.debug("RejectCall: Invalid call state");
-
-        options.success = false;
-        options.errorMsg = GECKO_ERROR_GENERIC_FAILURE;
-        this.sendChromeMessage(options);
-    }
+    this.telephonyRequestQueue.push(REQUEST_ANSWER, () => {
+      this.context.Buf.simpleRequest(REQUEST_ANSWER, options);
+    });
   },
 
   conferenceCall: function(options) {
     this.telephonyRequestQueue.push(REQUEST_CONFERENCE, () => {
       this.context.Buf.simpleRequest(REQUEST_CONFERENCE, options);
     });
   },
 
@@ -1709,19 +1539,19 @@ RilObject.prototype = {
       Buf.writeInt32(options.callIndex);
       Buf.sendParcel();
     });
   },
 
   /**
    * Get current calls.
    */
-  getCurrentCalls: function() {
+  getCurrentCalls: function(options) {
     this.telephonyRequestQueue.push(REQUEST_GET_CURRENT_CALLS, () => {
-      this.context.Buf.simpleRequest(REQUEST_GET_CURRENT_CALLS);
+      this.context.Buf.simpleRequest(REQUEST_GET_CURRENT_CALLS, options);
     });
   },
 
   /**
    * Mute or unmute the radio.
    *
    * @param mute
    *        Boolean to indicate whether to mute or unmute the radio.
@@ -2134,19 +1964,18 @@ RilObject.prototype = {
     // We don't really send a request to rild, so instantly reply success to
     // RadioInterfaceLayer.
     this.sendChromeMessage(options);
   },
 
   /**
    * Get failure casue code for the most recently failed PDP context.
    */
-  getFailCauseCode: function(callback) {
-    this.context.Buf.simpleRequest(REQUEST_LAST_CALL_FAIL_CAUSE,
-                                   {callback: callback});
+  getFailCause: function(options) {
+    this.context.Buf.simpleRequest(REQUEST_LAST_CALL_FAIL_CAUSE, options);
   },
 
   sendMMI: function(options) {
     if (DEBUG) {
       this.context.debug("SendMMI " + JSON.stringify(options));
     }
 
     let _sendMMIError = (function(errorMsg) {
@@ -3489,22 +3318,16 @@ RilObject.prototype = {
           let signalLevel = this._processSignalLevel(signalStrength, -110, -85);
           info.voice.relSignalStrength = info.data.relSignalStrength = signalLevel;
         }
       }
     }
 
     info.rilMessageType = "signalstrengthchange";
     this._sendNetworkInfoMessage(NETWORK_INFO_SIGNAL, info);
-
-    if (this.cachedDialRequest && info.voice.signalStrength) {
-      // Radio is ready for making the cached emergency call.
-      this.cachedDialRequest.callback();
-      this.cachedDialRequest = null;
-    }
   },
 
   /**
    * Process the network registration flags.
    *
    * @return true if the state changed, false otherwise.
    */
   _processCREG: function(curState, newState) {
@@ -3656,254 +3479,16 @@ RilObject.prototype = {
       // itself if operator name is overridden after checking, or we have to
       // do it by ourself.
       if (!this.overrideICCNetworkName()) {
         this._sendNetworkInfoMessage(NETWORK_INFO_OPERATOR, this.operator);
       }
     }
   },
 
-  /**
-   * Classify new calls into three groups: (removed, remained, added).
-   */
-  _classifyCalls: function(newCalls) {
-    newCalls = newCalls || {};
-
-    let removedCalls = [];
-    let remainedCalls = [];
-    let addedCalls = [];
-
-    for each (let currentCall in this.currentCalls) {
-      let newCall = newCalls[currentCall.callIndex];
-      if (!newCall) {
-        removedCalls.push(currentCall);
-      } else {
-        remainedCalls.push(newCall);
-        delete newCalls[currentCall.callIndex];
-      }
-    }
-
-    // Go through any remaining calls that are new to us.
-    for each (let newCall in newCalls) {
-      if (newCall.isVoice) {
-        addedCalls.push(newCall);
-      }
-    }
-
-    return [removedCalls, remainedCalls, addedCalls];
-  },
-
-  /**
-   * Check the calls in addedCalls and assign an appropriate one to pendingMO.
-   * Also update the |isEmergency| on that call.
-   */
-  _assignPendingMO: function(addedCalls) {
-    let options = this.pendingMO.options;
-    this.pendingMO = null;
-
-    for (let call of addedCalls) {
-      if (call.state !== CALL_STATE_INCOMING) {
-        call.isEmergency = options.isEmergency;
-        options.success = true;
-        options.callIndex = call.callIndex;
-        this.sendChromeMessage(options);
-        return;
-      }
-    }
-
-    // The call doesn't exist.
-    options.success = false;
-    options.errorMsg = GECKO_CALL_ERROR_UNSPECIFIED;
-    this.sendChromeMessage(options);
-  },
-
-  /**
-   * Check the currentCalls and identify the conference group.
-   * Return the conference state and the group as a set.
-   */
-  _detectConference: function() {
-    // There are some difficuties to identify the conference by |.isMpty| so we
-    // don't rely on this flag.
-    //  - |.isMpty| becomes false when the conference call is put on hold.
-    //  - |.isMpty| may remain true when other participants left the conference.
-
-    // All the calls in the conference should have the same state and it is
-    // either ACTIVE or HOLDING. That means, if we find a group of call with
-    // the same state and its size is larger than 2, it must be a conference.
-    let activeCalls = new Set();
-    let holdingCalls = new Set();
-
-    for each (let call in this.currentCalls) {
-      if (call.state === CALL_STATE_ACTIVE) {
-        activeCalls.add(call);
-      } else if (call.state === CALL_STATE_HOLDING) {
-        holdingCalls.add(call);
-      }
-    }
-
-    if (activeCalls.size >= 2) {
-      return [CALL_STATE_ACTIVE, activeCalls];
-    } else if (holdingCalls.size >= 2) {
-      return [CALL_STATE_HOLDING, holdingCalls];
-    }
-
-    return [CALL_STATE_UNKNOWN, new Set()];
-  },
-
-  /**
-   * Helpers for processing call state changes.
-   */
-  _processCalls: function(newCalls, failCause) {
-    if (DEBUG) this.context.debug("_processCalls: " + JSON.stringify(newCalls) +
-                                  " failCause: " + failCause);
-
-    // Let's get the failCause first if there are removed calls. Otherwise, we
-    // need to trigger another async request when removing call and it cause
-    // the order of callDisconnected and conferenceCallStateChanged
-    // unpredictable.
-    if (failCause === undefined) {
-      for each (let currentCall in this.currentCalls) {
-        if (!newCalls[currentCall.callIndex] && !currentCall.hangUpLocal) {
-          this.getFailCauseCode((function(newCalls, failCause) {
-            this._processCalls(newCalls, failCause);
-          }).bind(this, newCalls));
-          return;
-        }
-      }
-    }
-
-    let [removedCalls, remainedCalls, addedCalls] =
-      this._classifyCalls(newCalls);
-
-    // Handle removed calls.
-    // Only remove it from the map here. Notify callDisconnected later.
-    for (let call of removedCalls) {
-      delete this.currentCalls[call.callIndex];
-      call.failCause = call.hangUpLocal ? GECKO_CALL_ERROR_NORMAL_CALL_CLEARING
-                                        : failCause;
-    }
-
-    let changedCalls = new Set();
-
-    // Handle remained calls.
-    for (let newCall of remainedCalls) {
-      let oldCall = this.currentCalls[newCall.callIndex];
-      if (oldCall.state == newCall.state) {
-        continue;
-      }
-
-      if (oldCall.state == CALL_STATE_WAITING &&
-          newCall.state == CALL_STATE_INCOMING) {
-        // Update the call internally but we don't notify chrome since these two
-        // states are viewed as the same one there.
-        oldCall.state = newCall.state;
-        continue;
-      }
-
-      if (!oldCall.started && newCall.state == CALL_STATE_ACTIVE) {
-        oldCall.started = new Date().getTime();
-      }
-
-      oldCall.state = newCall.state;
-      oldCall.number =
-        this._formatInternationalNumber(newCall.number, newCall.toa);
-      changedCalls.add(oldCall);
-    }
-
-    // Handle pendingMO.
-    if (this.pendingMO) {
-      this._assignPendingMO(addedCalls);
-    }
-
-    // Handle added calls.
-    for (let call of addedCalls) {
-      this._addVoiceCall(call);
-      changedCalls.add(call);
-    }
-
-    // Detect conference and update isConference flag.
-    let [newConferenceState, conference] = this._detectConference();
-    for each (let call in this.currentCalls) {
-      let isConference = conference.has(call);
-      if (call.isConference != isConference) {
-        call.isConference = isConference;
-        changedCalls.add(call);
-      }
-    }
-
-    // Update audio state. We have to send this message before callStateChange
-    // and callDisconnected to make sure that the audio state is ready first.
-    this.sendChromeMessage({
-      rilMessageType: "audioStateChanged",
-      state: this._detectAudioState()
-    });
-
-    // Notify call disconnected.
-    for (let call of removedCalls) {
-      this._handleDisconnectedCall(call);
-    }
-
-    // Notify call state change.
-    for (let call of changedCalls) {
-      this._handleChangedCallState(call);
-    }
-
-    // Notify conference state change.
-    if (this.currentConferenceState != newConferenceState) {
-      this.currentConferenceState = newConferenceState;
-      let message = {rilMessageType: "conferenceCallStateChanged",
-                     state: newConferenceState};
-      this.sendChromeMessage(message);
-    }
-  },
-
-  _detectAudioState: function() {
-    let callNum = Object.keys(this.currentCalls).length;
-    if (!callNum) {
-      return AUDIO_STATE_NO_CALL;
-    }
-
-    let firstIndex = Object.keys(this.currentCalls)[0];
-    if (callNum == 1 &&
-        this.currentCalls[firstIndex].state == CALL_STATE_INCOMING) {
-      return AUDIO_STATE_INCOMING;
-    }
-
-    return AUDIO_STATE_IN_CALL;
-  },
-
-  // Format international numbers appropriately.
-  _formatInternationalNumber: function(number, toa) {
-    if (number && toa == TOA_INTERNATIONAL && number[0] != "+") {
-      number = "+" + number;
-    }
-
-    return number;
-  },
-
-  _addVoiceCall: function(newCall) {
-    newCall.number = this._formatInternationalNumber(newCall.number, newCall.toa);
-    newCall.isOutgoing = !newCall.isMT;
-    newCall.isConference = false;
-
-    this.currentCalls[newCall.callIndex] = newCall;
-  },
-
-  _handleChangedCallState: function(changedCall) {
-    let message = {rilMessageType: "callStateChange",
-                   call: changedCall};
-    this.sendChromeMessage(message);
-  },
-
-  _handleDisconnectedCall: function(disconnectedCall) {
-    let message = {rilMessageType: "callDisconnected",
-                   call: disconnectedCall};
-    this.sendChromeMessage(message);
-  },
-
   _setDataCallGeckoState: function(datacall) {
     switch (datacall.active) {
       case DATACALL_INACTIVE:
         datacall.state = GECKO_NETWORK_STATE_DISCONNECTED;
         break;
       case DATACALL_ACTIVE_DOWN:
       case DATACALL_ACTIVE_UP:
         datacall.state = GECKO_NETWORK_STATE_CONNECTED;
@@ -3920,48 +3505,30 @@ RilObject.prototype = {
       // We haven't supported MO intermediate result code, i.e.
       // info.notificationType === 0, which refers to code1 defined in 3GPP
       // 27.007 7.17. We only support partial MT unsolicited result code,
       // referring to code2, for now.
       return;
     }
 
     let notification = null;
-    let callIndex = -1;
 
     switch (info.code) {
       case SUPP_SVC_NOTIFICATION_CODE2_PUT_ON_HOLD:
       case SUPP_SVC_NOTIFICATION_CODE2_RETRIEVED:
         notification = GECKO_SUPP_SVC_NOTIFICATION_FROM_CODE2[info.code];
         break;
       default:
         // Notification type not supported.
         return;
     }
 
-    // Get the target call object for this notification.
-    let currentCallIndexes = Object.keys(this.currentCalls);
-    if (currentCallIndexes.length === 1) {
-      // Only one call exists. This should be the target.
-      callIndex = currentCallIndexes[0];
-    } else {
-      // Find the call in |currentCalls| by the given number.
-      if (info.number) {
-        for each (let currentCall in this.currentCalls) {
-          if (currentCall.number == info.number) {
-            callIndex = currentCall.callIndex;
-            break;
-          }
-        }
-      }
-    }
-
     let message = {rilMessageType: "suppSvcNotification",
-                   notification: notification,
-                   callIndex: callIndex};
+                   number: info.number,  // could be empty.
+                   notification: notification};
     this.sendChromeMessage(message);
   },
 
   _cancelEmergencyCbModeTimeout: function() {
     if (this._exitEmergencyCbModeTimeoutID) {
       clearTimeout(this._exitEmergencyCbModeTimeoutID);
       this._exitEmergencyCbModeTimeoutID = null;
     }
@@ -4883,29 +4450,16 @@ RilObject.prototype = {
                            JSON.stringify(message));
       }
       return;
     }
     method.call(this, message);
   },
 
   /**
-   * Get a list of current voice calls.
-   */
-  enumerateCalls: function(options) {
-    if (DEBUG) this.context.debug("Sending all current calls");
-    let calls = [];
-    for each (let call in this.currentCalls) {
-      calls.push(call);
-    }
-    options.calls = calls;
-    this.sendChromeMessage(options);
-  },
-
-  /**
    * Process STK Proactive Command.
    */
   processStkProactiveCommand: function() {
     let Buf = this.context.Buf;
     let length = Buf.readInt32();
     let berTlv;
     try {
       berTlv = this.context.BerTlvHelper.decode(length / 2);
@@ -5049,20 +4603,23 @@ RilObject.prototype[REQUEST_CHANGE_SIM_P
   this._processEnterAndChangeICCResponses(length, options);
 };
 RilObject.prototype[REQUEST_ENTER_NETWORK_DEPERSONALIZATION_CODE] =
   function REQUEST_ENTER_NETWORK_DEPERSONALIZATION_CODE(length, options) {
   this._processEnterAndChangeICCResponses(length, options);
 };
 RilObject.prototype[REQUEST_GET_CURRENT_CALLS] = function REQUEST_GET_CURRENT_CALLS(length, options) {
   // Retry getCurrentCalls several times when error occurs.
-  if (options.rilRequestError &&
-      this._getCurrentCallsRetryCount < GET_CURRENT_CALLS_RETRY_MAX) {
-    this._getCurrentCallsRetryCount++;
-    this.getCurrentCalls();
+  if (options.rilRequestError) {
+    if (this._getCurrentCallsRetryCount < GET_CURRENT_CALLS_RETRY_MAX) {
+      this._getCurrentCallsRetryCount++;
+      this.getCurrentCalls(options);
+    } else {
+      this.sendDefaultResponse(options);
+    }
     return;
   }
 
   this._getCurrentCallsRetryCount = 0;
 
   let Buf = this.context.Buf;
   let calls_length = 0;
   // The RIL won't even send us the length integer if there are no active calls.
@@ -5102,30 +4659,27 @@ RilObject.prototype[REQUEST_GET_CURRENT_
     if (uusInfoPresent == 1) {
       call.uusInfo = {
         type:     Buf.readInt32(),
         dcs:      Buf.readInt32(),
         userData: null //XXX TODO byte array?!?
       };
     }
 
-    calls[call.callIndex] = call;
-  }
-  this._processCalls(calls);
+    if (call.isVoice) {
+      calls[call.callIndex] = call;
+    }
+  }
+
+  options.calls = calls;
+  options.rilMessageType = options.rilMessageType || "currentCalls";
+  this.sendChromeMessage(options);
 };
 RilObject.prototype[REQUEST_DIAL] = function REQUEST_DIAL(length, options) {
-  if (options.rilRequestError === 0) {
-    this.pendingMO = {options: options};
-  } else {
-    this.getFailCauseCode((function(options, failCause) {
-      options.success = false;
-      options.errorMsg = failCause;
-      this.sendChromeMessage(options);
-    }).bind(this, options));
-  }
+  this.sendDefaultResponse(options);
 };
 RilObject.prototype[REQUEST_DIAL_EMERGENCY_CALL] = function REQUEST_DIAL_EMERGENCY_CALL(length, options) {
   RilObject.prototype[REQUEST_DIAL].call(this, length, options);
 };
 RilObject.prototype[REQUEST_GET_IMSI] = function REQUEST_GET_IMSI(length, options) {
   if (options.rilRequestError) {
     return;
   }
@@ -5153,31 +4707,32 @@ RilObject.prototype[REQUEST_SWITCH_WAITI
 };
 RilObject.prototype[REQUEST_CONFERENCE] = function REQUEST_CONFERENCE(length, options) {
   this.sendDefaultResponse(options);
 };
 RilObject.prototype[REQUEST_UDUB] = function REQUEST_UDUB(length, options) {
   this.sendDefaultResponse(options);
 };
 RilObject.prototype[REQUEST_LAST_CALL_FAIL_CAUSE] = function REQUEST_LAST_CALL_FAIL_CAUSE(length, options) {
-  let Buf = this.context.Buf;
-  let num = length ? Buf.readInt32() : 0;
-  let failCause = null;
-
-  if (num) {
-    let causeNum = Buf.readInt32();
-    // To make _processCalls work as design, failCause couldn't be "undefined."
-    // See Bug 1112550 for details.
-    failCause = RIL_CALL_FAILCAUSE_TO_GECKO_CALL_ERROR[causeNum] || null;
-  }
-  if (DEBUG) this.context.debug("Last call fail cause: " + failCause);
-
-  if (options.callback) {
-    options.callback(failCause);
-  }
+  // Treat it as CALL_FAIL_ERROR_UNSPECIFIED if the request failed.
+  let failCause = CALL_FAIL_ERROR_UNSPECIFIED;
+
+  if (options.rilRequestError === 0) {
+    let Buf = this.context.Buf;
+    let num = length ? Buf.readInt32() : 0;
+
+    if (num) {
+      let causeNum = Buf.readInt32();
+      failCause = RIL_CALL_FAILCAUSE_TO_GECKO_CALL_ERROR[causeNum] || failCause;
+    }
+    if (DEBUG) this.context.debug("Last call fail cause: " + failCause);
+  }
+
+  options.failCause = failCause;
+  this.sendChromeMessage(options);
 };
 RilObject.prototype[REQUEST_SIGNAL_STRENGTH] = function REQUEST_SIGNAL_STRENGTH(length, options) {
   this._receivedNetworkInfo(NETWORK_INFO_SIGNAL);
 
   if (options.rilRequestError) {
     return;
   }
 
@@ -5213,25 +4768,16 @@ RilObject.prototype[REQUEST_VOICE_REGIST
   if (options.rilRequestError) {
     return;
   }
 
   let state = this.context.Buf.readStringList();
   if (DEBUG) this.context.debug("voice registration state: " + state);
 
   this._processVoiceRegistrationState(state);
-
-  if (this.cachedDialRequest &&
-       (this.voiceRegistrationState.emergencyCallsOnly ||
-        this.voiceRegistrationState.connected) &&
-      this.voiceRegistrationState.radioTech != NETWORK_CREG_TECH_UNKNOWN) {
-    // Radio is ready for making the cached emergency call.
-    this.cachedDialRequest.callback();
-    this.cachedDialRequest = null;
-  }
 };
 RilObject.prototype[REQUEST_DATA_REGISTRATION_STATE] = function REQUEST_DATA_REGISTRATION_STATE(length, options) {
   this._receivedNetworkInfo(NETWORK_INFO_DATA_REGISTRATION_STATE);
 
   if (options.rilRequestError) {
     return;
   }
 
@@ -5245,29 +4791,17 @@ RilObject.prototype[REQUEST_OPERATOR] = 
     return;
   }
 
   let operatorData = this.context.Buf.readStringList();
   if (DEBUG) this.context.debug("Operator: " + operatorData);
   this._processOperator(operatorData);
 };
 RilObject.prototype[REQUEST_RADIO_POWER] = function REQUEST_RADIO_POWER(length, options) {
-  if (options.rilMessageType == null) {
-    // The request was made by ril_worker itself.
-    if (options.rilRequestError) {
-      if (this.cachedDialRequest && options.enabled) {
-        // Turning on radio fails. Notify the error of making an emergency call.
-        this.cachedDialRequest.onerror(GECKO_ERROR_RADIO_NOT_AVAILABLE);
-        this.cachedDialRequest = null;
-      }
-    }
-    return;
-  }
-
-  this.sendChromeMessage(options);
+  this.sendDefaultResponse(options);
 };
 RilObject.prototype[REQUEST_DTMF] = null;
 RilObject.prototype[REQUEST_SEND_SMS] = function REQUEST_SEND_SMS(length, options) {
   this._processSmsSendResult(length, options);
 };
 RilObject.prototype[REQUEST_SEND_SMS_EXPECT_MORE] = null;
 
 RilObject.prototype.readSetupDataCall_v5 = function readSetupDataCall_v5(options) {
--- a/dom/system/gonk/tests/test_ril_worker_barring_password.js
+++ b/dom/system/gonk/tests/test_ril_worker_barring_password.js
@@ -36,28 +36,28 @@ add_test(function test_change_call_barri
 
   do_test(ICC_CB_FACILITY_BA_ALL, PIN, NEW_PIN);
 
   run_next_test();
 });
 
 add_test(function test_check_change_call_barring_password_result() {
   let barringPasswordOptions;
-  let worker = newWorker({
-    postMessage: function(message) {
-      equal(barringPasswordOptions.pin, PIN);
-      equal(barringPasswordOptions.newPin, NEW_PIN);
-    }
-  });
+  let workerHelper = newInterceptWorker();
+  let worker = workerHelper.worker;
 
   let context = worker.ContextPool._contexts[0];
   context.RIL.changeCallBarringPassword =
     function fakeChangeCallBarringPassword(options) {
       barringPasswordOptions = options;
       context.RIL[REQUEST_CHANGE_BARRING_PASSWORD](0, {
         rilRequestError: ERROR_SUCCESS
       });
-    }
+    };
 
   context.RIL.changeCallBarringPassword({pin: PIN, newPin: NEW_PIN});
 
+  let postedMessage = workerHelper.postedMessage;
+  equal(barringPasswordOptions.pin, PIN);
+  equal(barringPasswordOptions.newPin, NEW_PIN);
+
   run_next_test();
 });
--- a/dom/system/gonk/tests/test_ril_worker_ssn.js
+++ b/dom/system/gonk/tests/test_ril_worker_ssn.js
@@ -40,61 +40,65 @@ add_test(function test_notification() {
     0: new Call(0, '00000')
   };
 
   let twoCalls = {
     0: new Call(0, '00000'),
     1: new Call(1, '11111')
   };
 
-  function testNotification(calls, code, number, resultNotification,
-                            resultCallIndex) {
+  function testNotification(calls, code, number, resultNotification) {
 
     let testInfo = {calls: calls, code: code, number: number,
-                    resultNotification: resultNotification,
-                    resultCallIndex: resultCallIndex};
+                    resultNotification: resultNotification};
     do_print('Test case info: ' + JSON.stringify(testInfo));
 
     // Set current calls.
-    context.RIL._processCalls(calls);
+    context.RIL.sendChromeMessage({
+      rilMessageType: "currentCalls",
+      calls: calls
+    });
 
     let notificationInfo = {
       notificationType: 1,  // MT
       code: code,
       index: 0,
       type: 0,
       number: number
     };
 
     context.RIL._processSuppSvcNotification(notificationInfo);
 
     let postedMessage = workerHelper.postedMessage;
     equal(postedMessage.rilMessageType, 'suppSvcNotification');
+    equal(postedMessage.number, number);
     equal(postedMessage.notification, resultNotification);
-    equal(postedMessage.callIndex, resultCallIndex);
 
     // Clear all existed calls.
-    context.RIL._processCalls({});
+    context.RIL.sendChromeMessage({
+      rilMessageType: "currentCalls",
+      calls: {}
+    });
   }
 
   testNotification(oneCall, SUPP_SVC_NOTIFICATION_CODE2_PUT_ON_HOLD, null,
-                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_HELD, 0);
+                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_HELD);
 
   testNotification(oneCall, SUPP_SVC_NOTIFICATION_CODE2_RETRIEVED, null,
-                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_RESUMED, 0);
+                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_RESUMED);
 
   testNotification(twoCalls, SUPP_SVC_NOTIFICATION_CODE2_PUT_ON_HOLD, null,
-                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_HELD, -1);
+                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_HELD);
 
   testNotification(twoCalls, SUPP_SVC_NOTIFICATION_CODE2_RETRIEVED, null,
-                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_RESUMED, -1);
+                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_RESUMED);
 
   testNotification(twoCalls, SUPP_SVC_NOTIFICATION_CODE2_PUT_ON_HOLD, '00000',
-                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_HELD, 0);
+                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_HELD);
 
   testNotification(twoCalls, SUPP_SVC_NOTIFICATION_CODE2_PUT_ON_HOLD, '11111',
-                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_HELD, 1);
+                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_HELD);
 
   testNotification(twoCalls, SUPP_SVC_NOTIFICATION_CODE2_PUT_ON_HOLD, '22222',
-                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_HELD, -1);
+                   GECKO_SUPP_SVC_NOTIFICATION_REMOTE_HELD);
 
   run_next_test();
 });
--- a/dom/telephony/gonk/TelephonyService.js
+++ b/dom/telephony/gonk/TelephonyService.js
@@ -160,39 +160,79 @@ TelephonyCallInfo.prototype = {
   namePresentation: nsITelephonyService.CALL_PRESENTATION_ALLOWED,
   isOutgoing: true,
   isEmergency: false,
   isConference: false,
   isSwitchable: true,
   isMergeable: true
 };
 
+function Call(aClientId, aCallIndex) {
+  this.clientId = aClientId;
+  this.callIndex = aCallIndex;
+}
+Call.prototype = {
+  clientId: 0,
+  callIndex: 0,
+  state: nsITelephonyService.CALL_STATE_UNKNOWN,
+  number: "",
+  numberPresentation: nsITelephonyService.CALL_PRESENTATION_ALLOWED,
+  name: "",
+  namePresentation: nsITelephonyService.CALL_PRESENTATION_ALLOWED,
+  isOutgoing: true,
+  isEmergency: false,
+  isConference: false,
+  isSwitchable: true,
+  isMergeable: true,
+  started: null
+};
+
+function MobileConnectionListener() {}
+MobileConnectionListener.prototype = {
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIMobileConnectionListener]),
+
+  // nsIMobileConnectionListener
+
+  notifyVoiceChanged: function() {},
+  notifyDataChanged: function() {},
+  notifyDataError: function(message) {},
+  notifyCFStateChanged: function(action, reason, number, timeSeconds, serviceClass) {},
+  notifyEmergencyCbModeChanged: function(active, timeoutMs) {},
+  notifyOtaStatusChanged: function(status) {},
+  notifyRadioStateChanged: function() {},
+  notifyClirModeChanged: function(mode) {},
+  notifyLastKnownNetworkChanged: function() {},
+  notifyLastKnownHomeNetworkChanged: function() {},
+  notifyNetworkSelectionModeChanged: function() {}
+};
+
 function TelephonyService() {
   this._numClients = gRadioInterfaceLayer.numRadioInterfaces;
   this._listeners = [];
 
   this._isDialing = false;
   this._cachedDialRequest = null;
   this._currentCalls = {};
   this._currentConferenceState = nsITelephonyService.CALL_STATE_UNKNOWN;
-  this._audioStates = {};
+  this._audioStates = [];
 
   this._cdmaCallWaitingNumber = null;
 
   this._updateDebugFlag();
   this.defaultServiceId = this._getDefaultServiceId();
 
   Services.prefs.addObserver(kPrefRilDebuggingEnabled, this, false);
   Services.prefs.addObserver(kPrefDefaultServiceId, this, false);
 
   Services.obs.addObserver(this, NS_XPCOM_SHUTDOWN_OBSERVER_ID, false);
 
   for (let i = 0; i < this._numClients; ++i) {
+    this._audioStates[i] = nsITelephonyAudioService.PHONE_STATE_NORMAL;
+    this._currentCalls[i] = {};
     this._enumerateCallsForClient(i);
-    this._audioStates[i] = RIL.AUDIO_STATE_NO_CALL;
   }
 }
 TelephonyService.prototype = {
   classID: GONK_TELEPHONYSERVICE_CID,
   classInfo: XPCOMUtils.generateCI({classID: GONK_TELEPHONYSERVICE_CID,
                                     contractID: GONK_TELEPHONYSERVICE_CONTRACTID,
                                     classDescription: "TelephonyService",
                                     interfaces: [Ci.nsITelephonyService,
@@ -266,17 +306,18 @@ TelephonyService.prototype = {
     return !this._isGsmTechGroup(type);
   },
 
   _isEmergencyOnly: function(aClientId) {
     return gGonkMobileConnectionService.getItemByServiceId(aClientId).voice.emergencyCallsOnly;
   },
 
   _isRadioOn: function(aClientId) {
-    return gGonkMobileConnectionService.getItemByServiceId(aClientId).radioState === nsIMobileConnection.MOBILE_RADIO_STATE_ENABLED;
+    let connection = gGonkMobileConnectionService.getItemByServiceId(aClientId);
+    return connection.radioState === nsIMobileConnection.MOBILE_RADIO_STATE_ENABLED;
   },
 
   // An array of nsITelephonyListener instances.
   _listeners: null,
   _notifyAllListeners: function(aMethodName, aArgs) {
     let listeners = this._listeners.slice();
     for (let listener of listeners) {
       if (this._listeners.indexOf(listener) == -1) {
@@ -288,28 +329,49 @@ TelephonyService.prototype = {
       try {
         handler.apply(listener, aArgs);
       } catch (e) {
         debug("listener for " + aMethodName + " threw an exception: " + e);
       }
     }
   },
 
-  _updateAudioState: function(aAudioState) {
-    switch (aAudioState) {
-      case RIL.AUDIO_STATE_NO_CALL:
-        gAudioService.setPhoneState(nsITelephonyAudioService.PHONE_STATE_NORMAL);
-        break;
-      case RIL.AUDIO_STATE_INCOMING:
-        gAudioService.setPhoneState(nsITelephonyAudioService.PHONE_STATE_RINGTONE);
-        break;
-      case RIL.AUDIO_STATE_IN_CALL:
-        gAudioService.setPhoneState(nsITelephonyAudioService.PHONE_STATE_IN_CALL);
-        break;
+  _computeAudioStateForClient: function(aClientId) {
+    let indexes = Object.keys(this._currentCalls[aClientId]);
+    if (!indexes.length) {
+      return nsITelephonyAudioService.PHONE_STATE_NORMAL;
+    }
+
+    let firstCall = this._currentCalls[aClientId][indexes[0]];
+    if (indexes.length === 1 &&
+        firstCall.state === nsITelephonyService.CALL_STATE_INCOMING) {
+      return nsITelephonyAudioService.PHONE_STATE_RINGTONE;
     }
+
+    return nsITelephonyAudioService.PHONE_STATE_IN_CALL;
+  },
+
+  _updateAudioState: function(aClientId) {
+    this._audioStates[aClientId] = this._computeAudioStateForClient(aClientId);
+
+    if (this._audioStates.some(state => state === nsITelephonyAudioService.PHONE_STATE_IN_CALL)) {
+      gAudioService.setPhoneState(nsITelephonyAudioService.PHONE_STATE_IN_CALL);
+    } else if (this._audioStates.some(state => state === nsITelephonyAudioService.PHONE_STATE_RINGTONE)) {
+      gAudioService.setPhoneState(nsITelephonyAudioService.PHONE_STATE_RINGTONE);
+    } else {
+      gAudioService.setPhoneState(nsITelephonyAudioService.PHONE_STATE_NORMAL);
+    }
+  },
+
+  _formatInternationalNumber: function(aNumber, aToa) {
+    if (aNumber && aToa == RIL.TOA_INTERNATIONAL && aNumber[0] != "+") {
+      return "+" + aNumber;
+    }
+
+    return aNumber;
   },
 
   _convertRILCallState: function(aState) {
     switch (aState) {
       case RIL.CALL_STATE_UNKNOWN:
         return nsITelephonyService.CALL_STATE_UNKNOWN;
       case RIL.CALL_STATE_ACTIVE:
         return nsITelephonyService.CALL_STATE_CONNECTED;
@@ -362,27 +424,27 @@ TelephonyService.prototype = {
 
     return id;
   },
 
   _currentCalls: null,
   _enumerateCallsForClient: function(aClientId) {
     if (DEBUG) debug("Enumeration of calls for client " + aClientId);
 
-    this._sendToRilWorker(aClientId, "enumerateCalls", null, response => {
-      if (!this._currentCalls[aClientId]) {
-        this._currentCalls[aClientId] = {};
+    this._sendToRilWorker(aClientId, "getCurrentCalls", null, response => {
+      if (response.errorMsg) {
+        return;
       }
-      for (let call of response.calls) {
-        call.clientId = aClientId;
-        call.state = this._convertRILCallState(call.state);
-        call.isSwitchable = true;
-        call.isMergeable = true;
+
+      // Clear all.
+      this._currentCalls[aClientId] = {};
 
-        this._currentCalls[aClientId][call.callIndex] = call;
+      for (let i in response.calls) {
+        let call = this._currentCalls[aClientId][i] = new Call(aClientId, i);
+        this._updateCallFromRil(call, response.calls[i]);
       }
     });
   },
 
   /**
    * Checks whether to temporarily suppress caller id for the call.
    *
    * @param aMmi
@@ -477,26 +539,26 @@ TelephonyService.prototype = {
         numCalls++;
       }
     }
 
     return hasConference ? numCalls + 1 : numCalls;
   },
 
   /**
-   * Get arbitrary one of active call.
+   * Is there an active call?
    */
-  _getOneActiveCall: function(aClientId) {
+  _isActive: function(aClientId) {
     for (let index in this._currentCalls[aClientId]) {
       let call = this._currentCalls[aClientId][index];
       if (call.state === nsITelephonyService.CALL_STATE_CONNECTED) {
-        return call;
+        return true;
       }
     }
-    return null;
+    return false;
   },
 
   /**
    * Dial number. Perform call setup or SS procedure accordingly.
    *
    * @see 3GPP TS 22.030 Figure 3.5.3.2
    */
   dial: function(aClientId, aNumber, aIsDialEmergency, aCallback) {
@@ -564,39 +626,39 @@ TelephonyService.prototype = {
       } else {
         this._dialCall(aClientId, aNumber, undefined, aCallback);
       }
     }
   },
 
   // Handling of supplementary services within a call as 3GPP TS 22.030 6.5.5
   _dialInCallMMI: function(aClientId, aNumber, aCallback) {
-    let mmiCallback = response => {
-      aCallback.notifyDialMMI(RIL.MMI_KS_SC_CALL);
-      if (!response.success) {
-        aCallback.notifyDialMMIError(RIL.MMI_ERROR_KS_ERROR);
-      } else {
-        aCallback.notifyDialMMISuccess(RIL.MMI_SM_KS_CALL_CONTROL);
-      }
+    let mmiCallback = {
+      notifyError: () => aCallback.notifyDialMMIError(RIL.MMI_ERROR_KS_ERROR),
+      notifySuccess: () => aCallback.notifyDialMMISuccess(RIL.MMI_SM_KS_CALL_CONTROL)
     };
 
     if (aNumber === "0") {
-      this._sendToRilWorker(aClientId, "hangUpBackground", null, mmiCallback);
+      aCallback.notifyDialMMI(RIL.MMI_KS_SC_CALL);
+      this._hangUpBackground(aClientId, mmiCallback);
     } else if (aNumber === "1") {
-      this._sendToRilWorker(aClientId, "hangUpForeground", null, mmiCallback);
+      aCallback.notifyDialMMI(RIL.MMI_KS_SC_CALL);
+      this._hangUpForeground(aClientId, mmiCallback);
     } else if (aNumber[0] === "1" && aNumber.length === 2) {
-      this._sendToRilWorker(aClientId, "hangUpCall",
-                            { callIndex: parseInt(aNumber[1]) }, mmiCallback);
+      aCallback.notifyDialMMI(RIL.MMI_KS_SC_CALL);
+      this.hangUpCall(aClientId, parseInt(aNumber[1]), mmiCallback);
     } else if (aNumber === "2") {
-      this._sendToRilWorker(aClientId, "switchActiveCall", null, mmiCallback);
+      aCallback.notifyDialMMI(RIL.MMI_KS_SC_CALL);
+      this._switchActiveCall(aClientId, mmiCallback);
     } else if (aNumber[0] === "2" && aNumber.length === 2) {
-      this._sendToRilWorker(aClientId, "separateCall",
-                            { callIndex: parseInt(aNumber[1]) }, mmiCallback);
+      aCallback.notifyDialMMI(RIL.MMI_KS_SC_CALL);
+      this._separateCallGsm(aClientId, parseInt(aNumber[1]), mmiCallback);
     } else if (aNumber === "3") {
-      this._sendToRilWorker(aClientId, "conferenceCall", null, mmiCallback);
+      aCallback.notifyDialMMI(RIL.MMI_KS_SC_CALL);
+      this._conferenceCallGsm(aClientId, mmiCallback);
     } else {
       this._dialCall(aClientId, aNumber, undefined, aCallback);
     }
   },
 
   _dialCall: function(aClientId, aNumber, aClirMode = RIL.CLIR_DEFAULT,
                       aCallback) {
     if (this._isDialing) {
@@ -639,27 +701,52 @@ TelephonyService.prototype = {
     if (isEmergency) {
       // Automatically select a proper clientId for emergency call.
       aClientId = gRadioInterfaceLayer.getClientIdForEmergencyCall() ;
       if (aClientId === -1) {
         if (DEBUG) debug("Error: No client is avaialble for emergency call.");
         aCallback.notifyError(DIAL_ERROR_INVALID_STATE_ERROR);
         return;
       }
+
+      // Radio is off. Turn it on first.
+      if (!this._isRadioOn(aClientId)) {
+        let connection = gGonkMobileConnectionService.getItemByServiceId(aClientId);
+        let listener = new MobileConnectionListener();
+
+        listener.notifyRadioStateChanged = () => {
+          if (this._isRadioOn(aClientId)) {
+            this._dialCall(aClientId, aNumber, undefined, aCallback);
+            connection.unregisterListener(listener);
+          }
+        };
+        connection.registerListener(listener);
+
+        // Turn on radio.
+        connection.setRadioEnabled(true, {
+          QueryInterface: XPCOMUtils.generateQI([Ci.nsIMobileConnectionCallback]),
+          notifySuccess: () => {},
+          notifyError: aErrorMsg => {
+            connection.unregisterListener(listener);
+            aCallback.notifyError(DIAL_ERROR_RADIO_NOT_AVAILABLE);
+          }
+        });
+
+        return;
+      }
     }
 
     let options = {
       isEmergency: isEmergency,
       number: aNumber,
       clirMode: aClirMode
     };
 
     // No active call. Dial it out directly.
-    let activeCall = this._getOneActiveCall(aClientId);
-    if (!activeCall) {
+    if (!this._isActive(aClientId)) {
       this._sendDialCallRequest(aClientId, options, aCallback);
       return;
     }
 
     // CDMA 3-way calling.
     if (this._isCdmaClient(aClientId)) {
       this._dialCdmaThreeWayCall(aClientId, aNumber, aCallback);
       return;
@@ -669,92 +756,89 @@ TelephonyService.prototype = {
     if (DEBUG) debug("There is an active call. Hold it first before dial.");
 
     if (this._cachedDialRequest) {
       if (DEBUG) debug("Error: There already is a pending dial request.");
       aCallback.notifyError(DIAL_ERROR_INVALID_STATE_ERROR);
       return;
     }
 
-    let autoHoldCallback = {
+    this._switchActiveCall(aClientId, {
       QueryInterface: XPCOMUtils.generateQI([Ci.nsITelephonyCallback]),
 
       notifySuccess: () => {
         this._cachedDialRequest = {
           clientId: aClientId,
           options: options,
           callback: aCallback
         };
       },
 
       notifyError: (aErrorMsg) => {
         if (DEBUG) debug("Error: Fail to automatically hold the active call.");
         aCallback.notifyError(aErrorMsg);
       }
-    };
-
-    if (activeCall.isConference) {
-      this.holdConference(aClientId, autoHoldCallback);
-    } else {
-      this.holdCall(aClientId, activeCall.callIndex, autoHoldCallback);
-    }
+    });
   },
 
   _dialCdmaThreeWayCall: function(aClientId, aNumber, aCallback) {
     this._sendToRilWorker(aClientId, "cdmaFlash", { featureStr: aNumber },
                           response => {
       if (!response.success) {
         aCallback.notifyError(response.errorMsg);
         return;
       }
 
       // RIL doesn't hold the 2nd call. We create one by ourselves.
       aCallback.notifyDialCallSuccess(aClientId, CDMA_SECOND_CALL_INDEX,
                                       aNumber);
 
-      let childCall = {
-        callIndex: CDMA_SECOND_CALL_INDEX,
-        state: RIL.CALL_STATE_DIALING,
-        number: aNumber,
-        isOutgoing: true,
-        isEmergency: false,
-        isConference: false,
-        isSwitchable: false,
-        isMergeable: true,
-        parentId: CDMA_FIRST_CALL_INDEX
-      };
+      let childCall = this._currentCalls[aClientId][CDMA_SECOND_CALL_INDEX] =
+        new Call(aClientId, CDMA_SECOND_CALL_INDEX);
+
+      childCall.parentId = CDMA_FIRST_CALL_INDEX;
+      childCall.state = nsITelephonyService.CALL_STATE_DIALING;
+      childCall.number = aNumber;
+      childCall.isOutgoing = true;
+      childCall.isEmergency = gDialNumberUtils.isEmergency(aNumber);
+      childCall.isConference = false;
+      childCall.isSwitchable = false;
+      childCall.isMergeable = true;
 
       // Manual update call state according to the request response.
-      this.notifyCallStateChanged(aClientId, childCall);
+      this._handleCallStateChanged(aClientId, childCall);
 
-      childCall.state = RIL.CALL_STATE_ACTIVE;
-      this.notifyCallStateChanged(aClientId, childCall);
+      childCall.state = nsITelephonyService.CALL_STATE_CONNECTED;
+      this._handleCallStateChanged(aClientId, childCall);
 
       let parentCall = this._currentCalls[aClientId][childCall.parentId];
       parentCall.childId = CDMA_SECOND_CALL_INDEX;
-      parentCall.state = RIL.CALL_STATE_HOLDING;
+      parentCall.state = nsITelephonyService.CALL_STATE_HELD;
       parentCall.isSwitchable = false;
       parentCall.isMergeable = true;
-      this.notifyCallStateChanged(aClientId, parentCall);
+      this._handleCallStateChanged(aClientId, parentCall);
     });
   },
 
   _sendDialCallRequest: function(aClientId, aOptions, aCallback) {
     this._isDialing = true;
 
     this._sendToRilWorker(aClientId, "dial", aOptions, response => {
       this._isDialing = false;
 
       if (!response.success) {
-        aCallback.notifyError(response.errorMsg);
-        return;
+        this._sendToRilWorker(aClientId, "getFailCause", null, response => {
+          aCallback.notifyError(response.failCause);
+        });
+      } else {
+        this._ongoingDial = {
+          clientId: aClientId,
+          callback: aCallback
+        };
       }
-
-      aCallback.notifyDialCallSuccess(aClientId, response.callIndex,
-                                      response.number);
     });
   },
 
   /**
    * @param aClientId
    *        Client id.
    * @param aMmi
    *        Parsed MMI structure.
@@ -891,16 +975,86 @@ TelephonyService.prototype = {
   _defaultCallbackHandler: function(aCallback, aResponse) {
     if (!aResponse.success) {
       aCallback.notifyError(aResponse.errorMsg);
     } else {
       aCallback.notifySuccess();
     }
   },
 
+  _getCallsWithState: function(aClientId, aState) {
+    let calls = [];
+    for (let i in this._currentCalls[aClientId]) {
+      let call = this._currentCalls[aClientId][i];
+      if (call.state === aState) {
+        calls.push(call);
+      }
+    }
+    return calls;
+  },
+
+  /**
+   * Update call information from RIL.
+   *
+   * @return Boolean to indicate whether the data is changed.
+   */
+  _updateCallFromRil: function(aCall, aRilCall) {
+    aRilCall.state = this._convertRILCallState(aRilCall.state);
+    aRilCall.number = this._formatInternationalNumber(aRilCall.number,
+                                                      aRilCall.toa);
+
+    if (!aCall.started &&
+        aCall.state == nsITelephonyService.CALL_STATE_CONNECTED) {
+      aCall.started = new Date().getTime();
+    }
+
+    let change = false;
+    const key = ["state", "number", "numberPresentation", "name",
+                 "namePresentation"];
+
+    for (let k of key) {
+      if (aCall[k] != aRilCall[k]) {
+        aCall[k] = aRilCall[k];
+        change = true;
+      }
+    }
+
+    aCall.isOutgoing = !aRilCall.isMT;
+    aCall.isEmergency = gDialNumberUtils.isEmergency(aCall.number);
+
+    return change;
+  },
+
+  /**
+   * Identify the conference group.
+   * Return the conference state and a array of calls in group.
+   *
+   * TODO: handle multi-sim case.
+   */
+  _detectConference: function(aClientId) {
+    // There are some difficuties to identify the conference by |.isMpty| from RIL
+    // so we don't rely on this flag.
+    //  - |.isMpty| becomes false when the conference call is put on hold.
+    //  - |.isMpty| may remain true when other participants left the conference.
+
+    // All the calls in the conference should have the same state and it is
+    // either CONNECTED or HELD. That means, if we find a group of call with
+    // the same state and its size is larger than 2, it must be a conference.
+    let connectedCalls = this._getCallsWithState(aClientId, nsITelephonyService.CALL_STATE_CONNECTED);
+    let heldCalls = this._getCallsWithState(aClientId, nsITelephonyService.CALL_STATE_HELD);
+
+    if (connectedCalls.length >= 2) {
+      return [nsITelephonyService.CALL_STATE_CONNECTED, connectedCalls];
+    } else if (heldCalls.length >= 2) {
+      return [nsITelephonyService.CALL_STATE_HELD, heldCalls];
+    }
+
+    return [nsITelephonyService.CALL_STATE_UNKNOWN, null];
+  },
+
   sendTones: function(aClientId, aDtmfChars, aPauseDuration, aToneDuration,
                       aCallback) {
     let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
     let tones = aDtmfChars;
     let playTone = (tone) => {
       this._sendToRilWorker(aClientId, "startTone", { dtmfChar: tone }, response => {
         if (!response.success) {
           aCallback.notifyError(response.errorMsg);
@@ -930,30 +1084,88 @@ TelephonyService.prototype = {
     this._sendToRilWorker(aClientId, "startTone", { dtmfChar: aDtmfChar });
   },
 
   stopTone: function(aClientId) {
     this._sendToRilWorker(aClientId, "stopTone");
   },
 
   answerCall: function(aClientId, aCallIndex, aCallback) {
-    this._sendToRilWorker(aClientId, "answerCall", { callIndex: aCallIndex },
+    let call = this._currentCalls[aClientId][aCallIndex];
+    if (!call || call.state != nsITelephonyService.CALL_STATE_INCOMING) {
+      aCallback.notifyError(RIL.GECKO_ERROR_GENERIC_FAILURE);
+      return;
+    }
+
+    let callNum = Object.keys(this._currentCalls[aClientId]).length;
+    if (callNum !== 1) {
+      this._switchActiveCall(aClientId, aCallback);
+    } else {
+      this._sendToRilWorker(aClientId, "answerCall", null,
+                            this._defaultCallbackHandler.bind(this, aCallback));
+    }
+  },
+
+  rejectCall: function(aClientId, aCallIndex, aCallback) {
+    if (this._isCdmaClient(aClientId)) {
+      this._hangUpBackground(aClientId, aCallback);
+      return;
+    }
+
+    let call = this._currentCalls[aClientId][aCallIndex];
+    if (!call || call.state != nsITelephonyService.CALL_STATE_INCOMING) {
+      aCallback.notifyError(RIL.GECKO_ERROR_GENERIC_FAILURE);
+      return;
+    }
+
+    let callNum = Object.keys(this._currentCalls[aClientId]).length;
+    if (callNum !== 1) {
+      this._hangUpBackground(aClientId, aCallback);
+    } else {
+      this._sendToRilWorker(aClientId, "udub", null,
+                            this._defaultCallbackHandler.bind(this, aCallback));
+    }
+  },
+
+  _hangUpForeground: function(aClientId, aCallback) {
+    let calls = this._getCallsWithState(aClientId, nsITelephonyService.CALL_STATE_CONNECTED);
+    calls.forEach(call => call.hangUpLocal = true);
+
+    this._sendToRilWorker(aClientId, "hangUpForeground", null,
                           this._defaultCallbackHandler.bind(this, aCallback));
   },
 
-  rejectCall: function(aClientId, aCallIndex, aCallback) {
-    this._sendToRilWorker(aClientId, "rejectCall", { callIndex: aCallIndex },
+  _hangUpBackground: function(aClientId, aCallback) {
+    // When both a held and a waiting call exist, the request shall apply to
+    // the waiting call.
+    let waitingCalls = this._getCallsWithState(aClientId, nsITelephonyService.CALL_STATE_INCOMING);
+    let heldCalls = this._getCallsWithState(aClientId, nsITelephonyService.CALL_STATE_HELD);
+
+    if (waitingCalls.length) {
+      waitingCalls.forEach(call => call.hangUpLocal = true);
+    } else {
+      heldCalls.forEach(call => call.hangUpLocal = true);
+    }
+
+    this._sendToRilWorker(aClientId, "hangUpBackground", null,
                           this._defaultCallbackHandler.bind(this, aCallback));
   },
 
   hangUpCall: function(aClientId, aCallIndex, aCallback) {
     // Should release both, child and parent, together. Since RIL holds only
     // the parent call, we send 'parentId' to RIL.
     aCallIndex = this._currentCalls[aClientId][aCallIndex].parentId || aCallIndex;
 
+    let call = this._currentCalls[aClientId][aCallIndex];
+    if (call.state === nsITelephonyService.CALL_STATE_HELD) {
+      this._hangUpBackground(aClientId, aCallback);
+      return;
+    }
+
+    call.hangUpLocal = true;
     this._sendToRilWorker(aClientId, "hangUpCall", { callIndex: aCallIndex },
                           this._defaultCallbackHandler.bind(this, aCallback));
   },
 
   _switchCall: function(aClientId, aCallIndex, aCallback, aRequiredState) {
     let call = this._currentCalls[aClientId][aCallIndex];
     if (!call) {
       aCallback.notifyError(RIL.GECKO_ERROR_GENERIC_FAILURE);
@@ -969,16 +1181,20 @@ TelephonyService.prototype = {
 
   _switchCallGsm: function(aClientId, aCallIndex, aCallback, aRequiredState) {
     let call = this._currentCalls[aClientId][aCallIndex];
     if (call.state != aRequiredState) {
       aCallback.notifyError(RIL.GECKO_ERROR_GENERIC_FAILURE);
       return;
     }
 
+    this._switchActiveCall(aClientId, aCallback);
+  },
+
+  _switchActiveCall: function(aClientId, aCallback) {
     this._sendToRilWorker(aClientId, "switchActiveCall", null,
                           this._defaultCallbackHandler.bind(this, aCallback));
   },
 
   _switchCallCdma: function(aClientId, aCallIndex, aCallback) {
     let call = this._currentCalls[aClientId][aCallIndex];
     if (!call.isSwitchable) {
       aCallback.notifyError(RIL.GECKO_ERROR_GENERIC_FAILURE);
@@ -1028,21 +1244,21 @@ TelephonyService.prototype = {
         // TODO: Bug 1124993. Deprecate it. Use callback response is enough.
         this._notifyAllListeners("notifyConferenceError",
                                  ["addError", response.errorMsg]);
         return;
       }
 
       for (let index in this._currentCalls[aClientId]) {
         let call = this._currentCalls[aClientId][index];
-        call.state = RIL.CALL_STATE_ACTIVE;
+        call.state = nsITelephonyService.CALL_STATE_CONNECTED;
         call.isConference = true;
-        this.notifyCallStateChanged(aClientId, call);
+        this._handleCallStateChanged(aClientId, call);
       }
-      this.notifyConferenceCallStateChanged(RIL.CALL_STATE_ACTIVE);
+      this._handleConferenceCallStateChanged(nsITelephonyService.CALL_STATE_CONNECTED);
 
       aCallback.notifySuccess();
     });
   },
 
   conferenceCall: function(aClientId, aCallback) {
     if (Object.keys(this._currentCalls[aClientId]).length < 2) {
       aCallback.notifyError(RIL.GECKO_ERROR_GENERIC_FAILURE);
@@ -1080,17 +1296,17 @@ TelephonyService.prototype = {
         aCallback.notifyError(RIL.GECKO_ERROR_GENERIC_FAILURE);
         // TODO: Bug 1124993. Deprecate it. Use callback response is enough.
         this._notifyAllListeners("notifyConferenceError",
                                  ["removeError", response.errorMsg]);
         return;
       }
 
       let childCall = this._currentCalls[aClientId][CDMA_SECOND_CALL_INDEX];
-      this.notifyCallDisconnected(aClientId, childCall);
+      this._handleCallDisconnected(aClientId, childCall);
 
       aCallback.notifySuccess();
     });
   },
 
   separateCall: function(aClientId, aCallIndex, aCallback) {
     let call = this._currentCalls[aClientId][aCallIndex];
     if (!call || !call.isConference) {
@@ -1123,18 +1339,17 @@ TelephonyService.prototype = {
 
   _switchConference: function(aClientId, aCallback) {
     // Cannot hold/resume a conference in cdma.
     if (this._isCdmaClient(aClientId)) {
       aCallback.notifyError(RIL.GECKO_ERROR_GENERIC_FAILURE);
       return;
     }
 
-    this._sendToRilWorker(aClientId, "switchActiveCall", null,
-                          this._defaultCallbackHandler.bind(this, aCallback));
+    this._switchActiveCall(aClientId, aCallback);
   },
 
   holdConference: function(aClientId, aCallback) {
     this._switchConference(aClientId, aCallback);
   },
 
   resumeConference: function(aClientId, aCallback) {
     this._switchConference(aClientId, aCallback);
@@ -1165,36 +1380,23 @@ TelephonyService.prototype = {
   set speakerEnabled(aEnabled) {
     gAudioService.speakerEnabled = aEnabled;
   },
 
   /**
    * nsIGonkTelephonyService interface.
    */
 
-  notifyAudioStateChanged: function(aClientId, aState) {
-    this._audioStates[aClientId] = aState;
-
-    let audioState = aState;
-    for (let i = 0; i < this._numClients; ++i) {
-      audioState = Math.max(audioState, this._audioStates[i]);
-    }
-
-    this._updateAudioState(audioState);
-  },
-
   /**
    * Handle call disconnects by updating our current state and the audio system.
    */
-  notifyCallDisconnected: function(aClientId, aCall) {
+  _handleCallDisconnected: function(aClientId, aCall) {
     if (DEBUG) debug("handleCallDisconnected: " + JSON.stringify(aCall));
 
-    aCall.clientId = aClientId;
     aCall.state = nsITelephonyService.CALL_STATE_DISCONNECTED;
-    aCall.isEmergency = gDialNumberUtils.isEmergency(aCall.number);
     let duration = ("started" in aCall && typeof aCall.started == "number") ?
       new Date().getTime() - aCall.started : 0;
 
     gTelephonyMessenger.notifyCallEnded(aClientId,
                                         aCall.number,
                                         this._cdmaCallWaitingNumber,
                                         aCall.isEmergency,
                                         duration,
@@ -1204,48 +1406,49 @@ TelephonyService.prototype = {
     // Clear cache of this._cdmaCallWaitingNumber after call disconnected.
     this._cdmaCallWaitingNumber = null;
 
     let manualConfStateChange = false;
     let childId = this._currentCalls[aClientId][aCall.callIndex].childId;
     if (childId) {
       // Child cannot live without parent.
       let childCall = this._currentCalls[aClientId][childId];
-      this.notifyCallDisconnected(aClientId, childCall);
+      this._handleCallDisconnected(aClientId, childCall);
     } else {
       let parentId = this._currentCalls[aClientId][aCall.callIndex].parentId;
       if (parentId) {
         let parentCall = this._currentCalls[aClientId][parentId];
         // The child is going to be released.
         delete parentCall.childId;
         if (parentCall.isConference) {
           // As the child is going to be gone, the parent should be moved out
           // of conference accordingly.
           manualConfStateChange = true;
           parentCall.isConference = false;
           parentCall.isSwitchable = true;
           parentCall.isMergeable = true;
           aCall.isConference = false;
-          this.notifyCallStateChanged(aClientId, parentCall, true);
+          this._handleCallStateChanged(aClientId, parentCall);
         }
       }
     }
 
-    if (!aCall.failCause ||
+    if (aCall.hangUpLocal || !aCall.failCause ||
         aCall.failCause === RIL.GECKO_CALL_ERROR_NORMAL_CALL_CLEARING) {
       let callInfo = new TelephonyCallInfo(aCall);
       this._notifyAllListeners("callStateChanged", [callInfo]);
     } else {
       this._notifyAllListeners("notifyError",
                                [aClientId, aCall.callIndex, aCall.failCause]);
     }
+
     delete this._currentCalls[aClientId][aCall.callIndex];
 
     if (manualConfStateChange) {
-      this.notifyConferenceCallStateChanged(RIL.CALL_STATE_UNKNOWN);
+      this._handleConferenceCallStateChanged(nsITelephonyService.CALL_STATE_UNKNOWN);
     }
   },
 
   /**
    * Handle an incoming call.
    *
    * Not much is known about this call at this point, but it's enough
    * to start bringing up the Phone app already.
@@ -1254,101 +1457,190 @@ TelephonyService.prototype = {
     // We need to acquire a CPU wake lock to avoid the system falling into
     // the sleep mode when the RIL handles the incoming call.
     this._acquireCallRingWakeLock();
 
     gTelephonyMessenger.notifyNewCall();
   },
 
   /**
-   * Handle call state changes by updating our current state and the audio
-   * system.
+   * Handle current calls reported from RIL.
+   *
+   * @param aCalls call from RIL, which contains:
+   *        state, callIndex, toa, isMT, number, numberPresentation, name,
+   *        namePresentation.
    */
-  notifyCallStateChanged: function(aClientId, aCall, aSkipStateConversion) {
+  notifyCurrentCalls: function(aClientId, aCalls) {
+    // Check whether there is a removed call.
+    let hasRemovedCalls = () => {
+      let newIndexes = new Set(Object.keys(aCalls));
+      for (let i in this._currentCalls[aClientId]) {
+        if (!newIndexes.has(i)) {
+          return true;
+        }
+      }
+      return false;
+    };
+
+    // If there are removedCalls, we should fetch the failCause first.
+    if (!hasRemovedCalls()) {
+      this._handleCurrentCalls(aClientId, aCalls);
+    } else {
+      this._sendToRilWorker(aClientId, "getFailCause", null, response => {
+        this._handleCurrentCalls(aClientId, aCalls, response.failCause);
+      });
+    }
+  },
+
+  _handleCurrentCalls: function(aClientId, aCalls,
+                                aFailCause = RIL.GECKO_CALL_ERROR_NORMAL_CALL_CLEARING) {
+    if (DEBUG) debug("handleCurrentCalls: " + JSON.stringify(aCalls) +
+                     ", failCause: " + aFailCause);
+
+    let changedCalls = new Set();
+    let removedCalls = new Set();
+
+    let allIndexes = new Set([...Object.keys(this._currentCalls[aClientId]),
+                              ...Object.keys(aCalls)]);
+
+    for (let i of allIndexes) {
+      let call = this._currentCalls[aClientId][i];
+      let rilCall = aCalls[i];
+
+      // Determine the change of call.
+      if (call && !rilCall) {  // removed.
+        removedCalls.add(call);
+      } else if (call && rilCall) {  // changed.
+        if (this._updateCallFromRil(call, rilCall)) {
+          changedCalls.add(call);
+        }
+      } else {  // !call && rilCall. added.
+        this._currentCalls[aClientId][i] = call = new Call(aClientId, i);
+        this._updateCallFromRil(call, rilCall);
+        changedCalls.add(call);
+
+        // Handle ongoingDial.
+        if (this._ongoingDial && this._ongoingDial.clientId === aClientId &&
+            call.state !== nsITelephonyService.CALL_STATE_INCOMING) {
+          this._ongoingDial.callback.notifyDialCallSuccess(aClientId, i,
+                                                           call.number);
+          this._ongoingDial = null;
+        }
+      }
+    }
+
+    // For correct conference detection, we should mark removedCalls as
+    // DISCONNECTED first.
+    removedCalls.forEach(call => {
+      call.state = nsITelephonyService.CALL_STATE_DISCONNECTED;
+      call.failCause = aFailCause;
+      this._handleCallDisconnected(aClientId, call);
+    });
+
+    // Detect conference and update isConference flag.
+    let [newConferenceState, conferenceCalls] = this._detectConference(aClientId);
+    if (DEBUG) debug("Conference state: " + newConferenceState);
+
+    let conference = new Set(conferenceCalls);
+    for (let i in this._currentCalls[aClientId]) {
+      let call = this._currentCalls[aClientId][i];
+      let isConference = conference.has(call);
+      if (call.isConference != isConference) {
+        call.isConference = isConference;
+        changedCalls.add(call);
+      }
+    }
+
+    changedCalls.forEach(call => this._handleCallStateChanged(aClientId, call));
+
+    // Should handle conferenceCallStateChange after callStateChanged and
+    // callDisconnected.
+    if (newConferenceState != this._currentConferenceState) {
+      this._handleConferenceCallStateChanged(newConferenceState);
+    }
+
+    this._updateAudioState(aClientId);
+
+    // Handle cached dial request.
+    if (this._cachedDialRequest && !this._isActive(aClientId)) {
+      if (DEBUG) debug("All calls held. Perform the cached dial request.");
+
+      let request = this._cachedDialRequest;
+      this._sendDialCallRequest(request.clientId, request.options,
+                                request.callback);
+      this._cachedDialRequest = null;
+    }
+  },
+
+  /**
+   * Handle call state changes.
+   */
+  _handleCallStateChanged: function(aClientId, aCall) {
     if (DEBUG) debug("handleCallStateChange: " + JSON.stringify(aCall));
 
-    if (!aSkipStateConversion) {
-      aCall.state = this._convertRILCallState(aCall.state);
-    }
-
     if (aCall.state == nsITelephonyService.CALL_STATE_DIALING) {
       gTelephonyMessenger.notifyNewCall();
     }
 
-    aCall.clientId = aClientId;
-
-    function pick(arg, defaultValue) {
-      return typeof arg !== 'undefined' ? arg : defaultValue;
-    }
-
-    let call = this._currentCalls[aClientId][aCall.callIndex];
-    if (call) {
-      call.state = aCall.state;
-      call.number = aCall.number;
-      call.isConference = aCall.isConference;
-      call.isEmergency = gDialNumberUtils.isEmergency(aCall.number);
-      call.isSwitchable = pick(aCall.isSwitchable, call.isSwitchable);
-      call.isMergeable = pick(aCall.isMergeable, call.isMergeable);
-    } else {
-      call = aCall;
-      call.isEmergency = pick(aCall.isEmergency, gDialNumberUtils.isEmergency(aCall.number));
-      call.isSwitchable = pick(aCall.isSwitchable, true);
-      call.isMergeable = pick(aCall.isMergeable, true);
-      call.name = pick(aCall.name, "");
-      call.numberPresentaation = pick(aCall.numberPresentation, nsITelephonyService.CALL_PRESENTATION_ALLOWED);
-      call.namePresentaation = pick(aCall.namePresentation, nsITelephonyService.CALL_PRESENTATION_ALLOWED);
-
-      this._currentCalls[aClientId][aCall.callIndex] = call;
-    }
-
-    // Handle cached dial request.
-    if (this._cachedDialRequest && !this._getOneActiveCall(aClientId)) {
-      if (DEBUG) debug("All calls held. Perform the cached dial request.");
-
-      let request = this._cachedDialRequest;
-      this._sendDialCallRequest(request.clientId, request.options, request.callback);
-      this._cachedDialRequest = null;
-    }
-
-    let callInfo = new TelephonyCallInfo(call);
+    let callInfo = new TelephonyCallInfo(aCall);
     this._notifyAllListeners("callStateChanged", [callInfo]);
   },
 
   notifyCdmaCallWaiting: function(aClientId, aCall) {
     // We need to acquire a CPU wake lock to avoid the system falling into
     // the sleep mode when the RIL handles the incoming call.
     this._acquireCallRingWakeLock();
 
     let call = this._currentCalls[aClientId][CDMA_SECOND_CALL_INDEX];
     if (call) {
       // TODO: Bug 977503 - B2G RIL: [CDMA] update callNumber when a waiting
       // call comes after a 3way call.
-      this.notifyCallDisconnected(aClientId, call);
+      this._handleCallDisconnected(aClientId, call);
     }
 
     this._cdmaCallWaitingNumber = aCall.number;
 
     this._notifyAllListeners("notifyCdmaCallWaiting", [aClientId,
                                                        aCall.number,
                                                        aCall.numberPresentation,
                                                        aCall.name,
                                                        aCall.namePresentation]);
   },
 
-  notifySupplementaryService: function(aClientId, aCallIndex, aNotification) {
+  notifySupplementaryService: function(aClientId, aNumber, aNotification) {
     let notification = this._convertRILSuppSvcNotification(aNotification);
+
+    // Get the target call object for this notification.
+    let callIndex = -1;
+
+    let indexes = Object.keys(this.currentCalls);
+    if (indexes.length === 1) {
+      // Only one call exists. This should be the target.
+      callIndex = indexes[0];
+    } else {
+      // Find the call in |currentCalls| by the given number.
+      if (aNumber) {
+        for (let i in this._currentCalls) {
+          let call = this._currentCalls[aClientId][i];
+          if (call.number === aNumber) {
+            callIndex = i;
+            break;
+          }
+        }
+      }
+    }
+
     this._notifyAllListeners("supplementaryServiceNotification",
-                             [aClientId, aCallIndex, notification]);
+                             [aClientId, callIndex, notification]);
   },
 
-  notifyConferenceCallStateChanged: function(aState) {
+  _handleConferenceCallStateChanged: function(aState) {
     if (DEBUG) debug("handleConferenceCallStateChanged: " + aState);
-    this._currentConferenceState = this._convertRILCallState(aState);
-    this._notifyAllListeners("conferenceCallStateChanged",
-                             [this._currentConferenceState]);
+    this._currentConferenceState = aState;
+    this._notifyAllListeners("conferenceCallStateChanged", [aState]);
   },
 
   notifyUssdReceived: function(aClientId, aMessage, aSessionEnded) {
     if (DEBUG) {
       debug("notifyUssdReceived for " + aClientId + ": " +
             aMessage + " (sessionEnded : " + aSessionEnded + ")");
     }
 
--- a/dom/telephony/nsIGonkTelephonyService.idl
+++ b/dom/telephony/nsIGonkTelephonyService.idl
@@ -5,30 +5,23 @@
 
 #include "nsITelephonyService.idl"
 
 %{C++
 #define GONK_TELEPHONY_SERVICE_CONTRACTID \
         "@mozilla.org/telephony/gonktelephonyservice;1"
 %}
 
-[scriptable, uuid(eab4b7b4-bf78-4c44-8182-ca305e70f971)]
+[scriptable, uuid(d287e11a-0a65-4456-b481-c63d62afdb5d)]
 interface nsIGonkTelephonyService : nsITelephonyService
 {
-  void notifyAudioStateChanged(in unsigned long clientId, in short state);
-
-  void notifyCallDisconnected(in unsigned long clientId, in jsval call);
-
   void notifyCallRing();
 
-  void notifyCallStateChanged(in unsigned long clientId, in jsval call,
-                              [optional] in boolean skipStateConversion);
+  void notifyCurrentCalls(in unsigned long clientId, in jsval calls);
 
   void notifyCdmaCallWaiting(in unsigned long clientId, in jsval waitingCall);
 
-  void notifySupplementaryService(in unsigned long clientId, in long callIndex,
+  void notifySupplementaryService(in unsigned long clientId, in AString number,
                                   in AString notification);
 
-  void notifyConferenceCallStateChanged(in short state);
-
   void notifyUssdReceived(in unsigned long clientId, in DOMString message,
                           in boolean sessionEnded);
 };
--- a/dom/telephony/test/marionette/test_outgoing_badNumber.js
+++ b/dom/telephony/test/marionette/test_outgoing_badNumber.js
@@ -11,17 +11,17 @@ function testDialOutInvalidNumber() {
   log("Make an outCall call to an invalid number.");
 
   // Note: The number is valid from the view of phone and the call could be
   // dialed out successfully. However, it will later receive the BadNumberError
   // from network side.
   return telephony.dial(number).then(call => {
     outCall = call;
     ok(outCall);
-    is(outCall.id.number, number);
+    is(outCall.id.number, "");  // Emulator returns empty number for this call.
     is(outCall.state, "dialing");
 
     is(outCall, telephony.active);
     is(telephony.calls.length, 1);
     is(telephony.calls[0], outCall);
 
     return gWaitForEvent(outCall, "error").then(event => {
       is(event.call, outCall);
--- a/mobile/android/base/overlays/service/OverlayActionService.java
+++ b/mobile/android/base/overlays/service/OverlayActionService.java
@@ -114,27 +114,17 @@ public class OverlayActionService extend
                 final ShareMethod.Result result = shareMethod.handle(shareData);
                 // Dispatch the share to the targeted ShareMethod.
                 switch (result) {
                     case SUCCESS:
                         // \o/
                         OverlayToastHelper.showSuccessToast(getApplicationContext(), shareMethod.getSuccessMessage());
                         break;
                     case TRANSIENT_FAILURE:
-                        // An OnClickListener to do this share again.
-                        View.OnClickListener retryListener = new View.OnClickListener() {
-                            @Override
-                            public void onClick(View view) {
-                                handleShare(intent);
-                            }
-                        };
-
-                        // Show a failure toast with a retry button.
-                        OverlayToastHelper.showFailureToast(getApplicationContext(), shareMethod.getFailureMessage(), retryListener);
-                        break;
+                        // Fall-through
                     case PERMANENT_FAILURE:
                         // Show a failure toast without a retry button.
                         OverlayToastHelper.showFailureToast(getApplicationContext(), shareMethod.getFailureMessage());
                         break;
                     default:
                         Assert.fail("Unknown share method result code: " + result);
                         break;
                 }
--- a/mobile/android/base/overlays/ui/OverlayDialogButton.java
+++ b/mobile/android/base/overlays/ui/OverlayDialogButton.java
@@ -25,18 +25,18 @@ import android.widget.TextView;