Bug 1232706 - Promote "Add to home screen" for frequently visited websites. r?margaret draft
authorSebastian Kaspari <s.kaspari@gmail.com>
Wed, 06 Apr 2016 09:41:34 +0200
changeset 349822 bb0ad1d81a4aee9c547120bbb95b7bed16598ad3
parent 349818 adbe62e4a9748eb9570e9bf72eaf7bd19af4da2b
child 518195 63794545f9eacde56a98d7ea42c93a8aeba6c595
push id15188
push users.kaspari@gmail.com
push dateTue, 12 Apr 2016 11:04:08 +0000
reviewersmargaret
bugs1232706
milestone48.0a1
Bug 1232706 - Promote "Add to home screen" for frequently visited websites. r?margaret MozReview-Commit-ID: 2OW7GKxuQmr
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java
mobile/android/base/java/org/mozilla/gecko/db/StubBrowserDB.java
mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java
mobile/android/base/java/org/mozilla/gecko/favicons/Favicons.java
mobile/android/base/java/org/mozilla/gecko/promotion/AddToHomeScreenPromotion.java
mobile/android/base/java/org/mozilla/gecko/promotion/HomeScreenPrompt.java
mobile/android/base/java/org/mozilla/gecko/util/Experiments.java
mobile/android/base/locales/en-US/android_strings.dtd
mobile/android/base/moz.build
mobile/android/base/strings.xml.in
mobile/android/services/src/main/res/layout/homescreen_prompt.xml
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -154,16 +154,20 @@
         </activity-alias>
 
         <service android:name="org.mozilla.gecko.GeckoService" />
 
         <activity android:name="org.mozilla.gecko.trackingprotection.TrackingProtectionPrompt"
                   android:launchMode="singleTop"
                   android:theme="@style/OverlayActivity" />
 
+        <activity android:name="org.mozilla.gecko.promotion.HomeScreenPrompt"
+                  android:launchMode="singleTop"
+                  android:theme="@style/OverlayActivity" />
+
         <!-- The main reason for the Tab Queue build flag is to not mess with the VIEW intent filter
              before the rest of the plumbing is in place -->
 
         <service android:name="org.mozilla.gecko.tabqueue.TabQueueService" />
 
         <activity android:name="org.mozilla.gecko.tabqueue.TabQueuePrompt"
                   android:launchMode="singleTop"
                   android:theme="@style/OverlayActivity" />
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -50,16 +50,17 @@ import org.mozilla.gecko.javaaddons.Java
 import org.mozilla.gecko.menu.GeckoMenu;
 import org.mozilla.gecko.menu.GeckoMenuItem;
 import org.mozilla.gecko.mozglue.ContextUtils;
 import org.mozilla.gecko.mozglue.ContextUtils.SafeIntent;
 import org.mozilla.gecko.overlays.ui.ShareDialog;
 import org.mozilla.gecko.permissions.Permissions;
 import org.mozilla.gecko.preferences.ClearOnShutdownPref;
 import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.promotion.AddToHomeScreenPromotion;
 import org.mozilla.gecko.prompts.Prompt;
 import org.mozilla.gecko.prompts.PromptListItem;
 import org.mozilla.gecko.reader.SavedReaderViewHelper;
 import org.mozilla.gecko.reader.ReaderModeUtils;
 import org.mozilla.gecko.reader.ReadingListHelper;
 import org.mozilla.gecko.restrictions.Restrictable;
 import org.mozilla.gecko.restrictions.RestrictedProfileConfiguration;
 import org.mozilla.gecko.restrictions.Restrictions;
@@ -225,16 +226,17 @@ public class BrowserApp extends GeckoApp
     private ToolbarProgressView mProgressView;
     private FirstrunAnimationContainer mFirstrunAnimationContainer;
     private HomePager mHomePager;
     private TabsPanel mTabsPanel;
     private ViewGroup mHomePagerContainer;
     private ActionModeCompat mActionMode;
     private TabHistoryController tabHistoryController;
     private ZoomedView mZoomedView;
+    private AddToHomeScreenPromotion mAddToHomeScreenPromotion;
 
     private static final int GECKO_TOOLS_MENU = -1;
     private static final int ADDON_MENU_OFFSET = 1000;
     public static final String TAB_HISTORY_FRAGMENT_TAG = "tabHistoryFragment";
 
     private static class MenuItemInfo {
         public int id;
         public String label;
@@ -774,16 +776,18 @@ public class BrowserApp extends GeckoApp
                        .andFallback(new Runnable() {
                            @Override
                            public void run() {
                                showUpdaterPermissionSnackbar();
                            }
                        })
                       .run();
         }
