--- 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.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;
import org.mozilla.gecko.search.SearchEngineManager;
@@ -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;
@@ -812,16 +814,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) {
@@ -1049,30 +1053,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,19 @@ 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);
+
+ 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/GeckoAppShell.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoAppShell.java
@@ -757,16 +757,18 @@ public class GeckoAppShell
// This is the entry point from nsIShellService.
@WrapForJNI
public static void createShortcut(final String aTitle, final String aURI) {
final GeckoInterface geckoInterface = getGeckoInterface();
if (geckoInterface == null) {
return;
}
geckoInterface.createShortcut(aTitle, aURI);
+
+
}
@JNITarget
static public int getPreferredIconSize() {
if (Versions.feature11Plus) {
ActivityManager am = (ActivityManager)
getApplicationContext().getSystemService(Context.ACTIVITY_SERVICE);
return am.getLauncherLargeIconSize();
@@ -1934,16 +1936,18 @@ public class GeckoAppShell
if (geckoInterface == null) {
return;
}
geckoInterface.checkUriVisited(uri);
}
@WrapForJNI(stubName = "MarkURIVisited")
static void markUriVisited(final String uri) {
+ Log.w("SKDBG", "***** markUrlVisited()");
+
final GeckoInterface geckoInterface = getGeckoInterface();
if (geckoInterface == null) {
return;
}
geckoInterface.markUriVisited(uri);
}
@WrapForJNI(stubName = "SetURITitle")
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
@@ -528,17 +528,26 @@ public class BrowserContract {
FEED("feed"),
/**
* This key maps URLs of feeds to an object describing the feed.
*
* Key: feed_subscription
* Value: JSON object describing feed
*/
- FEED_SUBSCRIPTION("feed_subscription");
+ FEED_SUBSCRIPTION("feed_subscription"),
+
+ /**
+ * Indicator that we 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
@@ -89,16 +89,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
@@ -533,17 +533,17 @@ public class LocalBrowserDB implements B
.appendQueryParameter(BrowserContract.PARAM_LIMIT,
String.valueOf(limit))
.build();
}
private Uri combinedUriWithLimit(int limit) {
return mCombinedUriWithProfile.buildUpon()
.appendQueryParameter(BrowserContract.PARAM_LIMIT,
- String.valueOf(limit))
+ String.valueOf(limit))
.build();
}
private static Uri withDeleted(final Uri uri) {
return uri.buildUpon()
.appendQueryParameter(BrowserContract.PARAM_SHOW_DELETED, "1")
.build();
}
@@ -636,25 +636,25 @@ public class LocalBrowserDB implements B
selection = Combined.URL + " NOT IN (SELECT " +
Bookmarks.URL + " FROM bookmarks WHERE " +
DBUtils.qualifyColumn("bookmarks", Bookmarks.PARENT) + " = ? AND " +
DBUtils.qualifyColumn("bookmarks", Bookmarks.IS_DELETED) + " == 0)";
selectionArgs = new String[] { String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) };
}
return filterAllSites(cr,
- new String[] { Combined._ID,
- Combined.URL,
- Combined.TITLE,
- Combined.BOOKMARK_ID,
- Combined.HISTORY_ID },
- constraint,
- limit,
- null,
- selection, selectionArgs);
+ new String[]{Combined._ID,
+ Combined.URL,
+ Combined.TITLE,
+ Combined.BOOKMARK_ID,
+ Combined.HISTORY_ID},
+ constraint,
+ limit,
+ null,
+ selection, selectionArgs);
}
@Override
public void updateVisitedHistory(ContentResolver cr, String uri) {
ContentValues values = new ContentValues();
values.put(History.URL, uri);
values.put(History.DATE_LAST_VISITED, System.currentTimeMillis());
@@ -687,26 +687,26 @@ public class LocalBrowserDB implements B
History.VISITS + " > 0",
null,
null);
}
@Override
public Cursor getRecentHistory(ContentResolver cr, int limit) {
return cr.query(combinedUriWithLimit(limit),
- new String[] { Combined._ID,
- Combined.BOOKMARK_ID,
- Combined.HISTORY_ID,
- Combined.URL,
- Combined.TITLE,
- Combined.DATE_LAST_VISITED,
- Combined.VISITS },
- History.DATE_LAST_VISITED + " > 0",
- null,
- History.DATE_LAST_VISITED + " DESC");
+ new String[]{Combined._ID,
+ Combined.BOOKMARK_ID,
+ Combined.HISTORY_ID,
+ Combined.URL,
+ Combined.TITLE,
+ Combined.DATE_LAST_VISITED,
+ Combined.VISITS},
+ History.DATE_LAST_VISITED + " > 0",
+ null,
+ History.DATE_LAST_VISITED + " DESC");
}
@Override
public Cursor getRecentHistoryBetweenTime(ContentResolver cr, int limit, long start, long end) {
return cr.query(combinedUriWithLimit(limit),
new String[] { Combined._ID,
Combined.BOOKMARK_ID,
Combined.HISTORY_ID,
@@ -714,16 +714,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
@@ -41,16 +41,32 @@ 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);
}
/**
+ * Did we ever create a home screen short cut for this URL? If this method returns true this
+ * doesn't mean that the shortcut still exists - only that we created one in the past.
+ */
+ @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
@@ -185,16 +185,22 @@ class StubUrlAnnotations implements UrlA
@Override
public void insertFeedSubscription(ContentResolver cr, FeedSubscription subscription) {}
@Override
public boolean hasFeedUrlForWebsite(ContentResolver cr, String websiteUrl) { return false; }
@Override
public void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl) {}
+
+ @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();
@@ -265,16 +271,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
@@ -20,9 +20,12 @@ public interface UrlAnnotations {
void deleteFeedUrl(ContentResolver cr, String websiteUrl);
boolean hasWebsiteForFeedUrl(ContentResolver cr, String feedUrl);
void deleteFeedSubscription(ContentResolver cr, FeedSubscription subscription);
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);
+
+ boolean hasAcceptedOrDeclinedHomeScreenShortcut(ContentResolver cr, String 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,37 @@ 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);
}
+
+ 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,197 @@
+/* -*- 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.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 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 = "lastVisitMinimumAge";
+ private static final String EXPERIMENT_LAST_VISIT_MAXIMUM_AGE = "lastVisitMaximumAge";
+
+ private Activity activity;
+ private boolean isEnabled;
+ private int minimumVisits;
+ private int lastVisitMinimumAge;
+ private int lastVisitMaximumAge;
+
+ public AddToHomeScreenPromotion(Activity activity) {
+ this.activity = activity;
+
+ initializeExperiment();
+ }
+
+ public void resume() {
+ Tabs.registerOnTabsChangedListener(this);
+ }
+
+ public void pause() {
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ private void initializeExperiment() {
+ isEnabled = SwitchBoard.isInExperiment(activity, Experiments.PROMOTE_ADD_TO_HOMESCREEN);
+ if (!isEnabled) {
+ 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.
+ isEnabled = false;
+ return;
+ }
+
+ try {
+ minimumVisits = values.getInt(EXPERIMENT_MINIMUM_TOTAL_VISITS);
+ lastVisitMinimumAge = values.getInt(EXPERIMENT_LAST_VISIT_MINIMUM_AGE);
+ lastVisitMaximumAge = values.getInt(EXPERIMENT_LAST_VISIT_MAXIMUM_AGE);
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Could not read experiment values", e);
+
+ // Disable experiment to avoid running the experiment with partial values.
+ isEnabled = false;
+ }
+ }
+
+ @Override
+ public void onTabChanged(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) {
+ 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) {
+ Log.v(LOGTAG, "No previous visits");
+ // There's no history for this URL yet or we can't read it right now. Just ignore.
+ return false;
+ }
+
+ if (history.visits < minimumVisits) {
+ Log.v(LOGTAG, "Not enough visits (" + history.visits + ")");
+ return false;
+ }
+
+ if (history.lastVisit > System.currentTimeMillis() - lastVisitMinimumAge) {
+ Log.v(LOGTAG, "Last visit not old enough");
+ // 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() - lastVisitMaximumAge) {
+ Log.v(LOGTAG, "Last visit too old");
+ // The last visit is to old. Do not show promotion.
+ return false;
+ }
+
+ final UrlAnnotations urlAnnotations = GeckoProfile.get(activity).getDB().getUrlAnnotations();
+ if (urlAnnotations.hasAcceptedOrDeclinedHomeScreenShortcut(activity.getContentResolver(), url)) {
+ Log.v(LOGTAG, "Shortcut already accepted or declined in the past");
+ // 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;
+ }
+
+ private 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,239 @@
+/* -*- 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();
+ }
+ });
+ }
+
+ private void goToHomeScreen() {
+ Intent startMain = new Intent(Intent.ACTION_MAIN);
+ startMain.addCategory(Intent.CATEGORY_HOME);
+ startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(startMain);
+
+ 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();
+ }
+
+ 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.
+ 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
@@ -776,8 +776,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
@@ -507,16 +507,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
@@ -600,9 +600,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:orientation="vertical"
+ android:clickable="true">
+
+ <ImageView
+ android:id="@+id/close"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_marginTop="30dp"
+ android:layout_marginRight="30dp"
+ android:layout_marginLeft="10dp"
+ android:padding="6dp"
+ android:maxLines="2"
+ android:ellipsize="end"
+ android:layout_alignParentRight="true"
+ 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:maxLines="1"
+ android:ellipsize="end"
+ 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_marginRight="30dp"
+ android:layout_marginLeft="20dp"
+ 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>