/** * QueueService.java * Copyright (C)2015 Nicholas Killewald * * This file is distributed under the terms of the BSD license. * The source package should have a LICENCE file at the toplevel. */ package net.exclaimindustries.tools; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.Iterator; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; import android.app.Service; import android.content.Intent; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.util.Log; /** * <p> * A <code>QueueService</code> is similar in theory to an {@link android.app.IntentService}, * with the exception that the <code>Intent</code> is stored in a queue and * dealt with that way. This also means the queue can be observed and iterated * as need be to, for instance, get a list of currently-waiting things to * process. * </p> * * <p> * Note that while <code>QueueService</code> has many superficial similarities * to <code>IntentService</code>, it is NOT a subclass of it. They just don't * work similarly enough under the hood to justify it. * </p> * * @author Nicholas Killewald */ public abstract class QueueService extends Service { private static final String DEBUG_TAG = "QueueService"; private volatile Looper mServiceLooper; private volatile ServiceHandler mServiceHandler; private final class ServiceHandler extends Handler { public ServiceHandler(Looper looper) { // WE'RE IN A THREAD NOW! super(looper); } public void handleMessage(Message msg) { // Quick! Hand this off to handleCommand! It might start ANOTHER // thread to deal with this. handleCommand((Intent)msg.obj); } } /** * Codes returned from onHandleIntent that tells the queue what to do next. */ protected enum ReturnCode { /** Queue should continue as normal. */ CONTINUE, /** * Queue should pause until resumed later. Useful for temporary * errors. The queue will not be emptied, and the Intent which caused * this pause won't be removed (though see {@link #COMMAND_RESUME_SKIP_FIRST}). */ PAUSE, /** * Queue should stop entirely and not be resumed. This implies the * queue will be emptied. */ STOP } /** * Internal prefix of serialized intent data. Don't change this unless you * know you'll be running multiple QueueServices, which is the sole reason * it's not static or final. */ protected String mInternalQueueFilePrefix = "Queue"; /** * Send an Intent with this extra data in it, set to one of the command * statics, to send a command. */ public static final String COMMAND_EXTRA = "net.exclaimindustries.tools.QUEUETHREAD_COMMAND"; /** * Command code sent to ask a paused QueueService to resume processing. */ public static final int COMMAND_RESUME = 0; /** * Command code sent to ask a paused QueueService to resume processing, * skipping the first thing in the queue. */ public static final int COMMAND_RESUME_SKIP_FIRST = 1; /** * Command code sent to ask a paused QueueService to give up entirely and * empty the queue (and by extension stop the service). Note that this is * NOT guaranteed to stop the queue if it is currently not paused. */ public static final int COMMAND_ABORT = 2; private Queue<Intent> mQueue; private Thread mThread; // Whether or not the queue is currently paused. private volatile boolean mIsPaused; public QueueService() { super(); // Give us a queue! mQueue = new ConcurrentLinkedQueue<>(); // And we're not paused by default. mIsPaused = false; } @Override public void onCreate() { super.onCreate(); // To recreate, we want to go through everything we have in storage in // the same order we wrote it out. String files[] = fileList(); // But the only files we're interested in are Queue# files. int count = 0; for(String s : files) { if(s.startsWith(mInternalQueueFilePrefix)) count++; } if(count >= 1) { // Now, open each one in order and have the deserializer deserialize // them. And because we're being paranoid today, make sure we // account for gaps in the numbering. int processed = 0; int i = 0; while(processed < count) { try { // All the queue files are named Queue#. We know there are // as many as the count variable. We don't know if all // those digits exist, though, so track how many files we // deserialized and stop when we run out. I really hope we // don't wind up in an infinite loop here. InputStream is = openFileInput(mInternalQueueFilePrefix + i); Intent intent = deserializeFromDisk(is); if(intent != null) mQueue.add(intent); try { is.close(); } catch (IOException e) { // Ignore this. } deleteFile(mInternalQueueFilePrefix + i); processed++; } catch (FileNotFoundException e) { // If we get here, we're apparently out of order. Log.w(DEBUG_TAG, "Couldn't find " + mInternalQueueFilePrefix + i + ", apparently we missed a number when writing..."); } i++; } // Always assume that a non-empty queue involved a pause somewhere. mIsPaused = true; } // Finally, restart the HandlerThread. We'll wait for further // instructions. HandlerThread thread = new HandlerThread("QueueService Handler"); thread.start(); mServiceLooper = thread.getLooper(); mServiceHandler = new ServiceHandler(mServiceLooper); } @Override public void onDestroy() { // Before destruction, serialize! Make it snappy! int i = 0; if(mQueue != null) { for(Intent in : mQueue) { try { serializeToDisk(in, openFileOutput(mInternalQueueFilePrefix + i, MODE_PRIVATE)); } catch (FileNotFoundException e) { // If we get an exception, complain about it and just move // on. Log.e(DEBUG_TAG, "Couldn't write queue entry to persistant storage! Stack trace follows..."); e.printStackTrace(); } i++; } } mServiceLooper.quit(); super.onDestroy(); } /** * Gets an iterator to the current queue. * * @return an iterator to the current queue */ public Iterator<Intent> getIterator() { return mQueue.iterator(); } /** * Gets how many items are currently in the queue. * * @return the number of items in the queue */ public int getSize() { return mQueue.size(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { // Here's a trick I picked up from IntentService... Message msg = mServiceHandler.obtainMessage(); msg.arg1 = startId; msg.obj = intent; mServiceHandler.sendMessage(msg); // We're not sticky. We don't want intents re-sent and we call stopSelf // whenever we want to stop entirely. return Service.START_NOT_STICKY; } /** * <p> * Handles the Intent sent in. Specifically, this looks at the Intent, * decides if it's a command or a work unit, and then either acts on the * command or shoves the Intent into the queue to be processed, starting the * queue-working thread if need be. This gets called on a separate thread * from the rest of the GUI (AND a separate thread from the queue worker). * The actual application-specific work happens in {@link #handleIntent(Intent)}. * </p> * * @param intent the incoming Intent */ private void handleCommand(Intent intent) { // First, check if this is a command message. if(intent.hasExtra(COMMAND_EXTRA)) { // If so, take command. Make sure it's a valid command. int command = intent.getIntExtra(COMMAND_EXTRA, -1); if(!isPaused()) { Log.w(DEBUG_TAG, "The queue isn't paused, ignoring the command..."); return; } if(command == -1) { // INVALID! Log.w(DEBUG_TAG, "Command Intent didn't have a valid command in it!"); return; } if(command != COMMAND_RESUME && command != COMMAND_ABORT && command != COMMAND_RESUME_SKIP_FIRST) { Log.w(DEBUG_TAG, "I don't know what sort of command " + command + " is supposed to be, ignoring..."); return; } // The thread should NOT be active right now! If it is, we're in // trouble! if(mThread != null && mThread.isAlive()) { Log.e(DEBUG_TAG, "isPaused returned true, but the thread is still alive? What?"); // Last ditch effort: Try to interrupt the thread to death. mThread.interrupt(); } mIsPaused = false; // It's a good command, send it off! if(command == COMMAND_RESUME) { // Simply restart the thread. The queue will start from where // it left off. Log.d(DEBUG_TAG, "Restarting the thread now..."); doNewThread(); } else if(command == COMMAND_RESUME_SKIP_FIRST) { Log.d(DEBUG_TAG, "Restarting the thread now, skipping the first Intent..."); if(mQueue.isEmpty()) { Log.w(DEBUG_TAG, "The queue is empty! There's nothing to skip!"); } else { mQueue.remove(); } doNewThread(); } else { // This is a COMMAND_ABORT. Simply empty the queue (but call // the callback first). Log.d(DEBUG_TAG, "Emptying out the queue (removing " + mQueue.size() + " Intents)..."); onQueueEmpty(false); mQueue.clear(); stopSelf(); } } else { // If this isn't a control message, add the intent to the queue. Log.d(DEBUG_TAG, "Enqueueing an Intent!"); mQueue.add(intent); // Next, if the thread isn't already running (AND we're not paused), // make it run. If it IS running, we'll just process the next one // in turn. if(isPaused() && resumeOnNewIntent()) { Log.d(DEBUG_TAG, "Queue was paused, resuming it now!"); if(mThread != null && mThread.isAlive()) { Log.e(DEBUG_TAG, "isPaused returned true, but the thread is still alive? What?"); // Last ditch effort: Try to interrupt the thread to death. mThread.interrupt(); } mIsPaused = false; doNewThread(); } else if(!isPaused() && (mThread == null || !mThread.isAlive())) { Log.d(DEBUG_TAG, "Starting the thread fresh..."); doNewThread(); } } } private void doNewThread() { // Only call this if the old thread isn't running. mThread = new Thread(new QueueThread(), "QueueService Runner"); mThread.start(); } /* (non-Javadoc) * @see android.app.Service#onBind(android.content.Intent) */ @Override public IBinder onBind(Intent arg0) { return null; } private class QueueThread implements Runnable { @Override public void run() { // Now! Loop through the queue! Intent i; if(!mQueue.isEmpty()) onQueueStart(); while(!mQueue.isEmpty()) { i = mQueue.peek(); Log.d(DEBUG_TAG, "Processing intent..."); ReturnCode r = handleIntent(i); Log.d(DEBUG_TAG, "Intent processed, return code is " + r); // Return check! if(r == ReturnCode.STOP) { // If the return code we got instructed us to stop entirely, // wipe the queue and bail out. Log.d(DEBUG_TAG, "Return said to stop, stopping now and abandoning " + mQueue.size() + " Intent(s)."); onQueueEmpty(false); mQueue.clear(); stopSelf(); return; } else if(r == ReturnCode.CONTINUE) { // CONTINUE means processing was a success, so we can yoink // the Intent from the front of the queue and scrap it. Log.d(DEBUG_TAG, "Return said to continue."); mQueue.remove(); } else if(r == ReturnCode.PAUSE) { // If we were told to pause, well, pause. We'll be told to // try again later. Log.d(DEBUG_TAG, "Return said to pause."); mIsPaused = true; onQueuePause(i); return; } } // If we got here, then hey! The thread's done! Log.d(DEBUG_TAG, "Processing complete."); onQueueEmpty(true); stopSelf(); } } /** * Returns whether or not the queue is currently paused. * * @return true if paused, false if not */ public boolean isPaused() { return mIsPaused; } /** * Called whenever a new data Intent comes in and the queue is paused to * determine if the queue should resume immediately. If this returns false, * the queue will remain paused until an explicit {@link #COMMAND_RESUME} * command Intent is sent. Note that the queue will always start if the * queue is empty. * * @return true to resume on a new Intent, false to remain paused */ protected abstract boolean resumeOnNewIntent(); /** * Subclasses get this called every time something from the queue comes in * to be processed. This will not be called on the main thread. There will * be no callback on successful processing of an individual Intent, but * {@link #onQueuePause(Intent)} will be called if the queue is paused, and * {@link #onQueueEmpty(boolean)} will be called at the end of all processing. * * @param i Intent to be processed * @return a ReturnCode indicating what the queue should do next */ protected abstract ReturnCode handleIntent(Intent i); /** * This gets called immediately before the first Intent is processed in a * given run of QueueService. That is to say, after the service is started * due to an Intent coming in OR every time the service is told to resume * after being paused. {@link #handleIntent(Intent)} will be called after * this returns. This would be a good place to set up wakelocks. */ protected abstract void onQueueStart(); /** * <p> * This gets called if the queue needs to be paused for some reason. The * Intent that caused the pause will be included. The thread will be killed * after this callback returns. However, {@link #isPaused()} will return * false if called during this callback. Try not to block it. * </p> * * <p> * Note that you aren't doing the actual pausing here. This method is just * here to do status updates or to inform the user that the queue is paused, * which might or might not require more input. If you need more * information as to exactly why the queue was paused, you can always stuff * more extras in the Intent during onHandleIntent before it gets here. * </p> * * <p> * Now would be a good time to release that wakelock you made back in * {@link #onQueueStart()}. * </p> * @param i Intent that caused the pause */ protected abstract void onQueuePause(Intent i); /** * <p> * This is called right after the queue is done processing and right before * the thread is killed and isn't paused. The boolean indicates if * processing was complete. If false, it means a {@link ReturnCode#STOP} * was received or {@link #COMMAND_ABORT} was sent. The queue will be * emptied AFTER this method returns. * </p> * * <p> * This would be another good place to release that {@link #onQueueStart()} * wakelock you've been holding onto. Onto which you've been holding. * </p> * * @param allProcessed true if the queue emptied normally, false if it was * aborted before all Intents were processed */ protected abstract void onQueueEmpty(boolean allProcessed); /** * <p> * Serializes the given Intent to disk for later re-reading. Note that at * this point, an Intent is solely used as a means of storing data. Which, * really, it can be, though I doubt that's why it was made. This gets * called at onDestroy time for each Intent left in the queue (if any are * left at all) so that they can be recreated at onCreate time to persist * the Service's state (there doesn't appear to be an onSaveInstanceState * like you'd get with Activities). * </p> * * <p> * Note that no checking is done to ensure you actually wrote anything to * the stream. If the result is a zero-byte file, that's your * responsibility to handle it at deserialize time. * </p> * * @param i the Intent to serialize * @param os what you'll be writing to */ protected abstract void serializeToDisk(Intent i, OutputStream os); /** * Deserializes an Intent previously written to disk by serializeToDisk. * This will be called once for each Intent found on disk, and will be * called in the order of the queue. All you have to do is pull back * whatever you wrote in serializeToDisk and get an Intent out of it. * * @param is what you'll be reading from * @return a new Intent to be processed at the right time (if null is * returned, it will be ignored) */ protected abstract Intent deserializeFromDisk(InputStream is); }