/* -*- 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. *
* 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. * * 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. * * 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