+
+        mAddToHomeScreenPromotion = new AddToHomeScreenPromotion(this);
     }
 
     /**
      * Initializes the default Switchboard URLs the first time.
      * @param intent
      */
     private void initSwitchboard(Intent intent) {
         if (Experiments.isDisabled(new SafeIntent(intent)) || !AppConstants.MOZ_SWITCHBOARD) {
@@ -1011,30 +1015,34 @@ public class BrowserApp extends GeckoApp
         }
 
         EventDispatcher.getInstance().unregisterGeckoThreadListener((GeckoEventListener) this,
             "Prompt:ShowTop");
 
         processTabQueue();
 
         mScreenshotObserver.start();
+
+        mAddToHomeScreenPromotion.resume();
     }
 
     @Override
     public void onPause() {
         super.onPause();
 
         // Needed for Adjust to get accurate session measurements
         AdjustConstants.getAdjustHelper().onPause();
 
         // Register for Prompt:ShowTop so we can foreground this activity even if it's hidden.
         EventDispatcher.getInstance().registerGeckoThreadListener((GeckoEventListener) this,
             "Prompt:ShowTop");
 
         mScreenshotObserver.stop();
+
+        mAddToHomeScreenPromotion.pause();
     }
 
     @Override
     public void onStart() {
         super.onStart();
 
         // Queue this work so that the first launch of the activity doesn't
         // trigger profile init too early.
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
@@ -4,16 +4,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko;
 
 import org.mozilla.gecko.AppConstants.Versions;
 import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.db.URLMetadataTable;
+import org.mozilla.gecko.db.UrlAnnotations;
 import org.mozilla.gecko.favicons.Favicons;
 import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.gfx.FullScreenState;
 import org.mozilla.gecko.gfx.Layer;
 import org.mozilla.gecko.gfx.LayerView;
 import org.mozilla.gecko.gfx.PluginLayer;
 import org.mozilla.gecko.health.HealthRecorder;
@@ -1802,48 +1803,23 @@ public abstract class GeckoApp
 
     @Override
     public String getDefaultUAString() {
         return HardwareUtils.isTablet() ? AppConstants.USER_AGENT_FENNEC_TABLET :
                                           AppConstants.USER_AGENT_FENNEC_MOBILE;
     }
 
     @Override
-    public void createShortcut(final String title, final String URI) {
-        ThreadUtils.assertOnBackgroundThread();
-        final BrowserDB db = GeckoProfile.get(getApplicationContext()).getDB();
-
-        final ContentResolver cr = getContext().getContentResolver();
-        final Map<String, Map<String, Object>> metadata = db.getURLMetadata().getForURLs(cr,
-                Collections.singletonList(URI),
-                Collections.singletonList(URLMetadataTable.TOUCH_ICON_COLUMN)
-        );
-
-        final Map<String, Object> row = metadata.get(URI);
-
-        String touchIconURL = null;
-
-        if (row != null) {
-            touchIconURL = (String) row.get(URLMetadataTable.TOUCH_ICON_COLUMN);
-        }
-
-        OnFaviconLoadedListener listener = new OnFaviconLoadedListener() {
+    public void createShortcut(final String title, final String url) {
+        Favicons.getPreferredIconForHomeScreenShortcut(this, url, new OnFaviconLoadedListener() {
             @Override
             public void onFaviconLoaded(String url, String faviconURL, Bitmap favicon) {
                 doCreateShortcut(title, url, favicon);
             }
-        };
-
-        // Retrieve the icon while bypassing the cache. Homescreen icon creation is a one-off event, hence it isn't
-        // useful to cache these icons. (Android takes care of storing homescreen icons after a shortcut
-        // has been created.)
-        // The cache is also (currently) limited to 32dp, hence we explicitly need to avoid accessing those icons.
-        // If touchIconURL is null, then Favicons falls back to finding the best possible favicon for
-        // the site URI, hence we can use this call even when there is no touchIcon defined.
-        Favicons.getPreferredSizeFaviconForPage(getApplicationContext(), URI, touchIconURL, listener);
+        });
     }
 
     private void doCreateShortcut(final String aTitle, final String aURI, final Bitmap aIcon) {
         // The intent to be launched by the shortcut.
         Intent shortcutIntent = new Intent();
         shortcutIntent.setAction(GeckoApp.ACTION_HOMESCREEN_SHORTCUT);
         shortcutIntent.setData(Uri.parse(aURI));
         shortcutIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME,
@@ -1859,16 +1835,20 @@ public abstract class GeckoApp
             intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, aURI);
         }
 
         // Do not allow duplicate items.
         intent.putExtra("duplicate", false);
 
         intent.setAction("com.android.launcher.action.INSTALL_SHORTCUT");
         getApplicationContext().sendBroadcast(intent);
+
+        // Remember interaction
+        final UrlAnnotations urlAnnotations = GeckoProfile.get(getApplicationContext()).getDB().getUrlAnnotations();
+        urlAnnotations.insertHomeScreenShortcut(getContentResolver(), aURI, true);
     }
 
     private void processAlertCallback(SafeIntent intent) {
         String alertName = "";
         String alertCookie = "";
         Uri data = intent.getData();
         if (data != null) {
             alertName = data.getQueryParameter("name");
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
@@ -544,17 +544,26 @@ public class BrowserContract {
             FEED_SUBSCRIPTION("feed_subscription"),
 
             /**
              * Indicates that this URL (if stored as a bookmark) should be opened into reader view.
              *
              * Key:   reader_view
              * Value: String "true" to indicate that we would like to open into reader view.
              */
-            READER_VIEW("reader_view");
+            READER_VIEW("reader_view"),
+
+            /**
+             * Indicator that the user interacted with the URL in regards to home screen shortcuts.
+             *
+             * Key:   home_screen_shortcut
+             * Value: True: User created an home screen shortcut for this URL
+             *        False: User declined to create a shortcut for this URL
+             */
+            HOME_SCREEN_SHORTCUT("home_screen_shortcut");
 
             private final String dbValue;
 
             Key(final String dbValue) { this.dbValue = dbValue; }
             public String getDbValue() { return dbValue; }
         }
 
         public enum SyncStatus {
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
@@ -88,16 +88,18 @@ public interface BrowserDB {
      */
     public abstract Cursor getAllVisitedHistory(ContentResolver cr);
 
     /**
      * Can return <code>null</code>.
      */
     public abstract Cursor getRecentHistory(ContentResolver cr, int limit);
 
+    public abstract Cursor getHistoryForURL(ContentResolver cr, String uri);
+
     public abstract Cursor getRecentHistoryBetweenTime(ContentResolver cr, int historyLimit, long start, long end);
 
     public abstract long getPrePathLastVisitedTimeMilliseconds(ContentResolver cr, String prePath);
 
     public abstract void expireHistory(ContentResolver cr, ExpirePriority priority);
 
     public abstract void removeHistoryEntry(ContentResolver cr, String url);
 
--- a/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
@@ -707,16 +707,28 @@ public class LocalBrowserDB implements B
                         Combined.TITLE,
                         Combined.DATE_LAST_VISITED,
                         Combined.VISITS },
                 History.DATE_LAST_VISITED + " >= " + start + " AND " + History.DATE_LAST_VISITED + " < " + end,
                 null,
                 History.DATE_LAST_VISITED + " DESC");
     }
 
+    public Cursor getHistoryForURL(ContentResolver cr, String uri) {
+        return cr.query(mHistoryUriWithProfile,
+                new String[] {
+                    History.VISITS,
+                    History.DATE_LAST_VISITED
+                },
+                History.URL + "= ?",
+                new String[] { uri },
+                History.DATE_LAST_VISITED + " DESC"
+        );
+    }
+
     @Override
     public long getPrePathLastVisitedTimeMilliseconds(ContentResolver cr, String prePath) {
         if (prePath == null) {
             return 0;
         }
         // If we don't end with a trailing slash, then both https://foo.com and https://foo.company.biz will match.
         if (!prePath.endsWith("/")) {
             prePath = prePath + "/";
--- a/mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java
@@ -40,16 +40,28 @@ public class LocalUrlAnnotations impleme
     /**
      * Insert mapping from website URL to URL of the feed.
      */
     @Override
     public void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl) {
         insertAnnotation(cr, originUrl, Key.FEED, feedUrl);
     }
 
+    @Override
+    public boolean hasAcceptedOrDeclinedHomeScreenShortcut(ContentResolver cr, String url) {
+        return hasResultsForSelection(cr,
+                BrowserContract.UrlAnnotations.URL + " = ?",
+                new String[]{url});
+    }
+
+    @Override
+    public void insertHomeScreenShortcut(ContentResolver cr, String url, boolean hasCreatedShortCut) {
+        insertAnnotation(cr, url, Key.HOME_SCREEN_SHORTCUT, String.valueOf(hasCreatedShortCut));
+    }
+
     /**
      * Returns true if there's a mapping from the given website URL to a feed URL. False otherwise.
      */
     @Override
     public boolean hasFeedUrlForWebsite(ContentResolver cr, String websiteUrl) {
         return hasResultsForSelection(cr,
                 BrowserContract.UrlAnnotations.URL + " = ? AND " + BrowserContract.UrlAnnotations.KEY + " = ?",
                 new String[]{websiteUrl, Key.FEED.getDbValue()});
--- a/mobile/android/base/java/org/mozilla/gecko/db/StubBrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/StubBrowserDB.java
@@ -130,16 +130,22 @@ class StubUrlAnnotations implements UrlA
     @Override
     public void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl) {}
 
     @Override
     public void insertReaderViewUrl(ContentResolver cr, String pageURL) {}
 
     @Override
     public void deleteReaderViewUrl(ContentResolver cr, String pageURL) {}
+
+    @Override
+    public boolean hasAcceptedOrDeclinedHomeScreenShortcut(ContentResolver cr, String url) { return false; }
+
+    @Override
+    public void insertHomeScreenShortcut(ContentResolver cr, String url, boolean hasCreatedShortCut) {}
 }
 
 /*
  * This base implementation just stubs all methods. For the
  * real implementations, see LocalBrowserDB.java.
  */
 public class StubBrowserDB implements BrowserDB {
     private final StubSearches searches = new StubSearches();
@@ -204,16 +210,21 @@ public class StubBrowserDB implements Br
         return null;
     }
 
     public Cursor getRecentHistory(ContentResolver cr, int limit) {
         return null;
     }
 
     @Override
+    public Cursor getHistoryForURL(ContentResolver cr, String uri) {
+        return null;
+    }
+
+    @Override
     public Cursor getRecentHistoryBetweenTime(ContentResolver cr, int limit, long time, long end) {
         return null;
     }
 
     @Override
     public long getPrePathLastVisitedTimeMilliseconds(ContentResolver cr, String prePath) { return 0; }
 
     public void expireHistory(ContentResolver cr, BrowserContract.ExpirePriority priority) {
--- a/mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java
@@ -23,9 +23,27 @@ public interface UrlAnnotations {
     void updateFeedSubscription(ContentResolver cr, FeedSubscription subscription);
     boolean hasFeedSubscription(ContentResolver cr, String feedUrl);
     void insertFeedSubscription(ContentResolver cr, FeedSubscription subscription);
     boolean hasFeedUrlForWebsite(ContentResolver cr, String websiteUrl);
     void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl);
 
     void insertReaderViewUrl(ContentResolver cr, String pageURL);
     void deleteReaderViewUrl(ContentResolver cr, String pageURL);
+
+    /**
+     * Did the user ever interact with this URL in regards to home screen shortcuts?
+     *
+     * @return true if the user has created a home screen shortcut or declined to create one in the
+     *         past. This method will still return true if the shortcut has been removed from the
+     *         home screen by the user.
+     */
+    boolean hasAcceptedOrDeclinedHomeScreenShortcut(ContentResolver cr, String url);
+
+    /**
+     * Insert an indication that the user has interacted with this URL in regards to home screen
+     * shortcuts.
+     *
+     * @param hasCreatedShortCut True if a home screen shortcut has been created for this URL. False
+     *                           if the user has actively declined to create a shortcut for this URL.
+     */
+    void insertHomeScreenShortcut(ContentResolver cr, String url, boolean hasCreatedShortCut);
 }
--- a/mobile/android/base/java/org/mozilla/gecko/favicons/Favicons.java
+++ b/mobile/android/base/java/org/mozilla/gecko/favicons/Favicons.java
@@ -3,20 +3,22 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.favicons;
 
 import android.graphics.drawable.Drawable;
 import org.mozilla.gecko.AboutPages;
 import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Tab;
 import org.mozilla.gecko.Tabs;
 import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.URLMetadataTable;
 import org.mozilla.gecko.favicons.cache.FaviconCache;
 import org.mozilla.gecko.util.GeckoJarReader;
 import org.mozilla.gecko.util.NonEvictingLruCache;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.res.Resources;
@@ -28,16 +30,17 @@ import android.text.TextUtils;
 import android.util.Log;
 import android.util.SparseArray;
 
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 public class Favicons {
     private static final String LOGTAG = "GeckoFavicons";
 
     // A magic URL representing the app's own favicon, used for about: pages.
@@ -591,9 +594,46 @@ public class Favicons {
      *
      * @param url page URL to get a large favicon image for.
      * @param onFaviconLoadedListener listener to call back with the result.
      */
     public static void getPreferredSizeFaviconForPage(Context context, String url, String iconURL, OnFaviconLoadedListener onFaviconLoadedListener) {
         int preferredSize = GeckoAppShell.getPreferredIconSize();
         loadUncachedFavicon(context, url, iconURL, LoadFaviconTask.FLAG_BYPASS_CACHE_WHEN_DOWNLOADING_ICONS, preferredSize, onFaviconLoadedListener);
     }
+
+    /**
+     * Load the icon that is the most suitable for using as a home screen shortcut.
+     *
+     * This method will try to load a 'touch icon' first. If not available it will fallback to use
+     * the best available favicon.
+     *
+     * This implementation sidesteps the cache and will load the icon from the database or the
+     * internet. See getPreferredSizeFaviconForPage().
+     */
+    public static void getPreferredIconForHomeScreenShortcut(Context context, String url, OnFaviconLoadedListener onFaviconLoadedListener) {
+        ThreadUtils.assertOnBackgroundThread();
+
+        final BrowserDB db = GeckoProfile.get(context).getDB();
+
+        final ContentResolver cr = context.getContentResolver();
+        final Map<String, Map<String, Object>> metadata = db.getURLMetadata().getForURLs(cr,
+                Collections.singletonList(url),
+                Collections.singletonList(URLMetadataTable.TOUCH_ICON_COLUMN)
+        );
+
+        final Map<String, Object> row = metadata.get(url);
+
+        String touchIconURL = null;
+
+        if (row != null) {
+            touchIconURL = (String) row.get(URLMetadataTable.TOUCH_ICON_COLUMN);
+        }
+
+        // Retrieve the icon while bypassing the cache. Homescreen icon creation is a one-off event, hence it isn't
+        // useful to cache these icons. (Android takes care of storing homescreen icons after a shortcut
+        // has been created.)
+        // The cache is also (currently) limited to 32dp, hence we explicitly need to avoid accessing those icons.
+        // If touchIconURL is null, then Favicons falls back to finding the best possible favicon for
+        // the site URI, hence we can use this call even when there is no touchIcon defined.
+        getPreferredSizeFaviconForPage(context, url, touchIconURL, onFaviconLoadedListener);
+    }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/promotion/AddToHomeScreenPromotion.java
@@ -0,0 +1,208 @@
+/* -*- 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.promotion;
+
+import android.app.Activity;
+import android.database.Cursor;
+import android.support.annotation.WorkerThread;
+import android.util.Log;
+
+import com.keepsafe.switchboard.SwitchBoard;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.util.Experiments;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import ch.boye.httpclientandroidlib.util.TextUtils;
+
+/**
+ * Promote "Add to home screen" if user visits website often.
+ */
+public class AddToHomeScreenPromotion implements Tabs.OnTabsChangedListener {
+    private static class URLHistory {
+        public final long visits;
+        public final long lastVisit;
+
+        private URLHistory(long visits, long lastVisit) {
+            this.visits = visits;
+            this.lastVisit = lastVisit;
+        }
+    }
+
+    private static final String LOGTAG = "GeckoPromoteShortcut";
+
+    private static final String EXPERIMENT_MINIMUM_TOTAL_VISITS = "minimumTotalVisits";
+    private static final String EXPERIMENT_LAST_VISIT_MINIMUM_AGE = "lastVisitMinimumAgeMs";
+    private static final String EXPERIMENT_LAST_VISIT_MAXIMUM_AGE = "lastVisitMaximumAgeMs";
+
+    private Activity activity;
+    private boolean isEnabled;
+    private int minimumVisits;
+    private int lastVisitMinimumAgeMs;
+    private int lastVisitMaximumAgeMs;
+
+    public AddToHomeScreenPromotion(Activity activity) {
+        this.activity = activity;
+
+        initializeExperiment();
+    }
+
+    public void resume() {
+        Tabs.registerOnTabsChangedListener(this);
+    }
+
+    public void pause() {
+        Tabs.unregisterOnTabsChangedListener(this);
+    }
+
+    private void initializeExperiment() {
+        if (!SwitchBoard.isInExperiment(activity, Experiments.PROMOTE_ADD_TO_HOMESCREEN)) {
+            Log.v(LOGTAG, "Experiment not enabled");
+            // Experiment is not enabled. No need to try to read values.
+            return;
+        }
+
+        JSONObject values = SwitchBoard.getExperimentValuesFromJson(activity, Experiments.PROMOTE_ADD_TO_HOMESCREEN);
+        if (values == null) {
+            // We didn't get any values for this experiment. Let's disable it instead of picking default
+            // values that might be bad.
+            return;
+        }
+
+        try {
+            initializeWithValues(
+                    values.getInt(EXPERIMENT_MINIMUM_TOTAL_VISITS),
+                    values.getInt(EXPERIMENT_LAST_VISIT_MINIMUM_AGE),
+                    values.getInt(EXPERIMENT_LAST_VISIT_MAXIMUM_AGE));
+        } catch (JSONException e) {
+            Log.w(LOGTAG, "Could not read experiment values", e);
+        }
+    }
+
+    private void initializeWithValues(int minimumVisits, int lastVisitMinimumAgeMs, int lastVisitMaximumAgeMs) {
+        this.isEnabled = true;
+
+        this.minimumVisits = minimumVisits;
+        this.lastVisitMinimumAgeMs = lastVisitMinimumAgeMs;
+        this.lastVisitMaximumAgeMs = lastVisitMaximumAgeMs;
+    }
+
+    @Override
+    public void onTabChanged(final Tab tab, Tabs.TabEvents msg, Object data) {
+        if (tab == null) {
+            return;
+        }
+
+        if (!Tabs.getInstance().isSelectedTab(tab)) {
+            // We only ever want to show this promotion for the current tab.
+            return;
+        }
+
+        if (Tabs.TabEvents.LOADED == msg) {
+            ThreadUtils.postToBackgroundThread(new Runnable() {
+                @Override
+                public void run() {
+                    maybeShowPromotionForUrl(tab.getURL(), tab.getTitle());
+                }
+            });
+        }
+    }
+
+    private void maybeShowPromotionForUrl(String url, String title) {
+        if (!isEnabled) {
+            return;
+        }
+
+        if (!shouldShowPromotion(url, title)) {
+            return;
+        }
+
+        HomeScreenPrompt.show(activity, url, title);
+    }
+
+    private boolean shouldShowPromotion(String url, String title) {
+        if (TextUtils.isEmpty(url) || TextUtils.isEmpty(title)) {
+            // We require an URL and a title for the shortcut.
+            return false;
+        }
+
+        if (AboutPages.isAboutPage(url)) {
+            // No promotion for our internal sites.
+            return false;
+        }
+
+        if (!url.startsWith("https://")) {
+            // Only promote websites that are served over HTTPS.
+            return false;
+        }
+
+        URLHistory history = getHistoryForURL(url);
+        if (history == null) {
+            // There's no history for this URL yet or we can't read it right now. Just ignore.
+            return false;
+        }
+
+        if (history.visits < minimumVisits) {
+            // This URL has not been visited often enough.
+            return false;
+        }
+
+        if (history.lastVisit > System.currentTimeMillis() - lastVisitMinimumAgeMs) {
+            // The last visit is too new. Do not show promotion. This is mostly to avoid that the
+            // promotion shows up for a quick refreshs and in the worst case the last visit could
+            // be the current visit (race).
+            return false;
+        }
+
+        if (history.lastVisit < System.currentTimeMillis() - lastVisitMaximumAgeMs) {
+            // The last visit is to old. Do not show promotion.
+            return false;
+        }
+
+        if (hasAcceptedOrDeclinedHomeScreenShortcut(url)) {
+            // The user has already created a shortcut in the past or actively declined to create one.
+            // Let's not ask again for this url - We do not want to be annoying.
+            return false;
+        }
+
+        return true;
+    }
+
+    protected boolean hasAcceptedOrDeclinedHomeScreenShortcut(String url) {
+        final UrlAnnotations urlAnnotations = GeckoProfile.get(activity).getDB().getUrlAnnotations();
+        return urlAnnotations.hasAcceptedOrDeclinedHomeScreenShortcut(activity.getContentResolver(), url);
+    }
+
+    protected URLHistory getHistoryForURL(String url) {
+        final GeckoProfile profile = GeckoProfile.get(activity);
+        final BrowserDB browserDB = profile.getDB();
+
+        Cursor cursor = null;
+        try {
+            cursor = browserDB.getHistoryForURL(activity.getContentResolver(), url);
+
+            if (cursor.moveToFirst()) {
+                return new URLHistory(
+                    cursor.getInt(cursor.getColumnIndex(BrowserContract.History.VISITS)),
+                    cursor.getLong(cursor.getColumnIndex(BrowserContract.History.DATE_LAST_VISITED)));
+            }
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+
+        return null;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/promotion/HomeScreenPrompt.java
@@ -0,0 +1,247 @@
+/* -*- 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.promotion;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.favicons.Favicons;
+import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
+import org.mozilla.gecko.util.Experiments;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * Prompt to promote adding the current website to the home screen.
+ */
+public class HomeScreenPrompt extends Locales.LocaleAwareActivity implements OnFaviconLoadedListener {
+    private static final String EXTRA_TITLE = "title";
+    private static final String EXTRA_URL = "url";
+
+    private static final String TELEMETRY_EXTRA = "home_screen_promotion";
+
+    private View containerView;
+    private ImageView iconView;
+    private String title;
+    private String url;
+    private boolean isAnimating;
+    private boolean hasAccepted;
+
+    public static void show(Context context, String url, String title) {
+        Intent intent = new Intent(context, HomeScreenPrompt.class);
+        intent.putExtra(EXTRA_TITLE, title);
+        intent.putExtra(EXTRA_URL, url);
+        context.startActivity(intent);
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        fetchDataFromIntent();
+        setupViews();
+        loadShortcutIcon();
+
+        slideIn();
+
+        Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, Experiments.PROMOTE_ADD_TO_HOMESCREEN);
+
+        // Technically this isn't triggered by a "service". But it's also triggered by a background task and without
+        // actual user interaction.
+        Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.SERVICE, TELEMETRY_EXTRA);
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+
+        Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, Experiments.PROMOTE_ADD_TO_HOMESCREEN);
+    }
+
+    private void fetchDataFromIntent() {
+        final Bundle extras = getIntent().getExtras();
+
+        title = extras.getString(EXTRA_TITLE);
+        url = extras.getString(EXTRA_URL);
+    }
+
+    private void setupViews() {
+        setContentView(R.layout.homescreen_prompt);
+
+        ((TextView) findViewById(R.id.title)).setText(title);
+
+        Uri uri = Uri.parse(url);
+        ((TextView) findViewById(R.id.host)).setText(uri.getHost());
+
+        containerView = findViewById(R.id.container);
+        iconView = (ImageView) findViewById(R.id.icon);
+
+        findViewById(R.id.add).setOnClickListener(new View.OnClickListener() {
+            @Override
+            public void onClick(View v) {
+                hasAccepted = true;
+
+                addToHomeScreen();
+            }
+        });
+
+        findViewById(R.id.close).setOnClickListener(new View.OnClickListener() {
+            @Override
+            public void onClick(View v) {
+                rememberRejection();
+                slideOut();
+
+                Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.BUTTON, TELEMETRY_EXTRA);
+            }
+        });
+    }
+
+    private void addToHomeScreen() {
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                GeckoAppShell.createShortcut(title, url);
+
+                Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, TELEMETRY_EXTRA);
+
+                goToHomeScreen();
+            }
+        });
+    }
+
+    /**
+     * Finish this activity and launch the default home screen activity.
+     */
+    private void goToHomeScreen() {
+        Intent intent = new Intent(Intent.ACTION_MAIN);
+
+        intent.addCategory(Intent.CATEGORY_HOME);
+        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        startActivity(intent);
+
+        finish();
+    }
+
+    private void loadShortcutIcon() {
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                Favicons.getPreferredIconForHomeScreenShortcut(HomeScreenPrompt.this, url, HomeScreenPrompt.this);
+            }
+        });
+    }
+
+    private void slideIn() {
+        containerView.setTranslationY(500);
+        containerView.setAlpha(0);
+
+        final Animator translateAnimator = ObjectAnimator.ofFloat(containerView, "translationY", 0);
+        translateAnimator.setDuration(400);
+
+        final Animator alphaAnimator = ObjectAnimator.ofFloat(containerView, "alpha", 1);
+        alphaAnimator.setStartDelay(200);
+        alphaAnimator.setDuration(600);
+
+        final AnimatorSet set = new AnimatorSet();
+        set.playTogether(alphaAnimator, translateAnimator);
+        set.setStartDelay(400);
+
+        set.start();
+    }
+
+    /**
+     * Remember that the user rejected creating a home screen shortcut for this URL.
+     */
+    private void rememberRejection() {
+        if (hasAccepted) {
+            // User has already accepted to create a shortcut.
+            return;
+        }
+
+        final UrlAnnotations urlAnnotations = GeckoProfile.get(this).getDB().getUrlAnnotations();
+        urlAnnotations.insertHomeScreenShortcut(getContentResolver(), url, false);
+    }
+
+    private void slideOut() {
+        if (isAnimating) {
+            return;
+        }
+
+        isAnimating = true;
+
+        ObjectAnimator animator = ObjectAnimator.ofFloat(containerView, "translationY", containerView.getHeight());
+        animator.addListener(new AnimatorListenerAdapter() {
+            @Override
+            public void onAnimationEnd(Animator animation) {
+                finish();
+            }
+
+        });
+        animator.start();
+    }
+
+    @Override
+    public void finish() {
+        super.finish();
+
+        // Don't perform an activity-dismiss animation.
+        overridePendingTransition(0, 0);
+    }
+
+    @Override
+    public void onBackPressed() {
+        rememberRejection();
+        slideOut();
+
+        Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.BACK, TELEMETRY_EXTRA);
+    }
+
+    /**
+     * User clicked outside of the prompt.
+     */
+    @Override
+    public boolean onTouchEvent(MotionEvent event) {
+        rememberRejection();
+        slideOut();
+
+        // Not really an action triggered by the "back" button but with the same effect: Finishing this
+        // activity and going back to the previous one.
+        Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.BACK, TELEMETRY_EXTRA);
+
+        return true;
+    }
+
+    @Override
+    public void onFaviconLoaded(String url, String faviconURL, final Bitmap favicon) {
+        if (favicon == null) {
+            return;
+        }
+
+        ThreadUtils.postToUiThread(new Runnable() {
+            @Override
+            public void run() {
+                iconView.setImageBitmap(favicon);
+            }
+        });
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/util/Experiments.java
+++ b/mobile/android/base/java/org/mozilla/gecko/util/Experiments.java
@@ -40,16 +40,19 @@ public class Experiments {
     // on the client, they are not part of the server config.
     public static final String ONBOARDING2_A = "onboarding2-a"; // Control: Single (blue) welcome screen
     public static final String ONBOARDING2_B = "onboarding2-b"; // 4 static Feature slides
     public static final String ONBOARDING2_C = "onboarding2-c"; // 4 static + 1 clickable (Data saving) Feature slides
 
     // Synchronizing the catalog of downloadable content from Kinto
     public static final String DOWNLOAD_CONTENT_CATALOG_SYNC = "download-content-catalog-sync";
 
+    // Promotion for "Add to homescreen"
+    public static final String PROMOTE_ADD_TO_HOMESCREEN = "promote-add-to-homescreen";
+
     public static final String PREF_ONBOARDING_VERSION = "onboarding_version";
 
     private static volatile Boolean disabled = null;
 
     /**
      * Determines whether Switchboard is disabled by the MOZ_DISABLE_SWITCHBOARD
      * environment variable. We need to read this value from the intent string
      * extra because environment variables from our test harness aren't set
--- a/mobile/android/base/locales/en-US/android_strings.dtd
+++ b/mobile/android/base/locales/en-US/android_strings.dtd
@@ -767,8 +767,10 @@ just addresses the organization to follo
 
 <!ENTITY eol_notification_title2 "&brandShortName; will no longer update">
 <!ENTITY eol_notification_summary "Tap to learn more">
 
 <!-- LOCALIZATION NOTE (whatsnew_notification_title, whatsnew_notification_summary): These strings
      are used for a system notification that's shown to users after the app updates. -->
 <!ENTITY whatsnew_notification_title "&brandShortName; is up to date">
 <!ENTITY whatsnew_notification_summary "Find out what\'s new in this version">
+
+<!ENTITY promotion_add_to_homescreen "Add to home screen">
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -504,16 +504,18 @@ gbjar.sources += ['java/org/mozilla/geck
     'preferences/PrivateDataPreference.java',
     'preferences/SearchEnginePreference.java',
     'preferences/SearchPreferenceCategory.java',
     'preferences/SetHomepagePreference.java',
     'preferences/SyncPreference.java',
     'PrefsHelper.java',
     'PrintHelper.java',
     'PrivateTab.java',
+    'promotion/AddToHomeScreenPromotion.java',
+    'promotion/HomeScreenPrompt.java',
     'prompts/ColorPickerInput.java',
     'prompts/IconGridInput.java',
     'prompts/IntentChooserPrompt.java',
     'prompts/IntentHandler.java',
     'prompts/Prompt.java',
     'prompts/PromptInput.java',
     'prompts/PromptListAdapter.java',
     'prompts/PromptListItem.java',
--- a/mobile/android/base/strings.xml.in
+++ b/mobile/android/base/strings.xml.in
@@ -598,9 +598,11 @@
   <string name="eol_notification_summary">&eol_notification_summary;</string>
   <!-- https://support.mozilla.org/1/mobile/%VERSION%/%OS%/%LOCALE%/honeycomb -->
   <string name="eol_notification_url">https://support.mozilla.org/1/mobile/&formatS1;/&formatS2;/&formatS3;/unsupported-version</string>
 
   <string name="whatsnew_notification_title">&whatsnew_notification_title;</string>
   <string name="whatsnew_notification_summary">&whatsnew_notification_summary;</string>
   <!-- https://support.mozilla.org/1/mobile/%VERSION%/%OS%/%LOCALE%/new-android -->
   <string name="whatsnew_notification_url">https://support.mozilla.org/1/mobile/&formatS1;/&formatS2;/&formatS3;/new-android</string>
+
+  <string name="promotion_add_to_homescreen">&promotion_add_to_homescreen;</string>
 </resources>
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/res/layout/homescreen_prompt.xml
@@ -0,0 +1,90 @@
+<?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"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:clipChildren="false"
+    android:clipToPadding="false">
+
+    <RelativeLayout
+        android:id="@+id/container"
+        android:layout_width="@dimen/overlay_prompt_container_width"
+        android:layout_height="wrap_content"
+        android:layout_gravity="bottom|center"
+        android:background="@android:color/white"
+        android:clickable="true"
+        android:orientation="vertical">
+
+        <ImageView
+            android:id="@+id/close"
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            android:layout_alignParentRight="true"
+            android:layout_marginLeft="10dp"
+            android:layout_marginRight="30dp"
+            android:layout_marginTop="30dp"
+            android:ellipsize="end"
+            android:maxLines="2"
+            android:padding="6dp"
+            android:src="@drawable/tab_close_active" />
+
+        <TextView
+            android:id="@+id/title"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="6dp"
+            android:layout_marginLeft="30dp"
+            android:layout_marginTop="30dp"
+            android:layout_toLeftOf="@id/close"
+            android:fontFamily="sans-serif-light"
+            android:textColor="@color/text_and_tabs_tray_grey"
+            android:textSize="20sp"
+            tools:text="The Pokedex" />
+
+        <TextView
+            android:id="@+id/host"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_below="@id/title"
+            android:layout_marginBottom="20dp"
+            android:layout_marginLeft="30dp"
+            android:layout_marginRight="30dp"
+            android:ellipsize="end"
+            android:maxLines="1"
+            android:textColor="@color/placeholder_grey"
+            android:textSize="16sp"
+            tools:text="pokedex.org" />
+
+        <ImageView
+            android:id="@+id/icon"
+            android:layout_width="50dp"
+            android:layout_height="50dp"
+            android:layout_below="@id/host"
+            android:layout_marginBottom="20dp"
+            android:layout_marginLeft="30dp"
+            android:src="@drawable/icon" />
+
+        <Button
+            android:id="@+id/add"
+            style="@style/Widget.BaseButton"
+            android:layout_width="wrap_content"
+            android:layout_height="50dp"
+            android:layout_alignParentRight="true"
+            android:layout_below="@id/host"
+            android:layout_marginBottom="20dp"
+            android:layout_marginLeft="20dp"
+            android:layout_marginRight="30dp"
+            android:background="@drawable/button_background_action_orange_round"
+            android:paddingLeft="16dp"
+            android:paddingRight="16dp"
+            android:text="@string/promotion_add_to_homescreen"
+            android:textColor="@android:color/white"
+            android:textSize="16sp" />
+
+    </RelativeLayout>
+</merge>