Backed out 6 changesets (bug 1369604) for android bustage in activitystream/Utils.java a=backout CLOSED TREE
authorWes Kocher <wkocher@mozilla.com>
Fri, 28 Jul 2017 19:31:38 -0700
changeset 420500 f91ecef40cabc02c2c669fd3572b8e121a9b7073
parent 420499 0aaf1e3ab6ea04d6986c8d2af4c4f9a728ab3c39
child 420501 1973805bf2046328d5d5d58cb9f7c1aaf164e414
push id7566
push usermtabara@mozilla.com
push dateWed, 02 Aug 2017 08:25:16 +0000
treeherdermozilla-beta@86913f512c3c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbackout
bugs1369604
milestone56.0a1
backs out07ae1124989b5214687b4aafe40fa008345fedd8
10a40b857266d63cf5c0577fd163d896b9c35eca
e49ad83bc31b4f1d14a0d0319822d65c30acaedf
b33806ccdd9fbe51e11744cb3f44b807a102e29c
e226cd5d64b254722e0fbfc21fc343b59542d5b8
9e2a5ef546dd9da5325f62de1ffb6336faffde9c
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
Backed out 6 changesets (bug 1369604) for android bustage in activitystream/Utils.java a=backout CLOSED TREE Backed out changeset 07ae1124989b (bug 1369604) Backed out changeset 10a40b857266 (bug 1369604) Backed out changeset e49ad83bc31b (bug 1369604) Backed out changeset b33806ccdd9f (bug 1369604) Backed out changeset e226cd5d64b2 (bug 1369604) Backed out changeset 9e2a5ef546dd (bug 1369604) MozReview-Commit-ID: 2IwX8r9b56D
mobile/android/app/src/test/java/org/mozilla/gecko/activitystream/homepanel/model/TestHighlight.java
mobile/android/base/java/org/mozilla/gecko/activitystream/Utils.java
mobile/android/base/java/org/mozilla/gecko/activitystream/homepanel/HighlightsLoader.java
mobile/android/base/java/org/mozilla/gecko/activitystream/homepanel/model/Highlight.java
mobile/android/base/java/org/mozilla/gecko/activitystream/homepanel/model/Metadata.java
mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightCandidate.java
mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightCandidateCursorIndices.java
mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightsRanking.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/activitystream/ranking/TestHighlightsRanking.java
deleted file mode 100644
--- a/mobile/android/app/src/test/java/org/mozilla/gecko/activitystream/homepanel/model/TestHighlight.java
+++ /dev/null
@@ -1,91 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-package org.mozilla.gecko.activitystream.homepanel.model;
-
-import junit.framework.Assert;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mozilla.gecko.background.testhelpers.TestRunner;
-
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import static android.R.id.input;
-
-@RunWith(TestRunner.class)
-public class TestHighlight {
-
-    @Test
-    public void testInitFastImageURL() throws Exception {
-        final Map<String, String> jsonToExpected = new HashMap<>();
-        jsonToExpected.put(
-                "{\"image_url\":\"https:\\/\\/upload.wikimedia.org\\/wikipedia\\/commons\\/f\\/f1\\/Brauysegen_im_Bett.gif\"}",
-                "https:\\/\\/upload.wikimedia.org\\/wikipedia\\/commons\\/f\\/f1\\/Brauysegen_im_Bett.gif");
-        jsonToExpected.put(
-                "{\"image_url\":\"https:\\/\\/www.apple.com\\/v\\/music\\/e\\/images\\/shared\\/og_image_family.png?201706051846\",\"provider\":\"Apple\",\"description_length\":135}",
-                "https:\\/\\/www.apple.com\\/v\\/music\\/e\\/images\\/shared\\/og_image_family.png?201706051846");
-        jsonToExpected.put(
-                "{\"image_url\":\"https:\\/\\/i.kinja-img.com\\/gawker-media\\/image\\/upload\\/s--XYjAnDao--\\/c_fill,fl_progressive,g_center,h_200,q_80,w_200\\/ghxlwgdztvqerb4zptdx.png\",\"provider\":\"Kotaku\",\"description_length\":17}",
-                "https:\\/\\/i.kinja-img.com\\/gawker-media\\/image\\/upload\\/s--XYjAnDao--\\/c_fill,fl_progressive,g_center,h_200,q_80,w_200\\/ghxlwgdztvqerb4zptdx.png");
-
-        for (final Map.Entry<String, String> entry : jsonToExpected.entrySet()) {
-            final String input = entry.getKey();
-            final String expected = entry.getValue();
-            Assert.assertEquals("For input: " + input, expected, Highlight.initFastImageURL(input));
-        }
-    }
-
-    @Test
-    public void testInitFastImageURLReturnsNullWithoutImageURL() {
-        final List<String> inputs = Arrays.asList(
-                "{\"description_length\":130}",
-                "{\"provider\":\"CNN\",\"description_length\":117}"
-        );
-
-        for (final String input : inputs) {
-            Assert.assertNull("For input: " + input, Highlight.initFastImageURL(input));
-        }
-    }
-
-    @Test
-    public void testInitFastImageURLNullInput() {
-        Assert.assertNull(Highlight.initFastImageURL(null));
-    }
-
-    @Test
-    public void testInitFastDescriptionLength() {
-        final Map<String, Integer> jsonToExpected = new HashMap<>();
-        jsonToExpected.put("{\"description_length\":130}", 130);
-        jsonToExpected.put(
-                "{\"image_url\":\"https:\\/\\/www.apple.com\\/v\\/music\\/e\\/images\\/shared\\/og_image_family.png?201706051846\",\"provider\":\"Apple\",\"description_length\":135}",
-                135);
-        jsonToExpected.put(
-                "{\"image_url\":\"https:\\/\\/i.kinja-img.com\\/gawker-media\\/image\\/upload\\/s--XYjAnDao--\\/c_fill,fl_progressive,g_center,h_200,q_80,w_200\\/ghxlwgdztvqerb4zptdx.png\",\"provider\":\"Kotaku\",\"description_length\":17}",
-                17);
-
-        for (final Map.Entry<String, Integer> entry : jsonToExpected.entrySet()) {
-            final String input = entry.getKey();
-            final int expected = entry.getValue();
-            Assert.assertEquals("For input: " + input, expected, Highlight.initFastDescriptionLength(input));
-        }
-    }
-
-    @Test
-    public void testInitFastDescriptionLengthMissingDescriptionLengthKey() {
-        Assert.assertEquals(0, Highlight.initFastDescriptionLength(
-                "{\"image_url\":\"https:\\/\\/upload.wikimedia.org\\/wikipedia\\/commons\\/f\\/f1\\/Brauysegen_im_Bett.gif\"}"));
-    }
-
-    @Test
-    public void testInitFastDescriptionLengthInvalidValue() {
-        Assert.assertEquals(0, Highlight.initFastDescriptionLength("{\"description_length\":abc}"));
-    }
-
-    @Test
-    public void testInitFastDescriptionLengthNullInput() {
-        Assert.assertEquals(0, Highlight.initFastDescriptionLength(null));
-    }
-}
\ No newline at end of file
--- a/mobile/android/base/java/org/mozilla/gecko/activitystream/Utils.java
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/Utils.java
@@ -1,31 +1,32 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.activitystream;
 
 import android.database.Cursor;
