Merge fx-team to m-c a=merge
authorWes Kocher <wkocher@mozilla.com>
Mon, 13 Oct 2014 18:15:04 -0700
changeset 210163 155b84a1d18a94370a8642ded512434bf270b73b
parent 210151 c7f5a7b46fcdefe55d5fd160e9d9e017cc6ebf02 (current diff)
parent 210162 f27341df2b4d801d72b8b6cbae2dc40e5057ebc6 (diff)
child 210208 54217864bae9ce772dabcb68d9a9cb0654431d34
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersmerge
milestone36.0a1
Merge fx-team to m-c a=merge
--- a/addon-sdk/source/test/jetpack-package.ini
+++ b/addon-sdk/source/test/jetpack-package.ini
@@ -1,16 +1,16 @@
 [DEFAULT]
 support-files =
   buffers/**
   commonjs-test-adapter/**
   event/**
   fixtures/**
   loader/**
-  libs/**
+  lib/**
   modules/**
   private-browsing/**
   sidebar/**
   tabs/**
   traits/**
   windows/**
   zip/**
   fixtures.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1547,17 +1547,17 @@ pref("browser.newtabpage.enabled", true)
 
 // number of rows of newtab grid
 pref("browser.newtabpage.rows", 3);
 
 // number of columns of newtab grid
 pref("browser.newtabpage.columns", 5);
 
 // directory tiles download URL
-pref("browser.newtabpage.directory.source", "https://tiles.services.mozilla.com/v2/links/fetch");
+pref("browser.newtabpage.directory.source", "https://tiles.services.mozilla.com/v2/links/fetch/%LOCALE%");
 
 // endpoint to send newtab click and view pings
 pref("browser.newtabpage.directory.ping", "https://tiles.services.mozilla.com/v2/links/");
 
 // Enable the DOM fullscreen API.
 pref("full-screen-api.enabled", true);
 
 // True if the fullscreen API requires approval upon a domain entering fullscreen.
--- a/browser/components/loop/standalone/content/css/webapp.css
+++ b/browser/components/loop/standalone/content/css/webapp.css
@@ -18,16 +18,19 @@ body,
   font-family: Open Sans,sans-serif;
 }
 
 .standalone-header {
   border-radius: 4px;
   background: #fff;
   border: 1px solid #E7E7E7;
   box-shadow: 0px 2px 0px rgba(0, 0, 0, 0.03);
+  background-image: url("../shared/img/beta-ribbon.svg#beta-ribbon");
+  background-size: 5rem 5rem;
+  background-repeat: no-repeat;
 }
 
 .header-box {
   padding: 1rem 5rem;
   margin-top: 2rem;
 }
 
 /*
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -278,17 +278,21 @@ loop.webapp = (function($, _, OT, mozL10
       this.setState({callState: "ringing"});
     },
 
     _cancelOutgoingCall: function() {
       this.props.websocket.cancel();
     },
 
     render: function() {
-      var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
+      var callStateStringEntityName = "call_progress_" + this.state.callState + "_description";
+      var callState = mozL10n.get(callStateStringEntityName);
+      document.title = mozL10n.get("standalone_title_with_status",
+                                   {clientShortname: mozL10n.get("clientShortname2"),
+                                    currentStatus: mozL10n.get(callStateStringEntityName)});
       return (
         React.DOM.div({className: "container"}, 
           React.DOM.div({className: "container-box"}, 
             React.DOM.header({className: "pending-header header-box"}, 
               ConversationBranding(null)
             ), 
 
             React.DOM.div({id: "cameraPreview"}), 
@@ -514,16 +518,19 @@ loop.webapp = (function($, _, OT, mozL10
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
       feedbackApiClient: React.PropTypes.object.isRequired,
       onAfterFeedbackReceived: React.PropTypes.func.isRequired
     },
 
     render: function() {
+      document.title = mozL10n.get("standalone_title_with_status",
+                                   {clientShortname: mozL10n.get("clientShortname2"),
+                                    currentStatus: mozL10n.get("status_conversation_ended")});
       return (
         React.DOM.div({className: "ended-conversation"}, 
           sharedViews.FeedbackView({
             feedbackApiClient: this.props.feedbackApiClient, 
             onAfterFeedbackReceived: this.props.onAfterFeedbackReceived}
           ), 
           sharedViews.ConversationView({
             initiate: false, 
@@ -534,26 +541,30 @@ loop.webapp = (function($, _, OT, mozL10
           )
         )
       );
     }
   });
 
   var StartConversationView = React.createClass({displayName: 'StartConversationView',
     render: function() {
+      document.title = mozL10n.get("clientShortname2");
       return this.transferPropsTo(
         InitiateConversationView({
           title: mozL10n.get("initiate_call_button_label2"), 
           callButtonLabel: mozL10n.get("initiate_audio_video_call_button2")})
       );
     }
   });
 
   var FailedConversationView = React.createClass({displayName: 'FailedConversationView',
     render: function() {
+      document.title = mozL10n.get("standalone_title_with_status",
+                                   {clientShortname: mozL10n.get("clientShortname2"),
+                                    currentStatus: mozL10n.get("status_error")});
       return this.transferPropsTo(
         InitiateConversationView({
           title: mozL10n.get("call_failed_title"), 
           callButtonLabel: mozL10n.get("retry_call_button")})
       );
     }
   });
 
@@ -631,16 +642,19 @@ loop.webapp = (function($, _, OT, mozL10
               client: this.props.client}
             )
           );
         }
         case "pending": {
           return PendingConversationView({websocket: this._websocket});
         }
         case "connected": {
+          document.title = mozL10n.get("standalone_title_with_status",
+                                       {clientShortname: mozL10n.get("clientShortname2"),
+                                        currentStatus: mozL10n.get("status_in_conversation")});
           return (
             sharedViews.ConversationView({
               initiate: true, 
               sdk: this.props.sdk, 
               model: this.props.conversation, 
               video: {enabled: this.props.conversation.hasVideoStream("outgoing")}}
             )
           );
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -278,17 +278,21 @@ loop.webapp = (function($, _, OT, mozL10
       this.setState({callState: "ringing"});
     },
 
     _cancelOutgoingCall: function() {
       this.props.websocket.cancel();
     },
 
     render: function() {
-      var callState = mozL10n.get("call_progress_" + this.state.callState + "_description");
+      var callStateStringEntityName = "call_progress_" + this.state.callState + "_description";
+      var callState = mozL10n.get(callStateStringEntityName);
+      document.title = mozL10n.get("standalone_title_with_status",
+                                   {clientShortname: mozL10n.get("clientShortname2"),
+                                    currentStatus: mozL10n.get(callStateStringEntityName)});
       return (
         <div className="container">
           <div className="container-box">
             <header className="pending-header header-box">
               <ConversationBranding />
             </header>
 
             <div id="cameraPreview" />
@@ -514,16 +518,19 @@ loop.webapp = (function($, _, OT, mozL10
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
       feedbackApiClient: React.PropTypes.object.isRequired,
       onAfterFeedbackReceived: React.PropTypes.func.isRequired
     },
 
     render: function() {
+      document.title = mozL10n.get("standalone_title_with_status",
+                                   {clientShortname: mozL10n.get("clientShortname2"),
+                                    currentStatus: mozL10n.get("status_conversation_ended")});
       return (
         <div className="ended-conversation">
           <sharedViews.FeedbackView
             feedbackApiClient={this.props.feedbackApiClient}
             onAfterFeedbackReceived={this.props.onAfterFeedbackReceived}
           />
           <sharedViews.ConversationView
             initiate={false}
@@ -534,26 +541,30 @@ loop.webapp = (function($, _, OT, mozL10
           />
         </div>
       );
     }
   });
 
   var StartConversationView = React.createClass({
     render: function() {
+      document.title = mozL10n.get("clientShortname2");
       return this.transferPropsTo(
         <InitiateConversationView
           title={mozL10n.get("initiate_call_button_label2")}
           callButtonLabel={mozL10n.get("initiate_audio_video_call_button2")} />
       );
     }
   });
 
   var FailedConversationView = React.createClass({
     render: function() {
+      document.title = mozL10n.get("standalone_title_with_status",
+                                   {clientShortname: mozL10n.get("clientShortname2"),
+                                    currentStatus: mozL10n.get("status_error")});
       return this.transferPropsTo(
         <InitiateConversationView
           title={mozL10n.get("call_failed_title")}
           callButtonLabel={mozL10n.get("retry_call_button")} />
       );
     }
   });
 
@@ -631,16 +642,19 @@ loop.webapp = (function($, _, OT, mozL10
               client={this.props.client}
             />
           );
         }
         case "pending": {
           return <PendingConversationView websocket={this._websocket} />;
         }
         case "connected": {
+          document.title = mozL10n.get("standalone_title_with_status",
+                                       {clientShortname: mozL10n.get("clientShortname2"),
+                                        currentStatus: mozL10n.get("status_in_conversation")});
           return (
             <sharedViews.ConversationView
               initiate={true}
               sdk={this.props.sdk}
               model={this.props.conversation}
               video={{enabled: this.props.conversation.hasVideoStream("outgoing")}}
             />
           );
--- a/browser/components/loop/standalone/content/l10n/loop.en-US.properties
+++ b/browser/components/loop/standalone/content/l10n/loop.en-US.properties
@@ -114,8 +114,17 @@ rooms_room_full_label=There are already 
 rooms_room_full_call_to_action_nonFx_label=Download {{brandShortname}} to start your own
 rooms_room_full_call_to_action_label=Learn more about {{clientShortname}} »
 rooms_room_joined_label=Someone has joined the conversation!
 rooms_room_join_label=Join the conversation
 
 brand_website=https://www.mozilla.org/firefox/
 privacy_website=https://www.mozilla.org/privacy/
 legal_website=/legal/terms/
+
+## LOCALIZATION_NOTE(standalone_title_with_status): {{clientShortname}} will be
+## replaced by the brand name and {{currentStatus}} will be replaced
+## by the current call status (Connecting, Ringing, etc.)
+standalone_title_with_status={{clientShortname}} — {{currentStatus}}
+status_in_conversation=In conversation
+status_conversation_ended=Conversation ended
+status_error=Something went wrong
+
--- a/browser/modules/DirectoryLinksProvider.jsm
+++ b/browser/modules/DirectoryLinksProvider.jsm
@@ -178,16 +178,19 @@ let DirectoryLinksProvider = {
   _removePrefsObserver: function DirectoryLinksProvider_removeObserver() {
     for (let pref in this._observedPrefs) {
       let prefName = this._observedPrefs[pref];
       Services.prefs.removeObserver(prefName, this);
     }
   },
 
   _fetchAndCacheLinks: function DirectoryLinksProvider_fetchAndCacheLinks(uri) {
+    // Replace with the same display locale used for selecting links data
+    uri = uri.replace("%LOCALE%", this.locale);
+
     let deferred = Promise.defer();
     let xmlHttp = new XMLHttpRequest();
 
     let self = this;
     xmlHttp.onload = function(aResponse) {
       let json = this.responseText;
       if (this.status && this.status != 200) {
         json = "{}";
@@ -201,24 +204,22 @@ let DirectoryLinksProvider = {
         });
     };
 
     xmlHttp.onerror = function(e) {
       deferred.reject("Fetching " + uri + " results in error code: " + e.target.status);
     };
 
     try {
-      xmlHttp.open('POST', uri);
+      xmlHttp.open("GET", uri);
       // Override the type so XHR doesn't complain about not well-formed XML
       xmlHttp.overrideMimeType(DIRECTORY_LINKS_TYPE);
       // Set the appropriate request type for servers that require correct types
       xmlHttp.setRequestHeader("Content-Type", DIRECTORY_LINKS_TYPE);
-      xmlHttp.send(JSON.stringify({
-        locale: this.locale,
-      }));
+      xmlHttp.send();
     } catch (e) {
       deferred.reject("Error fetching " + uri);
       Cu.reportError(e);
     }
     return deferred.promise;
   },
 
   /**
--- a/browser/modules/test/xpcshell/test_DirectoryLinksProvider.js
+++ b/browser/modules/test/xpcshell/test_DirectoryLinksProvider.js
@@ -48,32 +48,29 @@ const kPingUrl = kBaseUrl + kPingPath;
 Services.prefs.setCharPref(kLocalePref, "en-US");
 Services.prefs.setCharPref(kSourceUrlPref, kTestURL);
 Services.prefs.setCharPref(kPingUrlPref, kPingUrl);
 Services.prefs.setBoolPref(kNewtabEnhancedPref, true);
 
 const kHttpHandlerData = {};
 kHttpHandlerData[kExamplePath] = {"en-US": [{"url":"http://example.com","title":"RemoteSource"}]};
 
-const expectedBodyObject = {locale: DirectoryLinksProvider.locale};
 const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
                               "nsIBinaryInputStream",
                               "setInputStream");
 
+let gLastRequestPath;
 function getHttpHandler(path) {
   let code = 200;
   let body = JSON.stringify(kHttpHandlerData[path]);
   if (path == kFailPath) {
     code = 204;
   }
   return function(aRequest, aResponse) {
-    let bodyStream = new BinaryInputStream(aRequest.bodyInputStream);
-    let bodyObject = JSON.parse(NetUtil.readInputStreamToString(bodyStream, bodyStream.available()));
-    isIdentical(bodyObject, expectedBodyObject);
-
+    gLastRequestPath = aRequest.path;
     aResponse.setStatusLine(null, code);
     aResponse.setHeader("Content-Type", "application/json");
     aResponse.write(body);
   };
 }
 
 function isIdentical(actual, expected) {
   if (expected == null) {
@@ -126,17 +123,19 @@ function promiseDirectoryDownloadOnPrefC
   let oldValue = Services.prefs.getCharPref(pref);
   if (oldValue != newValue) {
     // if the preference value is already equal to newValue
     // the pref service will not call our observer and we
     // deadlock. Hence only setup observer if values differ
     let observer = new LinksChangeObserver();
     DirectoryLinksProvider.addObserver(observer);
     Services.prefs.setCharPref(pref, newValue);
-    return observer.deferred.promise;
+    return observer.deferred.promise.then(() => {
+      DirectoryLinksProvider.removeObserver(observer);
+    });
   }
   return Promise.resolve();
 }
 
 function promiseSetupDirectoryLinksProvider(options = {}) {
   return Task.spawn(function() {
     let linksURL = options.linksURL || kTestURL;
     yield DirectoryLinksProvider.init();
@@ -291,17 +290,18 @@ add_task(function test_fetchAndCacheLink
   let data = yield readJsonFile();
   isIdentical(data, kURLData);
 });
 
 add_task(function test_fetchAndCacheLinks_remote() {
   yield DirectoryLinksProvider.init();
   yield cleanJsonFile();
   // this must trigger directory links json download and save it to cache file
-  yield DirectoryLinksProvider._fetchAndCacheLinks(kExampleURL);
+  yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kExampleURL + "%LOCALE%");
+  do_check_eq(gLastRequestPath, kExamplePath + "en-US");
   let data = yield readJsonFile();
   isIdentical(data, kHttpHandlerData[kExamplePath]);
 });
 
 add_task(function test_fetchAndCacheLinks_malformedURI() {
   yield DirectoryLinksProvider.init();
   yield cleanJsonFile();
   let someJunk = "some junk";
@@ -331,17 +331,18 @@ add_task(function test_fetchAndCacheLink
   // File should be empty.
   let data = yield readJsonFile();
   isIdentical(data, "");
 });
 
 add_task(function test_fetchAndCacheLinks_non200Status() {
   yield DirectoryLinksProvider.init();
   yield cleanJsonFile();
-  yield DirectoryLinksProvider._fetchAndCacheLinks(kFailURL);
+  yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kFailURL);
+  do_check_eq(gLastRequestPath, kFailPath);
   let data = yield readJsonFile();
   isIdentical(data, {});
 });
 
 // To test onManyLinksChanged observer, trigger a fetch
 add_task(function test_DirectoryLinksProvider__linkObservers() {
   yield DirectoryLinksProvider.init();
 
@@ -503,16 +504,17 @@ add_task(function test_DirectoryLinksPro
   yield cleanJsonFile();
   // ensure that provider does not think it needs to download
   do_check_false(DirectoryLinksProvider._needsDownload);
 
   // change the source URL, which should force directory download
   yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kExampleURL);
   // then wait for testObserver to fire and test that json is downloaded
   yield testObserver.deferred.promise;
+  do_check_eq(gLastRequestPath, kExamplePath);
   let data = yield readJsonFile();
   isIdentical(data, kHttpHandlerData[kExamplePath]);
 
   yield promiseCleanDirectoryLinksProvider();
 });
 
 add_task(function test_DirectoryLinksProvider_fetchDirectoryOnShow() {
   yield promiseSetupDirectoryLinksProvider();
--- a/mobile/android/base/home/ReadingListRow.java
+++ b/mobile/android/base/home/ReadingListRow.java
@@ -3,40 +3,85 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.home;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Tab;
 import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.db.BrowserContract.ReadingListItems;
 import org.mozilla.gecko.home.TwoLinePageRow;
 
 import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
 import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+public class ReadingListRow extends LinearLayout {
+
+    private final Resources resources;
 
-public class ReadingListRow extends TwoLinePageRow {
+    private final TextView title;
+    private final TextView excerpt;
+    private final TextView readTime;
+
+    // Average reading speed in words per minute.
+    private static final int AVERAGE_READING_SPEED = 250;
+
+    // Length of average word.
+    private static final float AVERAGE_WORD_LENGTH = 5.1f;
+
 
     public ReadingListRow(Context context) {
         this(context, null);
     }
 
     public ReadingListRow(Context context, AttributeSet attrs) {
         super(context, attrs);
+
+        LayoutInflater.from(context).inflate(R.layout.reading_list_row_view, this);
+
+        setOrientation(LinearLayout.VERTICAL);
+
+        resources = context.getResources();
+
+        title = (TextView) findViewById(R.id.title);
+        excerpt = (TextView) findViewById(R.id.excerpt);
+        readTime = (TextView) findViewById(R.id.read_time);
     }
 
-    @Override
-    protected void updateDisplayedUrl() {
-        String pageUrl = getUrl();
+    public void updateFromCursor(Cursor cursor) {
+        if (cursor == null) {
+            return;
+        }
 
-        boolean isPrivate = Tabs.getInstance().getSelectedTab().isPrivate();
-        Tab tab = Tabs.getInstance().getFirstReaderTabForUrl(pageUrl, isPrivate);
+        final int titleIndex = cursor.getColumnIndexOrThrow(ReadingListItems.TITLE);
+        title.setText(cursor.getString(titleIndex));
 
-        if (tab != null) {
-            setUrl(R.string.switch_to_tab);
-            setSwitchToTabIcon(R.drawable.ic_url_bar_tab);
+        final int excerptIndex = cursor.getColumnIndexOrThrow(ReadingListItems.EXCERPT);
+        excerpt.setText(cursor.getString(excerptIndex));
+
+        final int lengthIndex = cursor.getColumnIndexOrThrow(ReadingListItems.LENGTH);
+        final int minutes = getEstimatedReadTime(cursor.getInt(lengthIndex));
+        if (minutes <= 60) {
+            readTime.setText(resources.getString(R.string.reading_list_time_minutes, minutes));
         } else {
-            setUrl(pageUrl);
-            setSwitchToTabIcon(NO_ICON);
+            readTime.setText(resources.getString(R.string.reading_list_time_over_an_hour));
         }
     }
 
+    /**
+     * Calculates the estimated time to read an article based on its length.
+     *
+     * @param length of the article (in characters)
+     * @return estimated time to read the article (in minutes)
+     */
+    private static int getEstimatedReadTime(int length) {
+        final int minutes = (int) Math.ceil((length / AVERAGE_WORD_LENGTH) / AVERAGE_READING_SPEED);
+
+        // Minimum of one minute.
+        return Math.max(minutes, 1);
+    }
 }
