diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /mobile/android/base/java/org/mozilla/gecko/tabqueue | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-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')
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; + } + } +} |