edu.mit.mobile.android.locast.data.Sync.java Source code

Java tutorial

Introduction

Here is the source code for edu.mit.mobile.android.locast.data.Sync.java

Source

package edu.mit.mobile.android.locast.data;

/*
 * Copyright (C) 2010  MIT Mobile Experience Lab
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */
import java.io.IOException;
import java.util.Date;
import java.util.concurrent.PriorityBlockingQueue;

import org.apache.http.HttpStatus;
import org.apache.http.client.HttpResponseException;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.database.Cursor;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Binder;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.util.Log;
import android.widget.Toast;
import edu.mit.mobile.android.locast.Constants;
import edu.mit.mobile.android.locast.net.NetworkClient;
import edu.mit.mobile.android.locast.net.NetworkProtocolException;
import edu.mit.mobile.android.locast.notifications.ProgressNotification;
import edu.mit.mobile.android.locast.ver2.R;
import edu.mit.mobile.android.locast.ver2.itineraries.ItineraryList;
import edu.mit.mobile.android.utils.LastUpdatedMap;

/**
 * A Simple Sync engine. Can do naive bi-directional sync with a server that exposes
 * a last modified or created date and a unique ID via a JSON API.
 *
 * To use, simply extend JsonSyncableItem and create the sync map. This mapping tells
 * the engine what JSON object properties sync to what local columns, as well as how
 * to recreate any objects for updating/creation.
 *
 *
 * @author stevep
 *
 */
public class Sync extends Service {
    public final static String TAG = "LocastSync";

    private static final boolean DEBUG = Constants.DEBUG;

    public final static String ACTION_CANCEL_SYNC = "edu.mit.mobile.android.locast.ACTION_CANCEL_SYNC";

    /**
     * When a sync request is a result of direct user action (eg. pressing a "sync" button),
     * set this boolean extra to true.
     */
    public final static String EXTRA_EXPLICIT_SYNC = "edu.mit.mobile.android.locast.EXTRA_EXPLICIT_SYNC";

    /**
     * If syncing a server URI that is destined for a specific local URI space, add the destination URI here.
     */
    public final static String EXTRA_DESTINATION_URI = "edu.mit.mobile.android.locast.EXTRA_DESTINATION_URI";

    public final static String EXTRA_DESTINATION_TYPE = "edu.mit.mobile.android.locast.EXTRA_DESTINATION_TYPE";

    private static final String CONTENT_TYPE_PREFIX_DIR = "vnd.android.cursor.dir";

    private static final long TIMEOUT_MAX_ITEM_WAIT = (long) (30 * 1e9), // nanoseconds
            TIMEOUT_AUTO_SYNC_MINIMUM = (long) (30 * 1e9); // nanoseconds

    private static final int TIMEOUT_SERVICE_AUTODESTRUCT = 60 * 1000; // milliseconds

    private final IBinder mBinder = new LocalBinder();
    private NetworkClient nc;
    private ContentResolver cr;
    //private Set<Uri> syncdItems;
    private final LastUpdatedMap<Uri> mLastUpdated = new LastUpdatedMap<Uri>(TIMEOUT_AUTO_SYNC_MINIMUM);
    private boolean mNotifiedUserAboutNetworkStatus = true;
    private boolean mShouldAlertUserOnSuccess = false;

    protected final PriorityBlockingQueue<SyncQueueItem> syncQueue = new PriorityBlockingQueue<SyncQueueItem>();

    private SyncTask currentSyncTask = null;

    private static int NOTIFICATION_SYNC = 0;