--- a/mobile/android/base/home/RemoteTabsPanel.java
+++ b/mobile/android/base/home/RemoteTabsPanel.java
@@ -1,17 +1,16 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.home;
 
 import java.util.EnumMap;
-import java.util.HashMap;
 import java.util.Map;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.fxa.AccountLoader;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.fxa.login.State.Action;
@@ -47,20 +46,24 @@ public class RemoteTabsPanel extends Hom
 
     // The current fragment being shown to reflect the system account state. We
     // don't want to detach and re-attach panels unnecessarily, because that
     // causes flickering.
     private Fragment mCurrentFragment;
 
     // A lazily-populated cache of fragments corresponding to the possible
     // system account states. We don't want to re-create panels unnecessarily,
-    // because that can cause flickering. Be aware that null is a valid key; it
-    // corresponds to "no Account, neither Firefox nor Legacy Sync."
+    // because that can cause flickering. `null` is not a valid key.
     private final Map<Action, Fragment> mFragmentCache = new EnumMap<>(Action.class);
 
+    // The fragment that corresponds to the null action -- "no Account,
+    // neither Firefox nor Legacy Sync."
+    // Lazily populated.
+    private Fragment mFallbackFragment;
+
     @Override
     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
         return inflater.inflate(R.layout.home_remote_tabs_panel, container, false);
     }
 
     @Override
     public void onActivityCreated(Bundle savedInstanceState) {
         super.onActivityCreated(savedInstanceState);
@@ -170,22 +173,30 @@ public class RemoteTabsPanel extends Hom
      * A null Account means there is no Account (Sync or Firefox) on the device.
      *
      * @param account
      *            Android Account (Sync or Firefox); may be null.
      */
     private Fragment getFragmentNeeded(Account account) {
         final Action actionNeeded = getActionNeeded(account);
 
-        // We use containsKey rather than get because null is a valid key.
-        if (!mFragmentCache.containsKey(actionNeeded)) {
-            final Fragment fragment = makeFragmentForAction(actionNeeded);
+        if (actionNeeded == null) {
+            if (mFallbackFragment == null) {
+                mFallbackFragment = makeFragmentForAction(null);
+            }
+            return mFallbackFragment;
+        }
+
+        Fragment fragment = mFragmentCache.get(actionNeeded);
+        if (fragment == null) {
+            fragment = makeFragmentForAction(actionNeeded);
             mFragmentCache.put(actionNeeded, fragment);
         }
-        return mFragmentCache.get(actionNeeded);
+
+        return fragment;
     }
 
     /**
      * Update the UI to reflect the given <code>Account</code> and its state.
      * <p>
      * A null Account means there is no Account (Sync or Firefox) on the device.
      *
      * @param account
--- a/mobile/android/base/locales/en-US/android_strings.dtd
+++ b/mobile/android/base/locales/en-US/android_strings.dtd
@@ -359,16 +359,22 @@ size. -->
 <!ENTITY site_settings_cancel       "Cancel">
 <!ENTITY site_settings_clear        "Clear">
 <!ENTITY site_settings_no_settings  "There are no settings to clear.">
 
 <!ENTITY reading_list_added "Page added to your Reading List">
 <!ENTITY reading_list_failed "Failed to add page to your Reading List">
 <!ENTITY reading_list_duplicate "Page already in your Reading List">
 
+<!-- Localization note (reading_list_time_minutes) : This string is used in the "Reading List"
+     panel on the home page to give the user an estimate of how many minutes it will take to
+     read an article. The word "minute" should be abbreviated if possible. -->
+<!ENTITY reading_list_time_minutes "&formatD;min">
+<!ENTITY reading_list_time_over_an_hour "Over an hour">
+
 <!-- Localization note : These strings are used as alternate text for accessibility.
      They are not visible in the UI. -->
 <!ENTITY page_action_dropmarker_description "Additional Actions">
 
 <!ENTITY masterpassword_create_title "Create Master Password">
 <!ENTITY masterpassword_remove_title "Remove Master Password">
 <!ENTITY masterpassword_password "Password">
 <!ENTITY masterpassword_confirm "Confirm password">
--- a/mobile/android/base/resources/layout/home_reading_list_panel.xml
+++ b/mobile/android/base/resources/layout/home_reading_list_panel.xml
@@ -11,10 +11,10 @@
     <ViewStub android:id="@+id/home_empty_view_stub"
               android:layout="@layout/home_empty_reading_panel"
               android:layout_width="match_parent"
               android:layout_height="match_parent"/>
 
     <org.mozilla.gecko.home.HomeListView android:id="@+id/list"
                                          style="@style/Widget.ReadingListView"
                                          android:layout_width="match_parent"
-                                         android:layout_height="wrap_content"/>
+                                         android:layout_height="match_parent"/>
 </LinearLayout>
--- a/mobile/android/base/resources/layout/reading_list_item_row.xml
+++ b/mobile/android/base/resources/layout/reading_list_item_row.xml
@@ -1,10 +1,10 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- This Source Code Form is subject to the terms of the Mozilla Public
    - License, v. 2.0. If a copy of the MPL was not distributed with this
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 
 <org.mozilla.gecko.home.ReadingListRow xmlns:android="http://schemas.android.com/apk/res/android"
                                        style="@style/Widget.BookmarkItemView"
                                        android:layout_width="match_parent"
-                                       android:layout_height="@dimen/page_row_height"
-                                       android:minHeight="@dimen/page_row_height"/>
+                                       android:layout_height="wrap_content"
+                                       android:padding="10dp"/>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/layout/reading_list_row_view.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+
+        <TextView
+            android:id="@+id/title"
+            android:layout_width="0dip"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            style="@style/Widget.ReadingListRow.Title" />
+
+        <TextView
+            android:id="@+id/read_time"
+            android:layout_width="wrap_content"
+            android:layout_height="match_parent"
+            style="@style/Widget.ReadingListRow.ReadTime" />
+
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/excerpt"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        style="@style/Widget.ReadingListRow.Description" />
+
+</merge>
--- a/mobile/android/base/resources/values-v16/styles.xml
+++ b/mobile/android/base/resources/values-v16/styles.xml
@@ -12,16 +12,22 @@
     <style name="TextAppearance.Widget.Home.ItemTitle" parent="TextAppearance.Medium">
         <item name="android:fontFamily">sans-serif-light</item>
     </style>
 
     <style name="TextAppearance.Widget.Home.PageTitle" parent="TextAppearance.Medium">
         <item name="android:fontFamily">sans-serif-light</item>
     </style>
 
+    <style name="Widget.ReadingListRow.ReadTime">
+        <item name="android:textStyle">italic</item>
+        <item name="android:textColor">#FF9400</item>
+        <item name="android:fontFamily">sans-serif-condensed</item>
+    </style>
+
     <style name="OnboardStartTextAppearance.Subtext">
         <item name="android:textSize">18sp</item>
         <item name="android:fontFamily">sans-serif-light</item>
     </style>
     <style name="TextAppearance.UrlBar.Title" parent="TextAppearance.Small">
         <item name="android:textSize">15sp</item>
         <item name="android:fontFamily">sans-serif-light</item>
     </style>
--- a/mobile/android/base/resources/values/styles.xml
+++ b/mobile/android/base/resources/values/styles.xml
@@ -122,16 +122,35 @@
 
     <style name="Widget.TwoLinePageRow.Url">
         <item name="android:textAppearance">@style/TextAppearance.Widget.Home.ItemDescription</item>
         <item name="android:includeFontPadding">false</item>
         <item name="android:singleLine">true</item>
         <item name="android:ellipsize">middle</item>
     </style>
 
+    <style name="Widget.ReadingListRow" />
+
+    <style name="Widget.ReadingListRow.Title">
+        <item name="android:textAppearance">@style/TextAppearance.Widget.Home.ItemTitle</item>
+        <item name="android:maxLines">2</item>
+        <item name="android:ellipsize">end</item>
+    </style>
+
+    <style name="Widget.ReadingListRow.Description">
+        <item name="android:textAppearance">@style/TextAppearance.Widget.Home.ItemDescription</item>
+        <item name="android:maxLines">4</item>
+        <item name="android:ellipsize">end</item>
+    </style>
+
+    <style name="Widget.ReadingListRow.ReadTime">
+        <item name="android:textStyle">italic</item>
+        <item name="android:textColor">@color/text_color_highlight</item>
+    </style>
+
     <style name="Widget.BookmarkFolderView" parent="Widget.TwoLinePageRow.Title">
         <item name="android:singleLine">true</item>
         <item name="android:ellipsize">none</item>
         <item name="android:paddingLeft">10dip</item>
         <item name="android:drawablePadding">10dip</item>
         <item name="android:drawableLeft">@drawable/bookmark_folder</item>
     </style>
 
--- a/mobile/android/base/strings.xml.in
+++ b/mobile/android/base/strings.xml.in
@@ -293,16 +293,18 @@
   <string name="site_settings_title">&site_settings_title3;</string>
   <string name="site_settings_cancel">&site_settings_cancel;</string>
   <string name="site_settings_clear">&site_settings_clear;</string>
   <string name="site_settings_no_settings">&site_settings_no_settings;</string>
 
   <string name="reading_list_added">&reading_list_added;</string>
   <string name="reading_list_failed">&reading_list_failed;</string>
   <string name="reading_list_duplicate">&reading_list_duplicate;</string>
+  <string name="reading_list_time_minutes">&reading_list_time_minutes;</string>
+  <string name="reading_list_time_over_an_hour">&reading_list_time_over_an_hour;</string>
 
   <string name="page_action_dropmarker_description">&page_action_dropmarker_description;</string>
 
   <string name="contextmenu_open_new_tab">&contextmenu_open_new_tab;</string>
   <string name="contextmenu_open_private_tab">&contextmenu_open_private_tab;</string>
   <string name="contextmenu_remove">&contextmenu_remove;</string>
   <string name="contextmenu_add_to_launcher">&contextmenu_add_to_launcher;</string>
   <string name="contextmenu_share">&contextmenu_share;</string>
--- a/mobile/android/chrome/content/aboutAddons.js
+++ b/mobile/android/chrome/content/aboutAddons.js
@@ -532,17 +532,17 @@ var Addons = {
 
   onUninstalled: function(aAddon) {
     let list = document.getElementById("addons-list");
     let element = this._getElementForAddon(aAddon.id);
     list.removeChild(element);
 
     // Go back if we're in the detail view of the add-on that was uninstalled.
     let detailItem = document.querySelector("#addons-details > .addon-item");
-    if (detailItem.addon == aAddon) {
+    if (detailItem.addon.id == aAddon.id) {
       history.back();
     }
   },
 
   onInstallFailed: function(aInstall) {
   },
 
   onDownloadProgress: function xpidm_onDownloadProgress(aInstall) {