Files
tubestation/mobile/android/base/tabqueue/TabQueueService.java
2015-06-01 13:20:13 +01:00

295 lines
13 KiB
Java

/* -*- 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.mozglue.ContextUtils;
import org.mozilla.gecko.preferences.GeckoPreferences;
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.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
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 org.mozilla.gecko.util.ThreadUtils;
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 ContextUtils.SafeIntent safeIntent = new ContextUtils.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());
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 {
windowManager.addView(toastLayout, toastLayoutParams);
}
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);
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();
Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, "tabqueue-now");
executorService.submit(new Runnable() {
@Override
public void run() {
int queuedTabCount = TabQueueHelper.getTabQueueLength(TabQueueService.this);
Telemetry.addToHistogram("FENNEC_TABQUEUE_QUEUESIZE", queuedTabCount);
}
});
}
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 ContextUtils.SafeIntent safeIntent = new ContextUtils.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);
TabQueueHelper.showNotification(applicationContext, tabsQueued);
// 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();
tabQueueHandler = null;
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();
}
}