    private final BroadcastReceiver networkStateReceiver = new BroadcastReceiver() {

        @Override
        public void onReceive(Context context, Intent intent) {
            if (ConnectivityManager.CONNECTIVITY_ACTION.equals(intent.getAction())) {
                final NetworkInfo networkInfo = intent.getParcelableExtra(ConnectivityManager.EXTRA_NETWORK_INFO);

                // reset this when the network status changes.
                mNotifiedUserAboutNetworkStatus = true;
                switch (networkInfo.getState()) {
                case DISCONNECTED:
                case DISCONNECTING:
                    if (intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false)) {
                        stopSync();
                    }

                    break;

                case CONNECTED:
                    startSync();
                    break;

                }
            }
        }
    };

    /**
     * Stops the synchronization.
     */
    private void stopSync() {
        if (DEBUG) {
            Log.d(TAG, "Stopping sync");
        }
        if (currentSyncTask != null) {
            currentSyncTask.cancel(true);
            Toast.makeText(getApplicationContext(), R.string.error_sync_canceled, Toast.LENGTH_LONG).show();
            currentSyncTask = null;
        }
        syncQueue.clear();
        scheduleSelfDestruct();
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent != null) {
            if (Intent.ACTION_SYNC.equals(intent.getAction())) {
                if (intent.getBooleanExtra(EXTRA_EXPLICIT_SYNC, false)) {
                    mNotifiedUserAboutNetworkStatus = true;
                    mShouldAlertUserOnSuccess = true;
                }
                startSync(intent.getData(), intent.getExtras());
            } else if (ACTION_CANCEL_SYNC.equals(intent.getAction())) {
                stopSync();
            }
        } else {
            // restarted by system.
            startSync();
        }

        return START_NOT_STICKY;
    }

    @Override
    public void onCreate() {
        super.onCreate();

        nc = NetworkClient.getInstance(this);
        cr = getApplicationContext().getContentResolver();

        registerReceiver(networkStateReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        unregisterReceiver(networkStateReceiver);
    }

    private JsonSyncableItem getSyncItem(Uri toSync) {
        JsonSyncableItem syncItem;
        // XXX the below system needs to be reworked.
        final String contentType = getApplicationContext().getContentResolver().getType(toSync);
        if (MediaProvider.TYPE_COMMENT_DIR.equals(contentType)
                || MediaProvider.TYPE_COMMENT_ITEM.equals(contentType)) {
            syncItem = new Comment();

        } else if (MediaProvider.TYPE_CAST_DIR.equals(contentType)
                || MediaProvider.TYPE_CAST_ITEM.equals(contentType)) {
            syncItem = new Cast();

        } else if (MediaProvider.TYPE_ITINERARY_DIR.equals(contentType)
                || MediaProvider.TYPE_ITINERARY_ITEM.equals(contentType)) {
            syncItem = new Itinerary();

        } else if (MediaProvider.TYPE_CASTMEDIA_DIR.equals(contentType)
                || MediaProvider.TYPE_CASTMEDIA_ITEM.equals(contentType)) {
            syncItem = new CastMedia();

        } else if (MediaProvider.TYPE_EVENT_DIR.equals(contentType)
                || MediaProvider.TYPE_EVENT_ITEM.equals(contentType)) {
            syncItem = new Event();

        } else {
            throw new RuntimeException(
                    "URI " + toSync + " is syncable, but there is no type mapping for it in getSyncItem().");
        }
        return syncItem;
    }

    private void sync(Uri toSync, SyncProgressNotifier syncProgress, Bundle extras)
            throws SyncException, IOException {
        Uri localUri;
        if ("http".equals(toSync.getScheme()) || "https".equals(toSync.getScheme())) {
            if (!extras.containsKey(EXTRA_DESTINATION_URI)) {
                throw new IllegalArgumentException("missing EXTRA_DESTINATION_URI when syncing HTTP URIs");
            }

            localUri = (Uri) extras.get(EXTRA_DESTINATION_URI);
            if (!MediaProvider.canSync(localUri)) {
                throw new IllegalArgumentException("URI " + toSync + " is not syncable.");
            }
        } else {
            localUri = toSync;
        }
        if (!MediaProvider.canSync(localUri)) {
            throw new IllegalArgumentException("URI " + toSync + " is not syncable.");
        }

        sync(toSync, getSyncItem(localUri), syncProgress, extras);
    }

    /**
     * Sync the given URI to the server. Will compare dates and do a two-way sync.
     * Does not yet handle the case where both server and and local are modified
     * (collisions)
     *
     * @param toSync URI of the object to sync. Generally, this should be the dir URI
     * @param sync A new object that extends JsonSyncableItem. This is needed for the JSON
     * serialization routine in it, as well as the sync map.
     * @throws IOException
     */
    private void sync(Uri toSync, JsonSyncableItem sync, SyncProgressNotifier syncProgress, Bundle extras)
            throws SyncException, IOException {
        String netPath;
        boolean haveItemPubUri = false;

        if ("http".equals(toSync.getScheme()) || "https".equals(toSync.getScheme())) {
            netPath = toSync.getPath();
            toSync = (Uri) extras.get(EXTRA_DESTINATION_URI);
            haveItemPubUri = true;
        } else {
            netPath = null;
        }
        final String contentType = getContentResolver().getType(toSync);

        final Cursor c = cr.query(toSync, sync.getFullProjection(), null, null, null);
        try {
            syncProgress.addPendingTasks(c.getCount());

            // Handle a list of items.
            if (contentType.startsWith(CONTENT_TYPE_PREFIX_DIR)) {
                // load from the network first...
                if (netPath == null) {
                    netPath = MediaProvider.getPublicPath(this, toSync);
                }
                syncNetworkList(toSync, netPath, sync, syncProgress);
            } else if (haveItemPubUri) {
                syncNetworkItem(toSync, netPath, sync, syncProgress);
            }

            // then load locally.

            if (DEBUG) {
                Log.d(TAG, "have " + c.getCount() + " local items to sync");
            }
            for (c.moveToFirst(); (currentSyncTask != null && !currentSyncTask.isCancelled()) && !c.isAfterLast(); c
                    .moveToNext()) {
                try {
                    syncItem(toSync, c, null, sync, syncProgress, netPath);
                    syncProgress.completeTask();

                } catch (final SyncItemDeletedException side) {
                    if (DEBUG) {
                        Log.d(TAG, side.getLocalizedMessage() + " Deleting...");
                    }
                    cr.delete(side.getItem(), null, null);
                    //side.printStackTrace();
                    syncProgress.completeTask();
                    continue;
                }
            }
        } catch (final NoPublicPath npp) {
            // XXX this should be a hard error
            Log.e(TAG, "Sync Error: " + npp.getLocalizedMessage());
            return;
        } finally {
            c.close();
        }
    }

    private void syncNetworkList(Uri toSync, String netPath, JsonSyncableItem sync,
            SyncProgressNotifier syncProgress) throws SyncException {
        JSONArray remObjs;

        try {
            remObjs = nc.getArray(netPath);
            // TODO figure out how to use getContentResolver().bulkInsert(url, values); for this:
            syncProgress.addPendingTasks(remObjs.length());

            for (int i = 0; (currentSyncTask != null && !currentSyncTask.isCancelled())
                    && i < remObjs.length(); i++) {
                final JSONObject jo = remObjs.getJSONObject(i);
                syncItem(toSync, null, jo, sync, syncProgress, null);
                syncProgress.completeTask();
            }
        } catch (final SyncException se) {
            throw se;
        } catch (final Exception e1) {
            String message = e1.getLocalizedMessage();
            if (message == null) {
                message = "caused by " + e1.getClass().getCanonicalName();
            }
            final SyncException se = new SyncException("Sync error: " + message);
            se.initCause(e1);
            throw se;
        }
    }

    private void syncNetworkItem(Uri toSync, String netPath, JsonSyncableItem sync,
            SyncProgressNotifier syncProgress) throws SyncException {
        JSONObject remObj;
        try {
            remObj = nc.getObject(netPath);
            syncProgress.addPendingTasks(1);

            syncItem(toSync, null, remObj, sync, syncProgress, null);
            syncProgress.completeTask();

        } catch (final SyncException se) {
            throw se;
        } catch (final Exception e1) {
            String message = e1.getLocalizedMessage();
            if (message == null) {
                message = "caused by " + e1.getClass().getCanonicalName();
            }
            final SyncException se = new SyncException("Sync error: " + message);
            se.initCause(e1);
            throw se;
        }
    }

    /**
     * Given a live cursor pointing to a data item and/or a set of contentValues loaded from the network,
     * attempt to sync.
     * Either c or cvNet can be null, but not both.
     * @param c A cursor pointing to the data item. Null is OK here.
     * @param jsonObject JSON object for the item as loaded from the network. null is OK here.
     * @param sync An empty JsonSyncableItem object.
     * @param publicPath TODO
     *
     * @return True if the item has been modified on either end.
     * @throws IOException
     */
    private boolean syncItem(Uri toSync, Cursor c, JSONObject jsonObject, JsonSyncableItem sync,
            SyncProgressNotifier syncProgress, String publicPath) throws SyncException, IOException {
        boolean modified = false;
        boolean needToCloseCursor = false;
        boolean toSyncIsIndex = false;
        final SyncMap syncMap = sync.getSyncMap();

        Uri locUri = null;
        final Uri origToSync = toSync;
        ContentValues cvNet = null;

        final Context context = getApplicationContext();
        final ContentResolver cr = context.getContentResolver();
        if (jsonObject != null) {
            if ("http".equals(toSync.getScheme()) || "https".equals(toSync.getScheme())) {
                // we successfully loaded it from the 'net, but toSync is really for local URIs. Erase it.

                toSync = sync.getContentUri();
                if (toSync == null) {
                    if (DEBUG) {
                        Log.w(TAG, "cannot get local URI for " + origToSync + ". Skipping...");
                    }
                    return false;
                }
            }
            try {
                cvNet = JsonSyncableItem.fromJSON(context, null, jsonObject, syncMap);
            } catch (final Exception e) {
                final SyncException se = new SyncException("Problem loading JSON object.");
                se.initCause(e);
                throw se;
            }
        }

        final String contentType = cr.getType(toSync);

        if (c != null) {
            if (contentType.startsWith(CONTENT_TYPE_PREFIX_DIR)) {
                locUri = ContentUris.withAppendedId(toSync, c.getLong(c.getColumnIndex(JsonSyncableItem._ID)))
                        .buildUpon().query(null).build();
                toSyncIsIndex = true;
            } else {
                locUri = toSync;
            }

            // skip any items already sync'd
            if (mLastUpdated.isUpdatedRecently(locUri)) {
                return false;
            }

            final int draftCol = c.getColumnIndex(TaggableItem._DRAFT);
            if (draftCol != -1 && c.getInt(draftCol) != 0) {
                if (DEBUG) {
                    Log.d(TAG, locUri + " is marked a draft. Not syncing.");
                }
                return false;
            }

            syncMap.onPreSyncItem(cr, locUri, c);
        } else if (contentType.startsWith(CONTENT_TYPE_PREFIX_DIR)) {
            // strip any query strings
            toSync = toSync.buildUpon().query(null).build();
        }
        //      if (c != null){
        //         MediaProvider.dumpCursorToLog(c, sync.getFullProjection());
        //      }
        // when the PUBLIC_URI is null, that means it's only local
        final int pubUriColumn = (c != null) ? c.getColumnIndex(JsonSyncableItem._PUBLIC_URI) : -1;
        if (c != null && (c.isNull(pubUriColumn) || c.getString(pubUriColumn) == "")) {
            // new content on the local side only. Gotta publish.

            try {
                jsonObject = JsonSyncableItem.toJSON(context, locUri, c, syncMap);
                if (publicPath == null) {
                    publicPath = MediaProvider.getPostPath(this, locUri);
                }
                if (DEBUG) {
                    Log.d(TAG, "Posting " + locUri + " to " + publicPath);
                }

                // The response from a post to create a new item should be the newly created item,
                // which contains the public ID that we need.
                jsonObject = nc.postJson(publicPath, jsonObject);

                final ContentValues cvUpdate = JsonSyncableItem.fromJSON(context, locUri, jsonObject, syncMap);
                if (cr.update(locUri, cvUpdate, null, null) == 1) {
                    // at this point, server and client should be in sync.
                    mLastUpdated.markUpdated(locUri);
                    if (DEBUG) {
                        Log.i(TAG, "Hooray! " + locUri + " has been posted succesfully.");
                    }

                } else {
                    Log.e(TAG, "update of " + locUri + " failed");
                }
                modified = true;

            } catch (final Exception e) {
                final SyncException se = new SyncException(getString(R.string.error_sync_no_post));
                se.initCause(e);
                throw se;
            }

            // only on the remote side, so pull it in.
        } else if (c == null && cvNet != null) {
            if (DEBUG) {
                Log.i(TAG, "Only on the remote side, using network-provided values.");
            }
            final String[] params = { cvNet.getAsString(JsonSyncableItem._PUBLIC_URI) };
            c = cr.query(toSync, sync.getFullProjection(), JsonSyncableItem._PUBLIC_URI + "=?", params, null);
            needToCloseCursor = true;

            if (!c.moveToFirst()) {
                locUri = cr.insert(toSync, cvNet);
                modified = true;
            } else {
                locUri = ContentUris.withAppendedId(toSync, c.getLong(c.getColumnIndex(JsonSyncableItem._ID)))
                        .buildUpon().query(null).build();
                syncMap.onPreSyncItem(cr, locUri, c);
            }
        }

        // we've now found data on both sides, so sync them.
        if (!modified && c != null) {

            publicPath = c.getString(c.getColumnIndex(JsonSyncableItem._PUBLIC_URI));

            try {

                if (cvNet == null) {
                    try {
                        if (publicPath == null && toSyncIsIndex && !MediaProvider.canSync(locUri)) {

                            // At this point, we've already checked the index and it doesn't contain the item (otherwise it would be in the syncdItems).
                            // If we can't sync individual items, it's possible that the index is paged or the item has been deleted.
                            if (DEBUG) {
                                Log.w(TAG, "Asked to sync " + locUri
                                        + " but item wasn't in server index and cannot sync individual entries. Skipping and hoping it is up to date.");
                            }
                            return false;

                        } else {
                            if (mLastUpdated.isUpdatedRecently(nc.getFullUri(publicPath))) {
                                if (DEBUG) {
                                    Log.d(TAG, "already sync'd! " + publicPath);
                                }
                                return false;
                            }
                            if (jsonObject == null) {
                                jsonObject = nc.getObject(publicPath);
                            }
                            cvNet = JsonSyncableItem.fromJSON(context, locUri, jsonObject, syncMap);

                        }
                    } catch (final HttpResponseException hre) {
                        if (hre.getStatusCode() == HttpStatus.SC_NOT_FOUND) {
                            final SyncItemDeletedException side = new SyncItemDeletedException(locUri);
                            side.initCause(hre);
                            throw side;
                        }
                    }
                }
                if (cvNet == null) {
                    Log.e(TAG, "got null values from fromJSON() on item " + locUri + ": "
                            + (jsonObject != null ? jsonObject.toString() : "<< no json object >>"));
                    return false;
                }
                final Date netLastModified = new Date(cvNet.getAsLong(JsonSyncableItem._MODIFIED_DATE));
                final Date locLastModified = new Date(c.getLong(c.getColumnIndex(JsonSyncableItem._MODIFIED_DATE)));

                if (netLastModified.equals(locLastModified)) {
                    // same! yay! We don't need to do anything.
                    if (DEBUG) {
                        Log.d("LocastSync", locUri + " doesn't need to sync.");
                    }
                } else if (netLastModified.after(locLastModified)) {
                    // remote is more up to date, update!
                    cr.update(locUri, cvNet, null, null);
                    if (DEBUG) {
                        Log.d("LocastSync", cvNet + " is newer than " + locUri);
                    }
                    modified = true;

                } else if (netLastModified.before(locLastModified)) {
                    // local is more up to date, propagate!
                    jsonObject = nc.putJson(publicPath, JsonSyncableItem.toJSON(context, locUri, c, syncMap));

                    if (DEBUG) {
                        Log.d("LocastSync", cvNet + " is older than " + locUri);
                    }
                    modified = true;
                }
                mLastUpdated.markUpdated(nc.getFullUri(publicPath));
            } catch (final JSONException e) {
                final SyncException se = new SyncException(
                        "Item sync error for path " + publicPath + ": invalid JSON.");
                se.initCause(e);
                throw se;
            } catch (final NetworkProtocolException e) {
                final SyncException se = new SyncException(
                        "Item sync error for path " + publicPath + ": " + e.getHttpResponseMessage());
                se.initCause(e);
                throw se;
            } finally {
                if (needToCloseCursor) {
                    c.close();
                    needToCloseCursor = false;
                }
            }
        }

        if (needToCloseCursor) {
            c.close();
        }

        if (locUri == null) {
            throw new RuntimeException("Never got a local URI for a sync'd item.");
        }

        // two calls are made in two different contexts. Which context you use depends on the application.
        syncMap.onPostSyncItem(context, locUri, jsonObject, modified);
        sync.onPostSyncItem(context, locUri, jsonObject, modified);

        mLastUpdated.markUpdated(locUri);

        // needed for things that may have requested a sync with a different URI than what was eventually produced.
        if (origToSync != locUri) {
            mLastUpdated.markUpdated(origToSync);
            cr.notifyChange(origToSync, null);
        }

        return modified;
    }

    /**
     * Retrieve an object from the server
     * @param context
     * @param path
     * @param sync
     * @throws SyncException
     * @throws IOException
     */
    public static void loadItemFromServer(Context context, String path, JsonSyncableItem sync)
            throws SyncException, IOException {
        ContentValues cvNet;
        final NetworkClient nc = NetworkClient.getInstance(context);
        try {
            cvNet = JsonSyncableItem.fromJSON(context, null, nc.getObject(path), sync.getSyncMap());
        } catch (final JSONException e) {
            final SyncException se = new SyncException("Item sync error for path " + path + ": invalid JSON.");
            se.initCause(e);
            throw se;
        } catch (final NetworkProtocolException e) {
            final SyncException se = new SyncException(
                    "Item sync error for path " + path + ": " + e.getHttpResponseMessage());
            se.initCause(e);
            throw se;
        }

        context.getContentResolver().insert(sync.getContentUri(), cvNet);
    }

    @Override
    public IBinder onBind(Intent intent) {

        return mBinder;
    }

    public class LocalBinder extends Binder {
        Sync getService() {
            return Sync.this;
        }
    }

    private boolean isDataConnected() {
        // a check is done here as well as startSync() to avoid the queue getting filled when the network is down.
        final ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
        final NetworkInfo ni = cm.getActiveNetworkInfo();
        if (ni == null || !ni.isConnected()) {
            if (mNotifiedUserAboutNetworkStatus) {
                Toast.makeText(this, R.string.error_sync_no_data_network, Toast.LENGTH_LONG).show();
                mNotifiedUserAboutNetworkStatus = false;
            }
            if (DEBUG) {
                Log.d(TAG, "not synchronizing, as it appears that there's no network connection");
            }
            return false;
        } else {
            return ni.isConnected();
        }

    }

    /**
     * Start the sync, if it isn't already started.
     */
    public void startSync() {
        if (!isDataConnected()) {
            return;
        }

        if (currentSyncTask == null || currentSyncTask.getStatus() == AsyncTask.Status.FINISHED) {
            currentSyncTask = new SyncTask();
            currentSyncTask.execute();
        }
    }

    /**
     * Adds the given URI to the sync queue and starts the sync.
     *
     * @param uri
     */
    public void startSync(Uri uri, Bundle extras) {
        if (!isDataConnected()) {
            return;
        }

        if (!syncQueue.contains(uri)) {
            if (!mLastUpdated.isUpdatedRecently(uri)
                    || (extras != null && extras.containsKey(EXTRA_EXPLICIT_SYNC))) {
                if (DEBUG) {
                    Log.d(TAG, "enqueing " + uri + " to sync queue");
                }
                syncQueue.add(new SyncQueueItem(uri, extras));
            } else {
                if (DEBUG) {
                    Log.d(TAG, "NOT enqueing " + uri + " to sync queue, as it's been updated recently");
                }
            }

        } else {
            if (DEBUG) {
                Log.d(TAG, "NOT enqueing " + uri + " to sync queue, as it's already present");
            }
        }

        startSync();
    }

    private class SyncTask extends AsyncTask<Void, Void, Boolean> implements SyncProgressNotifier {
        private Exception e;
        private ProgressNotification notification;
        private NotificationManager nm;
        private Date startTime;

        private int mTaskTotal = 0;
        private int mTaskCompleted = 0;

        private static final int MSG_PROGRESS_ADD_TASKS = 0, MSG_PROGRESS_COMPLETE_TASKS = 1;

        // this is used, as publishProgress is not very good about garbage collector churn.
        private final Handler mProgressHandler = new Handler() {
            @Override
            public void handleMessage(Message msg) {
                switch (msg.what) {
                case MSG_PROGRESS_ADD_TASKS:
                    mTaskTotal += msg.arg1;
                    break;

                case MSG_PROGRESS_COMPLETE_TASKS:
                    mTaskCompleted += msg.arg1;
                    break;
                }
                updateProgressBars();
            };
        };

        private void updateProgressBars() {
            if (!isCancelled()) {
                notification.setProgress(mTaskTotal, mTaskCompleted);
                nm.notify(NOTIFICATION_SYNC, notification);
            }
        }

        @Override
        protected void onPreExecute() {
            super.onPreExecute();
            startTime = new Date();
            final Context context = getApplicationContext();
            notification = new ProgressNotification(getApplicationContext(), R.drawable.stat_notify_sync);

            notification.contentIntent = PendingIntent.getActivity(context, 0,
                    new Intent(context, ItineraryList.class).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
                    PendingIntent.FLAG_UPDATE_CURRENT);
            notification.setProgress(0, 0);
            notification.setTitle(getText(R.string.sync_notification));
            nm = (NotificationManager) getApplication().getSystemService(NOTIFICATION_SERVICE);
            nm.notify(NOTIFICATION_SYNC, notification);

        }

        @Override
        protected Boolean doInBackground(Void... params) {

            try {
                while (!syncQueue.isEmpty()) {
                    if (DEBUG) {
                        Log.d(TAG, syncQueue.size() + " items in the sync queue");
                    }
                    final SyncQueueItem toSync = syncQueue.remove();
                    if (toSync.isStale()) {
                        if (DEBUG) {
                            Log.i(TAG, toSync + " is stale; skipping");
                        }
                        continue;
                    }
                    Sync.this.sync(toSync.uri, this, toSync.extras);

                }
            } catch (final SyncException e) {
                e.printStackTrace();
                this.e = e;
                return false;
            } catch (final IOException e) {
                this.e = e;
                e.printStackTrace();
                return false;
            }

            return true;
        }

        @Override
        protected void onPostExecute(Boolean result) {
            if (result) {
                // only show a notification if the user has been waiting for a bit.
                if (mShouldAlertUserOnSuccess && (new Date().getTime() - startTime.getTime()) > 10000) {
                    Toast.makeText(getApplicationContext(), R.string.sync_success, Toast.LENGTH_LONG).show();
                }
            } else {
                if (e instanceof SyncException) {
                    Toast.makeText(getApplicationContext(), e.getLocalizedMessage(), Toast.LENGTH_LONG).show();

                } else if (e instanceof IOException) {
                    Toast.makeText(getApplicationContext(), R.string.error_sync, Toast.LENGTH_LONG).show();
                }
            }
            mShouldAlertUserOnSuccess = false;

            nm.cancel(NOTIFICATION_SYNC);
            scheduleSelfDestruct();
        }

        public void addPendingTasks(int taskCount) {
            mProgressHandler.obtainMessage(MSG_PROGRESS_ADD_TASKS, taskCount, 0).sendToTarget();
        }

        public void completeTask() {
            mProgressHandler.obtainMessage(MSG_PROGRESS_COMPLETE_TASKS, 1, 0).sendToTarget();
        }

        @Override
        protected void onCancelled() {
            super.onCancelled();
            if (nm != null) {
                nm.cancel(NOTIFICATION_SYNC);
            }
        }
    }

    private void stopIfQueuesEmpty() {
        if (syncQueue.isEmpty()) {
            stopSelf();
        }
    }

    private static final int MSG_DONE = 0;

    private final Handler mDoneTimeout = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
            case MSG_DONE:
                stopIfQueuesEmpty();
                break;
            }
        }
    };

    private void scheduleSelfDestruct() {
        mDoneTimeout.removeMessages(MSG_DONE);
        mDoneTimeout.sendEmptyMessageDelayed(MSG_DONE, TIMEOUT_SERVICE_AUTODESTRUCT);
    }

    public interface SyncProgressNotifier {
        /**
         * Mark an added task as completed.
         */
        public void completeTask();

        /**
         * Adds a number of tasks to be completed.
         * @param taskCount
         */
        public void addPendingTasks(int taskCount);
    }

    private class SyncQueueItem implements Comparable<SyncQueueItem> {
        public SyncQueueItem(Uri uri, Bundle extras) {
            this.uri = uri;
            this.extras = extras;
            this.when = System.nanoTime();
        }

        public boolean isStale() {
            return (System.nanoTime() - when) >= TIMEOUT_MAX_ITEM_WAIT;
        }

        final Uri uri;
        final Bundle extras;
        final long when;

        /**
         * This compare function orders newest items first.
         *  (non-Javadoc)
         * @see java.lang.Comparable#compareTo(java.lang.Object)
         */
        @Override
        public int compareTo(SyncQueueItem another) {
            return -Long.valueOf(when).compareTo(Long.valueOf(another.when));
        }

    }
}