summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/tabqueue
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /mobile/android/base/java/org/mozilla/gecko/tabqueue
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/tabqueue')
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java357
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java215
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java342
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java130
4 files changed, 1044 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java
new file mode 100644
index 000000000..667eb8f6c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java
@@ -0,0 +1,357 @@
+/* -*- 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.tabqueue;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.graphics.PixelFormat;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.content.ContextCompat;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.View;
+import android.view.WindowManager;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TabQueueHelper {
+ private static final String LOGTAG = "Gecko" + TabQueueHelper.class.getSimpleName();
+
+ // Disable Tab Queue for API level 10 (GB) - Bug 1206055
+ public static final boolean TAB_QUEUE_ENABLED = true;
+
+ public static final String FILE_NAME = "tab_queue_url_list.json";
+ public static final String LOAD_URLS_ACTION = "TAB_QUEUE_LOAD_URLS_ACTION";
+ public static final int TAB_QUEUE_NOTIFICATION_ID = R.id.tabQueueNotification;
+
+ public static final String PREF_TAB_QUEUE_COUNT = "tab_queue_count";
+ public static final String PREF_TAB_QUEUE_LAUNCHES = "tab_queue_launches";
+ public static final String PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN = "tab_queue_times_prompt_shown";
+
+ public static final int MAX_TIMES_TO_SHOW_PROMPT = 3;
+ public static final int EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT = 3;
+
+ // result codes for returning from the prompt
+ public static final int TAB_QUEUE_YES = 201;
+ public static final int TAB_QUEUE_NO = 202;
+
+ /**
+ * Checks if the specified context can draw on top of other apps. As of API level 23, an app
+ * cannot draw on top of other apps unless it declares the SYSTEM_ALERT_WINDOW permission in
+ * its manifest, AND the user specifically grants the app this capability.
+ *
+ * @return true if the specified context can draw on top of other apps, false otherwise.
+ */
+ public static boolean canDrawOverlays(Context context) {
+ if (AppConstants.Versions.preMarshmallow) {
+ return true; // We got the permission at install time.
+ }
+
+ // It would be nice to just use Settings.canDrawOverlays() - but this helper is buggy for
+ // apps using sharedUserId (See bug 1244722).
+ // Instead we'll add and remove an invisible view. If this is successful then we seem to
+ // have permission to draw overlays.
+
+ View view = new View(context);
+ view.setVisibility(View.INVISIBLE);
+
+ WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(
+ 1, 1,
+ WindowManager.LayoutParams.TYPE_PHONE,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
+ PixelFormat.TRANSLUCENT);
+
+ WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+
+ try {
+ windowManager.addView(view, layoutParams);
+ windowManager.removeView(view);
+ return true;
+ } catch (final SecurityException | WindowManager.BadTokenException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Check if we should show the tab queue prompt
+ *
+ * @param context
+ * @return true if we should display the prompt, false if not.
+ */
+ public static boolean shouldShowTabQueuePrompt(Context context) {
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+
+ int numberOfTimesTabQueuePromptSeen = prefs.getInt(PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN, 0);
+
+ // Exit early if the feature is already enabled or the user has seen the
+ // prompt more than MAX_TIMES_TO_SHOW_PROMPT times.
+ if (isTabQueueEnabled(prefs) || numberOfTimesTabQueuePromptSeen >= MAX_TIMES_TO_SHOW_PROMPT) {
+ return false;
+ }
+
+ final int viewActionIntentLaunches = prefs.getInt(PREF_TAB_QUEUE_LAUNCHES, 0) + 1;
+ if (viewActionIntentLaunches < EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT) {
+ // Allow a few external links to open before we prompt the user.
+ prefs.edit().putInt(PREF_TAB_QUEUE_LAUNCHES, viewActionIntentLaunches).apply();
+ } else if (viewActionIntentLaunches == EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT) {
+ // Reset to avoid repeatedly showing the prompt if the user doesn't interact with it and
+ // we get more external VIEW action intents in.
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.remove(TabQueueHelper.PREF_TAB_QUEUE_LAUNCHES);
+
+ int timesPromptShown = prefs.getInt(TabQueueHelper.PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN, 0) + 1;
+ editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN, timesPromptShown);
+ editor.apply();
+
+ // Show the prompt
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Reads file and converts any content to JSON, adds passed in URL to the data and writes back to the file,
+ * creating the file if it doesn't already exist. This should not be run on the UI thread.
+ *
+ * @param profile
+ * @param url URL to add
+ * @param filename filename to add URL to
+ * @return the number of tabs currently queued
+ */
+ public static int queueURL(final GeckoProfile profile, final String url, final String filename) {
+ ThreadUtils.assertNotOnUiThread();
+
+ JSONArray jsonArray = profile.readJSONArrayFromFile(filename);
+
+ jsonArray.put(url);
+
+ profile.writeFile(filename, jsonArray.toString());
+
+ return jsonArray.length();
+ }
+
+ /**
+ * Remove a url from the file, if it exists.
+ * If the url exists multiple times, all instances of it will be removed.
+ * This should not be run on the UI thread.
+ *
+ * @param context
+ * @param urlToRemove URL to remove
+ * @param filename filename to remove URL from
+ * @return the number of queued urls
+ */
+ public static int removeURLFromFile(final Context context, final String urlToRemove, final String filename) {
+ ThreadUtils.assertNotOnUiThread();
+
+ final GeckoProfile profile = GeckoProfile.get(context);
+
+ JSONArray jsonArray = profile.readJSONArrayFromFile(filename);
+ JSONArray newArray = new JSONArray();
+ String url;
+
+ // Since JSONArray.remove was only added in API 19, we have to use two arrays in order to remove.
+ for (int i = 0; i < jsonArray.length(); i++) {
+ try {
+ url = jsonArray.getString(i);
+ } catch (JSONException e) {
+ url = "";
+ }
+ if (!TextUtils.isEmpty(url) && !urlToRemove.equals(url)) {
+ newArray.put(url);
+ }
+ }
+
+ profile.writeFile(filename, newArray.toString());
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+ prefs.edit().putInt(PREF_TAB_QUEUE_COUNT, newArray.length()).apply();
+
+ return newArray.length();
+ }
+
+ /**
+ * Get up to eight of the last queued URLs for displaying in the notification.
+ */
+ public static List<String> getLastURLs(final Context context, final String filename) {
+ final GeckoProfile profile = GeckoProfile.get(context);
+ final JSONArray jsonArray = profile.readJSONArrayFromFile(filename);
+ final List<String> urls = new ArrayList<>(8);
+
+ for (int i = 0; i < 8; i++) {
+ try {
+ urls.add(jsonArray.getString(i));
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Unable to parse URL from tab queue array", e);
+ }
+ }
+
+ return urls;
+ }
+
+ /**
+ * Displays a notification showing the total number of tabs queue. If there is already a notification displayed, it
+ * will be replaced.
+ *
+ * @param context
+ * @param tabsQueued
+ */
+ public static void showNotification(final Context context, final int tabsQueued, final List<String> urls) {
+ ThreadUtils.assertNotOnUiThread();
+
+ Intent resultIntent = new Intent();
+ resultIntent.setClassName(context, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ resultIntent.setAction(TabQueueHelper.LOAD_URLS_ACTION);
+
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, resultIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+
+ final String text;
+ final Resources resources = context.getResources();
+ if (tabsQueued == 1) {
+ text = resources.getString(R.string.tab_queue_notification_text_singular);
+ } else {
+ text = resources.getString(R.string.tab_queue_notification_text_plural, tabsQueued);
+ }
+
+ NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
+ inboxStyle.setBigContentTitle(text);
+ for (String url : urls) {
+ inboxStyle.addLine(url);
+ }
+ inboxStyle.setSummaryText(resources.getString(R.string.tab_queue_notification_title));
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setContentTitle(text)
+ .setContentText(resources.getString(R.string.tab_queue_notification_title))
+ .setStyle(inboxStyle)
+ .setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange))
+ .setNumber(tabsQueued)
+ .setContentIntent(pendingIntent);
+
+ NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.notify(TabQueueHelper.TAB_QUEUE_NOTIFICATION_ID, builder.build());
+ }
+
+ public static boolean shouldOpenTabQueueUrls(final Context context) {
+ ThreadUtils.assertNotOnUiThread();
+
+ // TODO: Use profile shared prefs when bug 1147925 gets fixed.
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+
+ int tabsQueued = prefs.getInt(PREF_TAB_QUEUE_COUNT, 0);
+
+ return isTabQueueEnabled(prefs) && tabsQueued > 0;
+ }
+
+ public static int getTabQueueLength(final Context context) {
+ ThreadUtils.assertNotOnUiThread();
+
+ // TODO: Use profile shared prefs when bug 1147925 gets fixed.
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+ return prefs.getInt(PREF_TAB_QUEUE_COUNT, 0);
+ }
+
+ public static void openQueuedUrls(final Context context, final GeckoProfile profile, final String filename, boolean shouldPerformJavaScriptCallback) {
+ ThreadUtils.assertNotOnUiThread();
+
+ removeNotification(context);
+
+ // exit early if we don't have any tabs queued
+ if (getTabQueueLength(context) < 1) {
+ return;
+ }
+
+ JSONArray jsonArray = profile.readJSONArrayFromFile(filename);
+
+ if (jsonArray.length() > 0) {
+ JSONObject data = new JSONObject();
+ try {
+ data.put("urls", jsonArray);
+ data.put("shouldNotifyTabsOpenedToJava", shouldPerformJavaScriptCallback);
+ GeckoAppShell.notifyObservers("Tabs:OpenMultiple", data.toString());
+ } catch (JSONException e) {
+ // Don't exit early as we perform cleanup at the end of this function.
+ Log.e(LOGTAG, "Error sending tab queue data", e);
+ }
+ }
+
+ try {
+ profile.deleteFileFromProfileDir(filename);
+ } catch (IllegalArgumentException e) {
+ Log.e(LOGTAG, "Error deleting Tab Queue data file.", e);
+ }
+
+ // TODO: Use profile shared prefs when bug 1147925 gets fixed.
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+ prefs.edit().remove(PREF_TAB_QUEUE_COUNT).apply();
+ }
+
+ protected static void removeNotification(Context context) {
+ NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.cancel(TAB_QUEUE_NOTIFICATION_ID);
+ }
+
+ public static boolean processTabQueuePromptResponse(int resultCode, Context context) {
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+ final SharedPreferences.Editor editor = prefs.edit();
+
+ switch (resultCode) {
+ case TAB_QUEUE_YES:
+ editor.putBoolean(GeckoPreferences.PREFS_TAB_QUEUE, true);
+
+ // By making this one more than EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT we ensure the prompt
+ // will never show again without having to keep track of an extra pref.
+ editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_LAUNCHES,
+ TabQueueHelper.EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT + 1);
+ break;
+
+ case TAB_QUEUE_NO:
+ // The user clicked the 'no' button, so let's make sure the user never sees the prompt again by
+ // maxing out the pref used to count the VIEW action intents received and times they've seen the prompt.
+
+ editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_LAUNCHES,
+ TabQueueHelper.EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT + 1);
+
+ editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN,
+ TabQueueHelper.MAX_TIMES_TO_SHOW_PROMPT + 1);
+ break;
+
+ default:
+ // We shouldn't ever get here.
+ Log.w(LOGTAG, "Unrecognized result code received from the tab queue prompt: " + resultCode);
+ }
+
+ editor.apply();
+
+ return resultCode == TAB_QUEUE_YES;
+ }
+
+ public static boolean isTabQueueEnabled(Context context) {
+ return isTabQueueEnabled(GeckoSharedPrefs.forApp(context));
+ }
+
+ public static boolean isTabQueueEnabled(SharedPreferences prefs) {
+ return prefs.getBoolean(GeckoPreferences.PREFS_TAB_QUEUE, false);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java
new file mode 100644
index 000000000..ead16ccba
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java
@@ -0,0 +1,215 @@
+/* -*- 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.tabqueue;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+
+import android.annotation.TargetApi;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.provider.Settings;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.Toast;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+
+public class TabQueuePrompt extends Locales.LocaleAwareActivity {
+ public static final String LOGTAG = "Gecko" + TabQueuePrompt.class.getSimpleName();
+
+ private static final int SETTINGS_REQUEST_CODE = 1;
+
+ // Flag set during animation to prevent animation multiple-start.
+ private boolean isAnimating;
+
+ private View containerView;
+ private View buttonContainer;
+ private View enabledConfirmation;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ showTabQueueEnablePrompt();
+ }
+
+ private void showTabQueueEnablePrompt() {
+ setContentView(R.layout.tab_queue_prompt);
+
+ final View okButton = findViewById(R.id.ok_button);
+ okButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onConfirmButtonPressed();
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "tabqueue_prompt_yes");
+ }
+ });
+ findViewById(R.id.cancel_button).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "tabqueue_prompt_no");
+ setResult(TabQueueHelper.TAB_QUEUE_NO);
+ finish();
+ }
+ });
+ final View settingsButton = findViewById(R.id.settings_button);
+ settingsButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onSettingsButtonPressed();
+ }
+ });
+
+ final View tipView = findViewById(R.id.tip_text);
+ final View settingsPermitView = findViewById(R.id.settings_permit_text);
+
+ if (TabQueueHelper.canDrawOverlays(this)) {
+ okButton.setVisibility(View.VISIBLE);
+ settingsButton.setVisibility(View.GONE);
+ tipView.setVisibility(View.VISIBLE);
+ settingsPermitView.setVisibility(View.GONE);
+ } else {
+ okButton.setVisibility(View.GONE);
+ settingsButton.setVisibility(View.VISIBLE);
+ tipView.setVisibility(View.GONE);
+ settingsPermitView.setVisibility(View.VISIBLE);
+ }
+
+ containerView = findViewById(R.id.tab_queue_container);
+ buttonContainer = findViewById(R.id.button_container);
+ enabledConfirmation = findViewById(R.id.enabled_confirmation);
+
+ 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();
+ }
+
+ @Override
+ public void finish() {
+ super.finish();
+
+ // Don't perform an activity-dismiss animation.
+ overridePendingTransition(0, 0);
+ }
+
+ private void onConfirmButtonPressed() {
+ enabledConfirmation.setVisibility(View.VISIBLE);
+ enabledConfirmation.setAlpha(0);
+
+ final Animator buttonsAlphaAnimator = ObjectAnimator.ofFloat(buttonContainer, "alpha", 0);
+ buttonsAlphaAnimator.setDuration(300);
+
+ final Animator messagesAlphaAnimator = ObjectAnimator.ofFloat(enabledConfirmation, "alpha", 1);
+ messagesAlphaAnimator.setDuration(300);
+ messagesAlphaAnimator.setStartDelay(200);
+
+ final AnimatorSet set = new AnimatorSet();
+ set.playTogether(buttonsAlphaAnimator, messagesAlphaAnimator);
+
+ set.addListener(new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ slideOut();
+ setResult(TabQueueHelper.TAB_QUEUE_YES);
+ }
+ }, 1000);
+ }
+ });
+
+ set.start();
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ private void onSettingsButtonPressed() {
+ Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
+ intent.setData(Uri.parse("package:" + getPackageName()));
+ startActivityForResult(intent, SETTINGS_REQUEST_CODE);
+
+ Toast.makeText(this, R.string.tab_queue_prompt_permit_drawing_over_apps, Toast.LENGTH_LONG).show();
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (requestCode != SETTINGS_REQUEST_CODE) {
+ return;
+ }
+
+ if (TabQueueHelper.canDrawOverlays(this)) {
+ // User granted the permission in Android's settings.
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "tabqueue_prompt_yes");
+
+ setResult(TabQueueHelper.TAB_QUEUE_YES);
+ finish();
+ }
+ }
+
+ /**
+ * Slide the overlay down off the screen and destroy it.
+ */
+ 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();
+ }
+
+ /**
+ * Close the dialog if back is pressed.
+ */
+ @Override
+ public void onBackPressed() {
+ slideOut();
+ }
+
+ /**
+ * Close the dialog if the anything that isn't a button is tapped.
+ */
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ slideOut();
+ return true;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java
new file mode 100644
index 000000000..ebb1bd761
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java
@@ -0,0 +1,342 @@
+/* -*- 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.tabqueue;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+
+import android.annotation.TargetApi;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.graphics.PixelFormat;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.provider.Settings;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationManagerCompat;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+import org.mozilla.gecko.mozglue.SafeIntent;
+
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+
+/**
+ * On launch this Service displays a View over the currently running process with an action to open the url in Fennec
+ * immediately. If the user takes no action, allowing the runnable to be processed after the specified
+ * timeout (TOAST_TIMEOUT), the url is added to a file which is then read in Fennec on next launch, this allows the
+ * user to quickly queue urls to open without having to open Fennec each time. If the Service receives an Intent whilst
+ * the created View is still active, the old url is immediately processed and the View is re-purposed with the new
+ * Intent data.
+ * <p/>
+ * The SYSTEM_ALERT_WINDOW permission is used to allow us to insert a View from this Service which responds to user
+ * interaction, whilst still allowing whatever is in the background to be seen and interacted with.
+ * <p/>
+ * Using an Activity to do this doesn't seem to work as there's an issue to do with the native android intent resolver
+ * dialog not being hidden when the toast is shown. Using an IntentService instead of a Service doesn't work as
+ * each new Intent received kicks off the IntentService lifecycle anew which means that a new View is created each time,
+ * meaning that we can't quickly queue the current data and re-purpose the View. The asynchronous nature of the
+ * IntentService is another prohibitive factor.
+ * <p/>
+ * General approach taken is similar to the FB chat heads functionality:
+ * http://stackoverflow.com/questions/15975988/what-apis-in-android-is-facebook-using-to-create-chat-heads
+ */
+public class TabQueueService extends Service {
+ private static final String LOGTAG = "Gecko" + TabQueueService.class.getSimpleName();
+
+ private static final long TOAST_TIMEOUT = 3000;
+ private static final long TOAST_DOUBLE_TAP_TIMEOUT_MILLIS = 6000;
+
+ private WindowManager windowManager;
+ private View toastLayout;
+ private Button openNowButton;
+ private Handler tabQueueHandler;
+ private WindowManager.LayoutParams toastLayoutParams;
+ private volatile StopServiceRunnable stopServiceRunnable;
+ private HandlerThread handlerThread;
+ private ExecutorService executorService;
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ // Not used
+ return null;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ executorService = Executors.newSingleThreadExecutor();
+
+ handlerThread = new HandlerThread("TabQueueHandlerThread");
+ handlerThread.start();
+ tabQueueHandler = new Handler(handlerThread.getLooper());
+
+ windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
+
+ LayoutInflater layoutInflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE);
+ toastLayout = layoutInflater.inflate(R.layout.tab_queue_toast, null);
+
+ final Resources resources = getResources();
+
+ TextView messageView = (TextView) toastLayout.findViewById(R.id.toast_message);
+ messageView.setText(resources.getText(R.string.tab_queue_toast_message));
+
+ openNowButton = (Button) toastLayout.findViewById(R.id.toast_button);
+ openNowButton.setText(resources.getText(R.string.tab_queue_toast_action));
+
+ toastLayoutParams = new WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ WindowManager.LayoutParams.TYPE_PHONE,
+ WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL |
+ WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH |
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
+ PixelFormat.TRANSLUCENT);
+
+ toastLayoutParams.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
+ }
+
+ @Override
+ public int onStartCommand(final Intent intent, final int flags, final int startId) {
+ // If this is a redelivery then lets bypass the entire double tap to open now code as that's a big can of worms,
+ // we also don't expect redeliveries because of the short time window associated with this feature.
+ if (flags != START_FLAG_REDELIVERY) {
+ final Context applicationContext = getApplicationContext();
+ final SharedPreferences sharedPreferences = GeckoSharedPrefs.forApp(applicationContext);
+
+ final String lastUrl = sharedPreferences.getString(GeckoPreferences.PREFS_TAB_QUEUE_LAST_SITE, "");
+
+ final SafeIntent safeIntent = new SafeIntent(intent);
+ final String intentUrl = safeIntent.getDataString();
+
+ final long lastRunTime = sharedPreferences.getLong(GeckoPreferences.PREFS_TAB_QUEUE_LAST_TIME, 0);
+ final boolean isWithinDoubleTapTimeLimit = System.currentTimeMillis() - lastRunTime < TOAST_DOUBLE_TAP_TIMEOUT_MILLIS;
+
+ if (!TextUtils.isEmpty(lastUrl) && lastUrl.equals(intentUrl) && isWithinDoubleTapTimeLimit) {
+ // Background thread because we could do some file IO if we have to remove a url from the list.
+ tabQueueHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ // If there is a runnable around, that means that the previous process hasn't yet completed, so
+ // we will need to prevent it from running and remove the view from the window manager.
+ // If there is no runnable around then the url has already been added to the list, so we'll
+ // need to remove it before proceeding or that url will open multiple times.
+ if (stopServiceRunnable != null) {
+ tabQueueHandler.removeCallbacks(stopServiceRunnable);
+ stopSelfResult(stopServiceRunnable.getStartId());
+ stopServiceRunnable = null;
+ removeView();
+ } else {
+ TabQueueHelper.removeURLFromFile(applicationContext, intentUrl, TabQueueHelper.FILE_NAME);
+ }
+ openNow(safeIntent.getUnsafe());
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, "tabqueue-doubletap");
+ stopSelfResult(startId);
+ }
+ });
+
+ return START_REDELIVER_INTENT;
+ }
+
+ sharedPreferences.edit().putString(GeckoPreferences.PREFS_TAB_QUEUE_LAST_SITE, intentUrl)
+ .putLong(GeckoPreferences.PREFS_TAB_QUEUE_LAST_TIME, System.currentTimeMillis())
+ .apply();
+ }
+
+ if (stopServiceRunnable != null) {
+ // If we're already displaying a toast, keep displaying it but store the previous url.
+ // The open button will refer to the most recently opened link.
+ tabQueueHandler.removeCallbacks(stopServiceRunnable);
+ stopServiceRunnable.run(false);
+ } else {
+ try {
+ windowManager.addView(toastLayout, toastLayoutParams);
+ } catch (final SecurityException | WindowManager.BadTokenException e) {
+ Toast.makeText(this, getText(R.string.tab_queue_toast_message), Toast.LENGTH_SHORT).show();
+ showSettingsNotification();
+ }
+ }
+
+ stopServiceRunnable = new StopServiceRunnable(startId) {
+ @Override
+ public void onRun() {
+ addURLToTabQueue(intent, TabQueueHelper.FILE_NAME);
+ stopServiceRunnable = null;
+ }
+ };
+
+ openNowButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ tabQueueHandler.removeCallbacks(stopServiceRunnable);
+ stopServiceRunnable = null;
+ removeView();
+ openNow(intent);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, "tabqueue-now");
+ stopSelfResult(startId);
+ }
+ });
+
+ tabQueueHandler.postDelayed(stopServiceRunnable, TOAST_TIMEOUT);
+
+ return START_REDELIVER_INTENT;
+ }
+
+ private void openNow(Intent intent) {
+ Intent forwardIntent = new Intent(intent);
+ forwardIntent.setClassName(getApplicationContext(), AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ forwardIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(forwardIntent);
+
+ TabQueueHelper.removeNotification(getApplicationContext());
+
+ GeckoSharedPrefs.forApp(getApplicationContext()).edit().remove(GeckoPreferences.PREFS_TAB_QUEUE_LAST_SITE)
+ .remove(GeckoPreferences.PREFS_TAB_QUEUE_LAST_TIME)
+ .apply();
+
+ executorService.submit(new Runnable() {
+ @Override
+ public void run() {
+ int queuedTabCount = TabQueueHelper.getTabQueueLength(TabQueueService.this);
+ Telemetry.addToHistogram("FENNEC_TABQUEUE_QUEUESIZE", queuedTabCount);
+ }
+ });
+
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ private void showSettingsNotification() {
+ if (AppConstants.Versions.preMarshmallow) {
+ return;
+ }
+
+ final Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
+ intent.setData(Uri.parse("package:" + getPackageName()));
+ PendingIntent pendingIntent = PendingIntent.getActivity(this, intent.hashCode(), intent, 0);
+
+ final String text = getString(R.string.tab_queue_notification_settings);
+
+ final NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle()
+ .bigText(text);
+
+ final Notification notification = new NotificationCompat.Builder(this)
+ .setContentTitle(getString(R.string.pref_tab_queue_title))
+ .setContentText(text)
+ .setCategory(NotificationCompat.CATEGORY_ERROR)
+ .setStyle(style)
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setContentIntent(pendingIntent)
+ .setPriority(NotificationCompat.PRIORITY_MAX)
+ .setAutoCancel(true)
+ .addAction(R.drawable.ic_action_settings, getString(R.string.tab_queue_prompt_settings_button), pendingIntent)
+ .build();
+
+ NotificationManagerCompat.from(this).notify(R.id.tabQueueSettingsNotification, notification);
+ }
+
+ private void removeView() {
+ try {
+ windowManager.removeView(toastLayout);
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ // This can happen if the Service is killed by the system. If this happens the View will have already
+ // been removed but the runnable will have been kept alive.
+ Log.e(LOGTAG, "Error removing Tab Queue toast from service", e);
+ }
+ }
+
+ private void addURLToTabQueue(final Intent intent, final String filename) {
+ if (intent == null) {
+ // This should never happen, but let's return silently instead of crashing if it does.
+ Log.w(LOGTAG, "Error adding URL to tab queue - invalid intent passed in.");
+ return;
+ }
+ final SafeIntent safeIntent = new SafeIntent(intent);
+ final String intentData = safeIntent.getDataString();
+
+ // As we're doing disk IO, let's run this stuff in a separate thread.
+ executorService.submit(new Runnable() {
+ @Override
+ public void run() {
+ Context applicationContext = getApplicationContext();
+ final GeckoProfile profile = GeckoProfile.get(applicationContext);
+ int tabsQueued = TabQueueHelper.queueURL(profile, intentData, filename);
+ List<String> urls = TabQueueHelper.getLastURLs(applicationContext, filename);
+
+ TabQueueHelper.showNotification(applicationContext, tabsQueued, urls);
+
+ // Store the number of URLs queued so that we don't have to read and process the file to see if we have
+ // any urls to open.
+ // TODO: Use profile shared prefs when bug 1147925 gets fixed.
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(applicationContext);
+
+ prefs.edit().putInt(TabQueueHelper.PREF_TAB_QUEUE_COUNT, tabsQueued).apply();
+ }
+ });
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ handlerThread.quit();
+ }
+
+ /**
+ * A modified Runnable which additionally removes the view from the window view hierarchy and stops the service
+ * when run, unless explicitly instructed not to.
+ */
+ private abstract class StopServiceRunnable implements Runnable {
+
+ private final int startId;
+
+ public StopServiceRunnable(final int startId) {
+ this.startId = startId;
+ }
+
+ public void run() {
+ run(true);
+ }
+
+ public void run(final boolean shouldRemoveView) {
+ onRun();
+
+ if (shouldRemoveView) {
+ removeView();
+ }
+
+ stopSelfResult(startId);
+ }
+
+ public int getStartId() {
+ return startId;
+ }
+
+ public abstract void onRun();
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java
new file mode 100644
index 000000000..4f5baacdb
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java
@@ -0,0 +1,130 @@
+/* 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.tabqueue;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BrowserLocaleManager;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+
+import android.app.IntentService;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.support.annotation.Nullable;
+import android.support.annotation.WorkerThread;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationManagerCompat;
+import android.util.Log;
+
+/**
+ * An IntentService that displays a notification for a tab sent to this device.
+ *
+ * The expected Intent should contain:
+ * * Data: URI to open in the notification
+ * * EXTRA_TITLE: Page title of the URI to open
+ */
+public class TabReceivedService extends IntentService {
+ private static final String LOGTAG = "Gecko" + TabReceivedService.class.getSimpleName();
+
+ private static final String PREF_NOTIFICATION_ID = "tab_received_notification_id";
+
+ private static final int MAX_NOTIFICATION_COUNT = 1000;
+
+ public TabReceivedService() {
+ super(LOGTAG);
+ setIntentRedelivery(true);
+ }
+
+ @Override
+ protected void onHandleIntent(final Intent intent) {
+ // IntentServices don't keep the process alive so
+ // we need to do this every time. Ideally, we wouldn't.
+ final Resources res = getResources();
+ BrowserLocaleManager.getInstance().correctLocale(this, res, res.getConfiguration());
+
+ final String uri = intent.getDataString();
+ if (uri == null) {
+ Log.d(LOGTAG, "Received null uri – ignoring");
+ return;
+ }
+
+ final Intent notificationIntent = new Intent(Intent.ACTION_VIEW, intent.getData());
+ notificationIntent.putExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, true);
+ final PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);
+
+ final String notificationTitle = getNotificationTitle(intent.getStringExtra(BrowserContract.EXTRA_CLIENT_GUID));
+ final NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
+ builder.setSmallIcon(R.drawable.flat_icon);
+ builder.setContentTitle(notificationTitle);
+ builder.setWhen(System.currentTimeMillis());
+ builder.setAutoCancel(true);
+ builder.setContentText(uri);
+ builder.setContentIntent(contentIntent);
+
+ // Trigger "heads-up" notification mode on supported Android versions.
+ builder.setPriority(NotificationCompat.PRIORITY_HIGH);
+ final Uri notificationSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
+ if (notificationSoundUri != null) {
+ builder.setSound(notificationSoundUri);
+ }
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(this);
+ final int notificationId = getNextNotificationId(prefs.getInt(PREF_NOTIFICATION_ID, 0));
+ final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
+ notificationManager.notify(notificationId, builder.build());
+
+ // Save the ID last so if the Service is killed and the Intent is redelivered,
+ // the ID is unlikely to have been updated and we would re-use the the old one.
+ // This would prevent two identical notifications from appearing if the
+ // notification was shown during the previous Intent processing attempt.
+ prefs.edit().putInt(PREF_NOTIFICATION_ID, notificationId).apply();
+ }
+
+ /**
+ * @param clientGUID the guid of the client in the clients table
+ * @return the client's name from the clients table, if possible, else the brand name.
+ */
+ @WorkerThread
+ private String getNotificationTitle(@Nullable final String clientGUID) {
+ if (clientGUID == null) {
+ Log.w(LOGTAG, "Received null guid, using brand name.");
+ return AppConstants.MOZ_APP_DISPLAYNAME;
+ }
+
+ final Cursor c = getContentResolver().query(BrowserContract.Clients.CONTENT_URI,
+ new String[] { BrowserContract.Clients.NAME },
+ BrowserContract.Clients.GUID + "=?", new String[] { clientGUID }, null);
+ try {
+ if (c != null && c.moveToFirst()) {
+ return c.getString(c.getColumnIndex(BrowserContract.Clients.NAME));
+ } else {
+ Log.w(LOGTAG, "Device not found, using brand name.");
+ return AppConstants.MOZ_APP_DISPLAYNAME;
+ }
+ } finally {
+ if (c != null) {
+ c.close();
+ }
+ }
+ }
+
+ /**
+ * Notification IDs must be unique else a notification
+ * will be overwritten so we cycle them.
+ */
+ private int getNextNotificationId(final int currentId) {
+ if (currentId > MAX_NOTIFICATION_COUNT) {
+ return 0;
+ } else {
+ return currentId + 1;
+ }
+ }
+}