-import org.mozilla.gecko.activitystream.ranking.HighlightCandidateCursorIndices;
+
+import org.mozilla.gecko.db.BrowserContract;
 
 /**
  * Various util methods and constants that are shared by different parts of Activity Stream.
  */
 public class Utils {
     public enum HighlightSource {
         VISITED,
         BOOKMARKED
     }
 
-    public static HighlightSource highlightSource(final Cursor cursor, final HighlightCandidateCursorIndices cursorIndices) {
-        if (-1 != cursor.getLong(cursorIndices.bookmarkIDColumnIndex)) {
+    public static HighlightSource highlightSource(final Cursor cursor) {
+        if (-1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Highlights.BOOKMARK_ID))) {
             return HighlightSource.BOOKMARKED;
         }
 
-        if (-1 != cursor.getLong(cursorIndices.historyIDColumnIndex)) {
+        if (-1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Highlights.HISTORY_ID))) {
             return HighlightSource.VISITED;
         }
 
         throw new IllegalArgumentException("Unknown highlight source.");
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/activitystream/homepanel/HighlightsLoader.java
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/homepanel/HighlightsLoader.java
@@ -4,25 +4,23 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.activitystream.homepanel;
 
 import android.content.Context;
 import android.database.ContentObserver;
 import android.database.Cursor;
 import android.os.SystemClock;
-import android.support.annotation.WorkerThread;
 import android.support.v4.content.AsyncTaskLoader;
 
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.activitystream.ranking.HighlightsRanking;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.activitystream.homepanel.model.Highlight;
-import org.mozilla.gecko.util.ThreadUtils;
 
 import java.util.Collections;
 import java.util.List;
 
 /**
  * Loader implementation for loading a list of ranked highlights from the database.
  */
 /* package-private */ class HighlightsLoader extends AsyncTaskLoader<List<Highlight>> {
@@ -59,38 +57,25 @@ import java.util.List;
             return Collections.emptyList();
         }
 
         try {
             // From now on get notified about content updates and reload data - until loader is reset.
             enableContentUpdates();
 
             final List<Highlight> highlights = HighlightsRanking.rank(candidatesCursor, highlightsLimit);
-            forceLoadHighlightMetadata(highlights); // force load now that we have a short list of the data.
 
             addToPerformanceHistogram(startTime);
 
             return highlights;
         } finally {
             candidatesCursor.close();
         }
     }
 
-    /**
-     * Optimization: we force the lazily-loaded metadata to load while we're running on this background thread
-     * rather than letting it block the UI thread when this data is being used in the UI.
-     */
-    @WorkerThread
-    private static void forceLoadHighlightMetadata(final List<Highlight> highlights) {
-        ThreadUtils.assertNotOnUiThread();
-        for (final Highlight highlight : highlights) {
-            highlight.getMetadataSlow();
-        }
-    }
-
     private void addToPerformanceHistogram(long startTime) {
         final long took = SystemClock.uptimeMillis() - startTime;
 
         Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_ACTIVITY_STREAM_HIGHLIGHTS, (int) Math.min(took, Integer.MAX_VALUE));
     }
 
     @Override
     protected void onReset() {
--- a/mobile/android/base/java/org/mozilla/gecko/activitystream/homepanel/model/Highlight.java
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/homepanel/model/Highlight.java
@@ -2,96 +2,49 @@
  * 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.activitystream.homepanel.model;
 
 import android.database.Cursor;
 import android.support.annotation.Nullable;
-import android.support.annotation.VisibleForTesting;
-import android.text.TextUtils;
 import android.text.format.DateUtils;
+
 import org.mozilla.gecko.activitystream.Utils;
-import org.mozilla.gecko.activitystream.ranking.HighlightCandidateCursorIndices;
-import org.mozilla.gecko.activitystream.ranking.HighlightsRanking;
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
+import org.mozilla.gecko.db.BrowserContract;
 
 public class Highlight implements Item {
-
-    /**
-     * A pattern matching a json object containing the key "image_url" and extracting the value. afaik, these urls
-     * are not encoded so it's entirely possible that the url will contain a quote and we will not extract the whole
-     * url. However, given these are coming from websites providing favicon-like images, it's not likely a quote will
-     * appear and since these urls are only being used to compare against one another (as imageURLs in Highlight items),
-     * a partial URL may actually have the same behavior: good enough for me!
-     */
-    private static final Pattern FAST_IMAGE_URL_PATTERN = Pattern.compile("\"image_url\":\"([^\"]+)\"");
-
-    // A pattern matching a json object containing the key "description_length" and extracting the value: this
-    // regex should perfectly match values in json without whitespace.
-    private static final Pattern FAST_DESCRIPTION_LENGTH_PATTERN = Pattern.compile("\"description_length\":([0-9]+)");
-
     private final String title;
     private final String url;
     private final Utils.HighlightSource source;
     private final long time;
 
     private long historyId;
 
-    private @Nullable Metadata metadata; // lazily-loaded.
-    private @Nullable final String metadataJSON;
-    private @Nullable String fastImageURL;
-    private int fastDescriptionLength;
+    private Metadata metadata;
 
     private @Nullable Boolean isPinned;
     private @Nullable Boolean isBookmarked;
 
-    public static Highlight fromCursor(final Cursor cursor, final HighlightCandidateCursorIndices cursorIndices) {
-        return new Highlight(cursor, cursorIndices);
-    }
-
-    private Highlight(final Cursor cursor, final HighlightCandidateCursorIndices cursorIndices) {
-        title = cursor.getString(cursorIndices.titleColumnIndex);
-        url = cursor.getString(cursorIndices.urlColumnIndex);
-        source = Utils.highlightSource(cursor, cursorIndices);
-        time = cursor.getLong(cursorIndices.highlightsDateColumnIndex);
-
-        historyId = cursor.getLong(cursorIndices.historyIDColumnIndex);
-
-        metadataJSON = cursor.getString(cursorIndices.metadataColumnIndex);
-        fastImageURL = initFastImageURL(metadataJSON);
-        fastDescriptionLength = initFastDescriptionLength(metadataJSON);
-
-        updateState();
+    public static Highlight fromCursor(Cursor cursor) {
+        return new Highlight(cursor);
     }
 
-    /** Gets a fast image URL. Full docs for this method at {@link #getFastImageURLForComparison()} & {@link #FAST_IMAGE_URL_PATTERN}. */
-    @VisibleForTesting static @Nullable String initFastImageURL(final String metadataJSON) {
-        return extractFirstGroupFromMetadataJSON(metadataJSON, FAST_IMAGE_URL_PATTERN);
-    }
+    private Highlight(Cursor cursor) {
+        title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.History.TITLE));
+        url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+        source = Utils.highlightSource(cursor);
+        time = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Highlights.DATE));
 
