--- 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>