-    /** Gets a fast description length. Full docs for this method at {@link #getFastDescriptionLength()} & {@link #FAST_DESCRIPTION_LENGTH_PATTERN}. */
-    @VisibleForTesting static int initFastDescriptionLength(final String metadataJSON) {
-        final String extractedStr = extractFirstGroupFromMetadataJSON(metadataJSON, FAST_DESCRIPTION_LENGTH_PATTERN);
-        try {
-            return !TextUtils.isEmpty(extractedStr) ? Integer.parseInt(extractedStr) : 0;
-        } catch (final NumberFormatException e) { /* intentionally blank */ }
-        return 0;
-    }
+        historyId = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Highlights.HISTORY_ID));
 
-    private static @Nullable String extractFirstGroupFromMetadataJSON(final String metadataJSON, final Pattern pattern) {
-        if (metadataJSON == null) {
-            return null;
-        }
+        metadata = Metadata.fromCursor(cursor);
 
-        final Matcher matcher = pattern.matcher(metadataJSON);
-        return matcher.find() ? matcher.group(1) : null;
+        updateState();
     }
 
     private void updateState() {
         // We can only be certain of bookmark state if an item is a bookmark item.
         // Otherwise, due to the underlying highlights query, we have to look up states when
         // menus are displayed.
         switch (source) {
             case BOOKMARKED:
@@ -110,77 +63,20 @@ public class Highlight implements Item {
     public String getTitle() {
         return title;
     }
 
     public String getUrl() {
         return url;
     }
 
-    /**
-     * Retrieves the metadata associated with this highlight, lazily loaded.
-     *
-     * AVOID USING THIS FOR A LARGE NUMBER OF ITEMS, particularly in {@link HighlightsRanking#extractFeatures(Cursor)},
-     * where we added lazy loading to improve performance.
-     *
-     * The JSONObject constructor inside Metadata takes a non-trivial amount of time to run so
-     * we lazily load it. At the time of writing, in {@link HighlightsRanking#extractFeatures(Cursor)}, we get
-     * 500 highlights before curating down to the ~5 shown items. For the non-displayed items, we use
-     * the getFast* methods and, for the shown items, lazy-load the metadata since only then is it necessary.
-     * These methods include:
-     * - {@link #getFastDescriptionLength()}
-     * - {@link #getFastImageURLForComparison()}
-     * - {@link #hasFastImageURL()}
-     */
-    public Metadata getMetadataSlow() {
-        if (metadata == null) {
-            metadata = new Metadata(metadataJSON);
-        }
+    public Metadata getMetadata() {
         return metadata;
     }
 
-    /**
-     * Returns the image url in the highlight's metadata. This value does not provide valid image url but is
-     * consistent across invocations and can be used to compare against other Highlight's fast image urls.
-     * See {@link #getMetadataSlow()} for a description of why we use this method.
-     *
-     * To get a valid image url (at a performance penalty), use {@link #getMetadataSlow()}
-     * {@link #getMetadataSlow()} & {@link Metadata#getImageUrl()}.
-     *
-     * Note that this explanation is dependent on the implementation of {@link #initFastImageURL(String)}.
-     *
-     * @return the image url, or null if one could not be found.
-     */
-    public @Nullable String getFastImageURLForComparison() {
-        return fastImageURL;
-    }
-
-    /**
-     * Returns true if {@link #getFastImageURLForComparison()} has found an image url, false otherwise.
-     * See that method for caveats.
-     */
-    public boolean hasFastImageURL() {
-        return fastImageURL != null;
-    }
-
-    /**
-     * Returns the description length in the highlight's metadata. This value is expected to correct in all cases.
-     * See {@link #getMetadataSlow()} for why we use this method.
-     *
-     * This is a faster version of {@link #getMetadataSlow()} & {@link Metadata#getDescriptionLength()} because
-     * retrieving the metadata in this way does a full json parse, which is slower.
-     *
-     * Note: this explanation is dependent on the implementation of {@link #initFastDescriptionLength(String)}.
-     *
-     * @return the given description length, or 0 if no description length was given
-     */
-    public int getFastDescriptionLength() {
-        return fastDescriptionLength;
-    }
-
     public Boolean isBookmarked() {
         return isBookmarked;
     }
 
     public Boolean isPinned() {
         return isPinned;
     }
 
--- a/mobile/android/base/java/org/mozilla/gecko/activitystream/homepanel/model/Metadata.java
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/homepanel/model/Metadata.java
@@ -1,29 +1,36 @@
 /* -*- 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.activitystream.homepanel.model;
 
-import android.support.annotation.Nullable;
+import android.database.Cursor;
 import android.text.TextUtils;
 import android.util.Log;
+
 import org.json.JSONException;
 import org.json.JSONObject;
+import org.mozilla.gecko.db.BrowserContract;
 
 public class Metadata {
     private static final String LOGTAG = "GeckoMetadata";
 
+    public static Metadata fromCursor(Cursor cursor) {
+        return new Metadata(
+                cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Highlights.METADATA)));
+    }
+
     private String provider;
     private String imageUrl;
     private int descriptionLength;
 
-    /* package-private */ Metadata(String json) {
+    private Metadata(String json) {
         if (TextUtils.isEmpty(json)) {
             // Just use default values. It's better to have an empty Metadata object instead of
             // juggling with null values.
             return;
         }
 
         try {
             JSONObject object = new JSONObject(json);
@@ -37,21 +44,26 @@ public class Metadata {
     }
 
     public boolean hasProvider() {
         return !TextUtils.isEmpty(provider);
     }
 
     /**
      * Returns the URL of an image representing this site. Returns null if no image could be found.
+     * Use hasImageUrl() to avoid dealing with null values.
      */
-    public @Nullable String getImageUrl() {
+    public String getImageUrl() {
         return imageUrl;
     }
 
+    public boolean hasImageUrl() {
+        return imageUrl != null;
+    }
+
     public String getProvider() {
         return provider;
     }
 
     public int getDescriptionLength() {
         return descriptionLength;
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightCandidate.java
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightCandidate.java
@@ -2,162 +2,131 @@
  * 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.activitystream.ranking;
 
 import android.database.Cursor;
 import android.net.Uri;
-import android.support.annotation.IntDef;
 import android.support.annotation.Nullable;
+import android.support.annotation.StringDef;
 import android.support.annotation.VisibleForTesting;
+
+import org.mozilla.gecko.activitystream.ranking.RankingUtils.Func1;
+import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.activitystream.homepanel.model.Highlight;
 
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
+import java.util.HashMap;
+import java.util.Map;
 
 /**
  * A highlight candidate (Highlight object + features). Ranking will determine whether this is an
  * actual highlight.
  */
 /* package-private */ class HighlightCandidate {
-
-    // Features we score over for Highlight results - see Features class for more details & usage.
-    @Retention(RetentionPolicy.SOURCE)
-    @IntDef({FEATURE_AGE_IN_DAYS, FEATURE_BOOKMARK_AGE_IN_MILLISECONDS, FEATURE_DESCRIPTION_LENGTH,
-            FEATURE_DOMAIN_FREQUENCY, FEATURE_IMAGE_COUNT, FEATURE_IMAGE_SIZE, FEATURE_PATH_LENGTH,
-            FEATURE_QUERY_LENGTH, FEATURE_VISITS_COUNT})
-    /* package-private */ @interface FeatureName {}
-
-    // IF YOU ADD A FIELD, INCREMENT `FEATURE_COUNT`! For a perf boost, we use these ints to index into an array and
-    // FEATURE_COUNT tracks the number of features we have and thus how big the array needs to be.
-    private static final int FEATURE_COUNT = 9; // = the-greatest-feature-index + 1.
-    /* package-private */ static final int FEATURE_AGE_IN_DAYS = 0;
-    /* package-private */ static final int FEATURE_BOOKMARK_AGE_IN_MILLISECONDS = 1;
-    /* package-private */ static final int FEATURE_DESCRIPTION_LENGTH = 2;
-    /* package-private */ static final int FEATURE_DOMAIN_FREQUENCY = 3;
-    /* package-private */ static final int FEATURE_IMAGE_COUNT = 4;
-    /* package-private */ static final int FEATURE_IMAGE_SIZE = 5;
-    /* package-private */ static final int FEATURE_PATH_LENGTH = 6;
-    /* package-private */ static final int FEATURE_QUERY_LENGTH = 7;
-    /* package-private */ static final int FEATURE_VISITS_COUNT = 8;
+    /* package-private */ static final String FEATURE_AGE_IN_DAYS = "ageInDays";
+    /* package-private */ static final String FEATURE_IMAGE_COUNT = "imageCount";
+    /* package-private */ static final String FEATURE_DOMAIN_FREQUENCY = "domainFrequency";
+    /* package-private */ static final String FEATURE_VISITS_COUNT = "visitsCount";
+    /* package-private */ static final String FEATURE_BOOKMARK_AGE_IN_MILLISECONDS = "bookmarkageInDays";
+    /* package-private */ static final String FEATURE_DESCRIPTION_LENGTH = "descriptionLength";
+    /* package-private */ static final String FEATURE_PATH_LENGTH = "pathLength";
+    /* package-private */ static final String FEATURE_QUERY_LENGTH = "queryLength";
+    /* package-private */ static final String FEATURE_IMAGE_SIZE = "imageSize";
 
-    /**
-     * A data class for accessing Features values. It acts as a map from FeatureName -> value:
-     * <pre>
-     *   Features features = new Features();
-     *   features.put(FEATURE_AGE_IN_DAYS, 30);
-     *   double value = features.get(FEATURE_AGE_IN_DAYS);
-     * </pre>
-     *
-     * This data is accessed frequently and needs to be performant. As such, the implementation is a little fragile
-     * (e.g. we could increase type safety with enums and index into the backing array with Enum.ordinal(), but it
-     * gets called enough that it's not worth the performance trade-off).
-     */
-    /* package-private */ static class Features {
-        private final double[] values = new double[FEATURE_COUNT];
+    @StringDef({FEATURE_AGE_IN_DAYS, FEATURE_IMAGE_COUNT, FEATURE_DOMAIN_FREQUENCY, FEATURE_VISITS_COUNT,
+            FEATURE_BOOKMARK_AGE_IN_MILLISECONDS, FEATURE_DESCRIPTION_LENGTH, FEATURE_PATH_LENGTH,
+            FEATURE_QUERY_LENGTH, FEATURE_IMAGE_SIZE})
+    public @interface Feature {}
 
-        Features() {}
-
-        /* package-private */ double get(final @FeatureName int featureName) {
-            return values[featureName];
-        }
-
-        /* package-private */ void put(final @FeatureName int featureName, final double value) {
-            values[featureName] = value;
-        }
-    }
-
-    @VisibleForTesting final Features features = new Features();
+    @VisibleForTesting final Map<String, Double> features;
     private Highlight highlight;
     private @Nullable String imageUrl;
     private String host;
     private double score;
 
-    public static HighlightCandidate fromCursor(final Cursor cursor, final HighlightCandidateCursorIndices cursorIndices)
-            throws InvalidHighlightCandidateException {
+    public static HighlightCandidate fromCursor(Cursor cursor) throws InvalidHighlightCandidateException {
         final HighlightCandidate candidate = new HighlightCandidate();
 
-        extractHighlight(candidate, cursor, cursorIndices);
-        extractFeatures(candidate, cursor, cursorIndices);
+        extractHighlight(candidate, cursor);
+        extractFeatures(candidate, cursor);
 
         return candidate;
     }
 
     /**
      * Extract highlight object from cursor.
      */
-    private static void extractHighlight(final HighlightCandidate candidate, final Cursor cursor,
-            final HighlightCandidateCursorIndices cursorIndices) {
-        candidate.highlight = Highlight.fromCursor(cursor, cursorIndices);
+    private static void extractHighlight(HighlightCandidate candidate, Cursor cursor) {
+        candidate.highlight = Highlight.fromCursor(cursor);
     }
 
     /**
      * Extract and assign features that will be used for ranking.
      */
-    private static void extractFeatures(final HighlightCandidate candidate, final Cursor cursor,
-            final HighlightCandidateCursorIndices cursorIndices) throws InvalidHighlightCandidateException {
+    private static void extractFeatures(HighlightCandidate candidate, Cursor cursor) throws InvalidHighlightCandidateException {
         candidate.features.put(
                 FEATURE_AGE_IN_DAYS,
-                (System.currentTimeMillis() - cursor.getDouble(cursorIndices.historyDateLastVisitedColumnIndex))
+                (System.currentTimeMillis() - cursor.getDouble(cursor.getColumnIndexOrThrow(BrowserContract.History.DATE_LAST_VISITED)))
                         / (1000 * 3600 * 24));
 
         candidate.features.put(
                 FEATURE_VISITS_COUNT,
-                cursor.getDouble(cursorIndices.visitsColumnIndex));
+                cursor.getDouble(cursor.getColumnIndexOrThrow(BrowserContract.History.VISITS)));
 
         // Until we can determine those numbers we assume this domain has only been visited once
         // and the cursor returned all database entries.
         // TODO: Calculate values based using domain hash field (bug 1335817)
         final int occurrences = 1; // Number of times host shows up in history (Bug 1319485)
         final int domainCountSize = cursor.getCount(); // Number of domains visited (Bug 1319485)
 
         candidate.features.put(
                 FEATURE_DOMAIN_FREQUENCY,
                 Math.log(1 + domainCountSize / occurrences));
 
-        candidate.imageUrl = candidate.highlight.getFastImageURLForComparison();
+        candidate.imageUrl = candidate.highlight.getMetadata().getImageUrl();
 
         // The desktop add-on used the number of images returned form Embed.ly here. This is not the
         // same as total images on the page (think of small icons or the famous spacer.gif). So for
         // now this value will only be 1 or 0 depending on whether we found a good image. The desktop
         // team will face the same issue when switching from Embed.ly to the metadata-page-parser.
         // At this point we can try to find a fathom rule for determining a good value here.
         candidate.features.put(
                 FEATURE_IMAGE_COUNT,
-                candidate.highlight.hasFastImageURL() ? 1d : 0d);
+                candidate.highlight.getMetadata().hasImageUrl() ? 1d : 0d);
 
         // TODO: We do not store the size of the main image (Bug 1335819).
         // The desktop add-on calculates: Math.min(image.width * image.height, 1e5)
         candidate.features.put(
                 FEATURE_IMAGE_SIZE,
-                candidate.highlight.hasFastImageURL() ? 1d : 0d
+                candidate.highlight.getMetadata().hasImageUrl() ? 1d : 0d
         );
 
         // Historical note: before Bug 1335198, this value was not really the time at which the
         // bookmark was created by the user. Especially synchronized bookmarks could have a recent
         // value but have been bookmarked a long time ago.
         // Current behaviour: synchronized clients will, over time, converge DATE_CREATED field
         // to the real creation date, or the earliest one mentioned in the clients constellation.
         // We are sourcing highlights from the recent visited history - so in order to
         // show up this bookmark need to have been visited recently too.
-        if (cursor.isNull(cursorIndices.bookmarkDateCreatedColumnIndex)) {
+        final int bookmarkDateColumnIndex = cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.DATE_CREATED);
+        if (cursor.isNull(bookmarkDateColumnIndex)) {
             candidate.features.put(
                     FEATURE_BOOKMARK_AGE_IN_MILLISECONDS,
                     0d);
         } else {
             candidate.features.put(
                     FEATURE_BOOKMARK_AGE_IN_MILLISECONDS,
-                    Math.max(1, System.currentTimeMillis() - cursor.getDouble(cursorIndices.bookmarkDateCreatedColumnIndex)));
+                    Math.max(1, System.currentTimeMillis() - cursor.getDouble(bookmarkDateColumnIndex)));
         }
 
         candidate.features.put(
                 FEATURE_DESCRIPTION_LENGTH,
-                (double) candidate.highlight.getFastDescriptionLength());
+                (double) candidate.highlight.getMetadata().getDescriptionLength());
 
         final Uri uri = Uri.parse(candidate.highlight.getUrl());
 
         // We don't support opaque URIs (such as mailto:...), or URIs which do not have a valid host.
         // The latter might simply be URIs with invalid characters in them (such as underscore...).
         // See Bug 1363521 and Bug 1378967.
         if (!uri.isHierarchical() || uri.getHost() == null) {
             throw new InvalidHighlightCandidateException();
@@ -171,16 +140,17 @@ import java.lang.annotation.RetentionPol
 
         // Only hierarchical URIs support getQueryParameterNames.
         candidate.features.put(
                 FEATURE_QUERY_LENGTH,
                 (double) uri.getQueryParameterNames().size());
     }
 
     @VisibleForTesting HighlightCandidate() {
+        features = new HashMap<>();
     }
 
     /* package-private */ double getScore() {
         return score;
     }
 
     /* package-private */ void updateScore(double score) {
         this.score = score;
@@ -189,25 +159,45 @@ import java.lang.annotation.RetentionPol
     /* package-private */ String getUrl() {
         return highlight.getUrl();
     }
 
     /* package-private */ String getHost() {
         return host;
     }
 
-    /**
-     * Gets an estimate of the actual image url that should only be used to compare against other return
-     * values of this method. See {@link Highlight#getFastImageURLForComparison()} for more details.
-     */
     @Nullable
-    /* package-private */ String getFastImageUrlForComparison() {
+    /* package-private */ String getImageUrl() {
         return imageUrl;
     }
 
     /* package-private */ Highlight getHighlight() {
         return highlight;
     }
 
+    /* package-private */ double getFeatureValue(@Feature String feature) {
+        if (!features.containsKey(feature)) {
+            throw new IllegalStateException("No value for feature " + feature);
+        }
+
+        return features.get(feature);
+    }
+
+    /* package-private */ void setFeatureValue(@Feature String feature, double value) {
+        features.put(feature, value);
+    }
+
+    /* package-private */ Map<String, Double> getFilteredFeatures(Func1<String, Boolean> filter) {
+        Map<String, Double> filteredFeatures = new HashMap<>();
+
+        for (Map.Entry<String, Double> entry : features.entrySet()) {
+            if (filter.call(entry.getKey())) {
+                filteredFeatures.put(entry.getKey(), entry.getValue());
+            }
+        }
+
+        return filteredFeatures;
+    }
+
     /* package-private */ static class InvalidHighlightCandidateException extends Exception {
         private static final long serialVersionUID = 949263104621445850L;
     }
 }
deleted file mode 100644
--- a/mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightCandidateCursorIndices.java
+++ /dev/null
@@ -1,45 +0,0 @@
-/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-package org.mozilla.gecko.activitystream.ranking;
-
-import android.database.Cursor;
-import org.mozilla.gecko.db.BrowserContract;
-
-/**
- * A cache of the column indices of the given Cursor.
- *
- * We use this for performance reasons: {@link Cursor#getColumnIndexOrThrow(String)} will do a HashMap look-up and
- * String comparison each time it's called, which gets expensive while iterating through HighlightCandidate results
- * (currently a maximum of 500 items), so we cache the values.
- */
-public class HighlightCandidateCursorIndices {
-
-    public final int titleColumnIndex;
-    public final int urlColumnIndex;
-    public final int visitsColumnIndex;
-    public final int metadataColumnIndex;
-
-    public final int highlightsDateColumnIndex;
-    public final int bookmarkDateCreatedColumnIndex;
-    public final int historyDateLastVisitedColumnIndex;
-
-    public final int historyIDColumnIndex;
-    public final int bookmarkIDColumnIndex;
-
-    HighlightCandidateCursorIndices(final Cursor cursor) {
-        titleColumnIndex = cursor.getColumnIndexOrThrow(BrowserContract.History.TITLE);
-        urlColumnIndex = cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL);
-        visitsColumnIndex = cursor.getColumnIndexOrThrow(BrowserContract.History.VISITS);
-        metadataColumnIndex = cursor.getColumnIndexOrThrow(BrowserContract.Highlights.METADATA);
-
-        highlightsDateColumnIndex = cursor.getColumnIndexOrThrow(BrowserContract.Highlights.DATE);
-        bookmarkDateCreatedColumnIndex = cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.DATE_CREATED);
-        historyDateLastVisitedColumnIndex = cursor.getColumnIndexOrThrow(BrowserContract.History.DATE_LAST_VISITED);
-
-        historyIDColumnIndex = cursor.getColumnIndexOrThrow(BrowserContract.Highlights.HISTORY_ID);
-        bookmarkIDColumnIndex = cursor.getColumnIndexOrThrow(BrowserContract.Highlights.BOOKMARK_ID);
-    }
-}
--- a/mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightsRanking.java
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightsRanking.java
@@ -3,101 +3,79 @@
  * 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.activitystream.ranking;
 
 import android.database.Cursor;
 import android.support.annotation.VisibleForTesting;
 import android.util.Log;
-import android.util.SparseArray;
+
 import org.mozilla.gecko.activitystream.homepanel.model.Highlight;
 
+import java.util.Arrays;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 import static java.util.Collections.sort;
-import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_AGE_IN_DAYS;
-import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_BOOKMARK_AGE_IN_MILLISECONDS;
-import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_DESCRIPTION_LENGTH;
-import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_DOMAIN_FREQUENCY;
-import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_IMAGE_COUNT;
-import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_IMAGE_SIZE;
-import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_PATH_LENGTH;
-import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_QUERY_LENGTH;
-import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_VISITS_COUNT;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Action1;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Action2;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Func1;
+import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Func2;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.apply;
+import static org.mozilla.gecko.activitystream.ranking.RankingUtils.apply2D;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.applyInPairs;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.filter;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.looselyMapCursor;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.mapWithLimit;
+import static org.mozilla.gecko.activitystream.ranking.RankingUtils.reduce;
 
 /**
  * HighlightsRanking.rank() takes a Cursor of highlight candidates and applies ranking to find a set
  * of good highlights. The result set is likely smaller than the cursor size.
  *
  * - First we calculate an initial score based on how frequent we visit the URL and domain.
  * - Then we multiply some (normalized) feature values with weights to calculate:
  *      initialScore * e ^ -(sum of weighted features)
  * - Finally we adjust the score with some custom rules.
  */
 public class HighlightsRanking {
     private static final String LOG_TAG = "HighlightsRanking";
 
-    /** An array of all the features that are weighted while scoring. */
-    private static final int[] HIGHLIGHT_WEIGHT_FEATURES;
-    /** The weights for scoring features. */
-    private static final HighlightCandidate.Features HIGHLIGHT_WEIGHTS = new HighlightCandidate.Features();
+    private static final Map<String, Double> HIGHLIGHT_WEIGHTS = new HashMap<>();
     static {
-        // In initialization, we put all data into a single data structure so we don't have to repeat
-        // ourselves: this data structure is copied into two other data structures upon completion.
-        //
-        // To add a weight, just add it to tmpWeights as seen below.
-        final SparseArray<Double> tmpWeights = new SparseArray<>();
-        tmpWeights.put(FEATURE_VISITS_COUNT, -0.1);
-        tmpWeights.put(FEATURE_DESCRIPTION_LENGTH, -0.1);
-        tmpWeights.put(FEATURE_PATH_LENGTH, -0.1);
+        // TODO: Needs confirmation from the desktop team that this is the correct weight mapping (Bug 1336037)
+        HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_VISITS_COUNT, -0.1);
+        HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH, -0.1);
+        HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_PATH_LENGTH, -0.1);
 
-        tmpWeights.put(FEATURE_QUERY_LENGTH, 0.4);
-        tmpWeights.put(FEATURE_IMAGE_SIZE, 0.2);
-
-        HIGHLIGHT_WEIGHT_FEATURES = new int[tmpWeights.size()];
-        for (int i = 0; i < tmpWeights.size(); ++i) {
-            final @HighlightCandidate.FeatureName int featureName = tmpWeights.keyAt(i);
-            final Double featureWeight = tmpWeights.get(featureName);
-
-            HIGHLIGHT_WEIGHTS.put(featureName, featureWeight);
-            HIGHLIGHT_WEIGHT_FEATURES[i] = featureName;
-        }
+        HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_QUERY_LENGTH, 0.4);
+        HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_IMAGE_SIZE, 0.2);
     }
 
-    /**
-     * An array of all the features we want to normalize.
-     *
-     * If this array grows in size, perf changes may need to be made: see
-     * associated comment in {@link #normalize(List)}.
-     */
-    private static final int[] NORMALIZATION_FEATURES = new int[] {
-            FEATURE_DESCRIPTION_LENGTH,
-            FEATURE_PATH_LENGTH,
-            FEATURE_IMAGE_SIZE,
-    };
+    private static final List<String> NORMALIZATION_FEATURES = Arrays.asList(
+            HighlightCandidate.FEATURE_DESCRIPTION_LENGTH,
+            HighlightCandidate.FEATURE_PATH_LENGTH,
+            HighlightCandidate.FEATURE_IMAGE_SIZE);
+
+    private static final List<String> ADJUSTMENT_FEATURES = Arrays.asList(
+            HighlightCandidate.FEATURE_BOOKMARK_AGE_IN_MILLISECONDS,
+            HighlightCandidate.FEATURE_IMAGE_COUNT,
+            HighlightCandidate.FEATURE_AGE_IN_DAYS,
+            HighlightCandidate.FEATURE_DOMAIN_FREQUENCY
+    );
 
     private static final double BOOKMARK_AGE_DIVIDEND = 3 * 24 * 60 * 60 * 1000;
 
     /**
      * Create a list of highlights based on the candidates provided by the input cursor.
-     *
-     * THIS METHOD IS CRITICAL FOR HIGHLIGHTS PERFORMANCE AND HAS BEEN OPTIMIZED (bug 1369604):
-     * please be careful what you add to it!
      */
     public static List<Highlight> rank(Cursor cursor, int limit) {
         List<HighlightCandidate> highlights = extractFeatures(cursor);
 
         normalize(highlights);
 
         scoreEntries(highlights);
 
@@ -113,71 +91,87 @@ public class HighlightsRanking {
 
         return createHighlightsList(highlights, limit);
     }
 
     /**
      * Extract features for every candidate. The heavy lifting is done in
      * HighlightCandidate.fromCursor().
      */
-    @VisibleForTesting static List<HighlightCandidate> extractFeatures(final Cursor cursor) {
-        // Cache column indices for performance: see class Javadoc for more info.
-        final HighlightCandidateCursorIndices cursorIndices = new HighlightCandidateCursorIndices(cursor);
+    @VisibleForTesting static List<HighlightCandidate> extractFeatures(Cursor cursor) {
         return looselyMapCursor(cursor, new Func1<Cursor, HighlightCandidate>() {
             @Override
             public HighlightCandidate call(Cursor cursor) {
                 try {
-                    return HighlightCandidate.fromCursor(cursor, cursorIndices);
+                    return HighlightCandidate.fromCursor(cursor);
                 } catch (HighlightCandidate.InvalidHighlightCandidateException e) {
                     Log.w(LOG_TAG, "Skipping invalid highlight item", e);
                     return null;
                 }
             }
         });
     }
 
     /**
      * Normalize the values of all features listed in NORMALIZATION_FEATURES. Normalization will map
      * the values into the interval of [0,1] based on the min/max values for the features.
      */
     @VisibleForTesting static void normalize(List<HighlightCandidate> candidates) {
-        for (final int feature : NORMALIZATION_FEATURES) {
-            double minForFeature = Double.MAX_VALUE;
-            double maxForFeature = Double.MIN_VALUE;
+        final HashMap<String, double[]> minMaxValues = new HashMap<>(); // 0 = min, 1 = max
+
+        // First update the min/max values for all features
+        apply2D(candidates, NORMALIZATION_FEATURES, new Action2<HighlightCandidate, String>() {
+            @Override
+            public void call(HighlightCandidate candidate, String feature) {
+                double[] minMaxForFeature = minMaxValues.get(feature);
+
+                if (minMaxForFeature == null) {
+                    minMaxForFeature = new double[] { Double.MAX_VALUE, Double.MIN_VALUE };
+                    minMaxValues.put(feature, minMaxForFeature);
+                }
 
-            // The foreach loop creates an Iterator inside an inner loop which is generally bad for GC.
-            // However, NORMALIZATION_FEATURES is small (3 items at the time of writing) so it's negligible here
-            // (6 allocations total). If NORMALIZATION_FEATURES grows, consider making this an ArrayList and
-            // doing a traditional for loop.
-            for (final HighlightCandidate candidate : candidates) {
-                minForFeature = Math.min(minForFeature, candidate.features.get(feature));
-                maxForFeature = Math.max(maxForFeature, candidate.features.get(feature));
+                minMaxForFeature[0] = Math.min(minMaxForFeature[0], candidate.getFeatureValue(feature));
+                minMaxForFeature[1] = Math.max(minMaxForFeature[1], candidate.getFeatureValue(feature));
             }
+        });
 
-            for (final HighlightCandidate candidate : candidates) {
-                final double value = candidate.features.get(feature);
-                candidate.features.put(feature, RankingUtils.normalize(value, minForFeature, maxForFeature));
+        // Then normalizeFeatureValue the features with the min max values into (0, 1) range.
+        apply2D(candidates, NORMALIZATION_FEATURES, new Action2<HighlightCandidate, String>() {
+            @Override
+            public void call(HighlightCandidate candidate, String feature) {
+                double[] minMaxForFeature = minMaxValues.get(feature);
+                double value = candidate.getFeatureValue(feature);
+
+                candidate.setFeatureValue(feature,
+                        RankingUtils.normalize(value, minMaxForFeature[0], minMaxForFeature[1]));
             }
-        }
+        });
     }
 
     /**
      * Calculate the score for every highlight candidate.
      */
     @VisibleForTesting static void scoreEntries(List<HighlightCandidate> highlights) {
         apply(highlights, new Action1<HighlightCandidate>() {
             @Override
             public void call(HighlightCandidate candidate) {
+                final Map<String, Double> featuresForWeighting = candidate.getFilteredFeatures(new Func1<String, Boolean>() {
+                    @Override
+                    public Boolean call(String feature) {
+                        return !ADJUSTMENT_FEATURES.contains(feature);
+                    }
+                });
+
                 // Initial score based on frequency.
-                final double initialScore = candidate.features.get(FEATURE_VISITS_COUNT) *
-                        candidate.features.get(FEATURE_DOMAIN_FREQUENCY);
+                final double initialScore = candidate.getFeatureValue(HighlightCandidate.FEATURE_VISITS_COUNT)
+                        * candidate.getFeatureValue(HighlightCandidate.FEATURE_DOMAIN_FREQUENCY);
 
                 // First multiply some features with weights (decay) then adjust score with manual rules
                 final double score = adjustScore(
-                        decay(initialScore, candidate.features, HIGHLIGHT_WEIGHTS),
+                        decay(initialScore, featuresForWeighting, HIGHLIGHT_WEIGHTS),
                         candidate);
 
                 candidate.updateScore(score);
             }
         });
     }
 
     /**
@@ -218,21 +212,21 @@ public class HighlightsRanking {
             return;
         }
 
         final double[] penalty = new double[] { 0.8 };
 
         applyInPairs(candidates, new Action2<HighlightCandidate, HighlightCandidate>() {
             @Override
             public void call(HighlightCandidate previous, HighlightCandidate next) {
-                boolean hasImage = previous.features.get(FEATURE_IMAGE_COUNT) > 0
-                        && next.features.get(FEATURE_IMAGE_COUNT) > 0;
+                boolean hasImage = previous.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_COUNT) > 0
+                        && next.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_COUNT) > 0;
 
                 boolean similar = previous.getHost().equals(next.getHost());
-                similar |= hasImage && next.getFastImageUrlForComparison().equals(previous.getFastImageUrlForComparison());
+                similar |= hasImage && next.getImageUrl().equals(previous.getImageUrl());
 
                 if (similar) {
                     next.updateScore(next.getScore() * penalty[0]);
                     penalty[0] -= 0.2;
                 } else {
                     penalty[0] = 0.8;
                 }
             }
@@ -260,49 +254,55 @@ public class HighlightsRanking {
         return mapWithLimit(candidates, new Func1<HighlightCandidate, Highlight>() {
             @Override
             public Highlight call(HighlightCandidate candidate) {
                 return candidate.getHighlight();
             }
         }, limit);
     }
 
-    private static double decay(double initialScore, HighlightCandidate.Features features, final HighlightCandidate.Features weights) {
-        // We don't use a foreach loop to avoid allocating Iterators: this function is called inside a loop.
-        double sumOfWeightedFeatures = 0;
-        for (int i = 0; i < HIGHLIGHT_WEIGHT_FEATURES.length; i++) {
-            final @HighlightCandidate.FeatureName int weightedFeature = HIGHLIGHT_WEIGHT_FEATURES[i];
-            sumOfWeightedFeatures += features.get(weightedFeature) + weights.get(weightedFeature);
+    private static double decay(double initialScore, Map<String, Double> features, final Map<String, Double> weights) {
+        if (features.size() != weights.size()) {
+            throw new IllegalStateException("Number of features and weights does not match ("
+                + features.size() + " != " + weights.size());
         }
+
+        double sumOfWeightedFeatures = reduce(features.entrySet(), new Func2<Map.Entry<String, Double>, Double, Double>() {
+            @Override
+            public Double call(Map.Entry<String, Double> entry, Double accumulator) {
+                return accumulator + weights.get(entry.getKey()) * entry.getValue();
+            }
+        }, 0d);
+
         return initialScore * Math.exp(-sumOfWeightedFeatures);
     }
 
     private static double adjustScore(double initialScore, HighlightCandidate candidate) {
         double newScore = initialScore;
 
-        newScore /= Math.pow(1 + candidate.features.get(FEATURE_AGE_IN_DAYS), 2);
+        newScore /= Math.pow(1 + candidate.getFeatureValue(HighlightCandidate.FEATURE_AGE_IN_DAYS), 2);
 
         // The desktop add-on is downgrading every item without images to a score of 0 here. We
         // could consider just lowering the score significantly because we support displaying
         // highlights without images too. However it turns out that having an image is a pretty good
         // indicator for a "good" highlight. So completely ignoring items without images is a good
         // strategy for now.
-        if (candidate.features.get(FEATURE_IMAGE_COUNT) == 0) {
+        if (candidate.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_COUNT) == 0) {
             newScore = 0;
         }
 
-        if (candidate.features.get(FEATURE_PATH_LENGTH) == 0
-                || candidate.features.get(FEATURE_DESCRIPTION_LENGTH) == 0) {
+        if (candidate.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH) == 0
+                || candidate.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH) == 0) {
             newScore *= 0.2;
         }
 
         // TODO: Consider adding a penalty for items without an icon or with a low quality icon (Bug 1335824).
 
         // Boost bookmarks even if they have low score or no images giving a just-bookmarked page
         // a near-infinite boost.
-        final double bookmarkAge = candidate.features.get(FEATURE_BOOKMARK_AGE_IN_MILLISECONDS);
+        double bookmarkAge = candidate.getFeatureValue(HighlightCandidate.FEATURE_BOOKMARK_AGE_IN_MILLISECONDS);
         if (bookmarkAge > 0) {
             newScore += BOOKMARK_AGE_DIVIDEND / bookmarkAge;
         }
 
         return newScore;
     }
 }
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/activitystream/ranking/TestHighlightsRanking.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/activitystream/ranking/TestHighlightsRanking.java
@@ -21,33 +21,33 @@ public class TestHighlightsRanking {
         final HighlightCandidate candidate3 = createCandidateWithNormalizationFeatures(15d, 75d, 10000d);
         final HighlightCandidate candidate4 = createCandidateWithNormalizationFeatures(75d, 100d, 250d);
         final HighlightCandidate candidate5 = createCandidateWithNormalizationFeatures(115d, 20d, 2000d);
 
         List<HighlightCandidate> candidates = Arrays.asList(candidate1, candidate2, candidate3, candidate4, candidate5);
 
         HighlightsRanking.normalize(candidates);
 
-        Assert.assertEquals(0.15, candidate1.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
-        Assert.assertEquals(0.35, candidate2.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
-        Assert.assertEquals(0, candidate3.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
-        Assert.assertEquals(0.6, candidate4.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
-        Assert.assertEquals(1.0, candidate5.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
+        Assert.assertEquals(0.15, candidate1.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
+        Assert.assertEquals(0.35, candidate2.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
+        Assert.assertEquals(0, candidate3.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
+        Assert.assertEquals(0.6, candidate4.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
+        Assert.assertEquals(1.0, candidate5.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
 
-        Assert.assertEquals(0, candidate1.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
-        Assert.assertEquals(0.1, candidate2.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
-        Assert.assertEquals(0.75, candidate3.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
-        Assert.assertEquals(1, candidate4.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
-        Assert.assertEquals(0.2, candidate5.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
+        Assert.assertEquals(0, candidate1.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
+        Assert.assertEquals(0.1, candidate2.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
+        Assert.assertEquals(0.75, candidate3.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
+        Assert.assertEquals(1, candidate4.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
+        Assert.assertEquals(0.2, candidate5.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
 
-        Assert.assertEquals(0.01, candidate1.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
-        Assert.assertEquals(0, candidate2.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
-        Assert.assertEquals(1.0, candidate3.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
-        Assert.assertEquals(0.025, candidate4.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
-        Assert.assertEquals(0.2, candidate5.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
+        Assert.assertEquals(0.01, candidate1.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
+        Assert.assertEquals(0, candidate2.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
+        Assert.assertEquals(1.0, candidate3.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
+        Assert.assertEquals(0.025, candidate4.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
+        Assert.assertEquals(0.2, candidate5.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
     }
 
     @Test
     public void testSortingByScore() {
         List<HighlightCandidate> candidates = Arrays.asList(
                 createCandidateWithScore(1000),
                 createCandidateWithScore(-10),
                 createCandidateWithScore(0),