org.devtcg.five.provider.FiveSyncAdapter.java Source code

Java tutorial

Introduction

Here is the source code for org.devtcg.five.provider.FiveSyncAdapter.java

Source

/*
 * Copyright (C) 2010 Josh Guilfoyle <jasta@devtcg.org>
 *
 * 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, 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.
 */

package org.devtcg.five.provider;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.StatusLine;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.methods.HttpGet;
import org.devtcg.five.Constants;
import org.devtcg.five.R;
import org.devtcg.five.meta.data.Protos;
import org.devtcg.five.provider.AbstractTableMerger.SyncableColumns;
import org.devtcg.five.provider.util.SourceItem;
import org.devtcg.five.service.SyncContext;
import org.devtcg.five.service.SyncContext.CancelTrigger;
import org.devtcg.five.util.AuthHelper;
import org.devtcg.five.util.DbUtils;
import org.devtcg.five.util.streaming.FailfastHttpClient;
import org.devtcg.util.IOUtilities;

import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.res.Resources;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.util.Log;

import com.google.protobuf.CodedInputStream;

public class FiveSyncAdapter extends AbstractSyncAdapter {
    private static final FailfastHttpClient sClient = FailfastHttpClient.newInstance(null);

    private static final String RANGE_HEADER = "Range";
    private static final String CONTENT_RANGE_HEADER = "Content-Range";
    private static final String LAST_MODIFIED_HEADER = "X-Last-Modified";
    private static final String MODIFIED_SINCE_HEADER = "X-Modified-Since";

    private static final String FEED_ARTISTS = "artists";
    private static final String FEED_ALBUMS = "albums";
    private static final String FEED_SONGS = "songs";
    private static final String FEED_PLAYLISTS = "playlists";
    private static final String FEED_PLAYLIST_SONGS = "playlistSongs";

    private static final String TAG = "FiveSyncAdapter";

    private final SourceItem mSource;

    private final RecordDispatcher mArtistDispatcher = new ArtistRecordDispatcher();
    private final RecordDispatcher mAlbumDispatcher = new AlbumRecordDispatcher();
    private final RecordDispatcher mSongDispatcher = new SongRecordDispatcher();
    private final RecordDispatcher mPlaylistDispatcher = new PlaylistRecordDispatcher();
    private final RecordDispatcher mPlaylistSongDispatcher = new PlaylistSongRecordDispatcher();

    private final ContentValues mTmpValues = new ContentValues();

    public FiveSyncAdapter(Context context, FiveProvider provider) {
        super(context, provider);
        mSource = provider.mSource;
    }

    @Override
    public void getServerDiffs(SyncContext context, AbstractSyncProvider serverDiffs) {
        if (mSource != ((FiveProvider) serverDiffs).mSource)
            throw new IllegalStateException("What the hell happened here?");

        context.moreRecordsToGet = true;

        /* Source must have been deleted or something? */
        if (mSource.moveToFirst() == false)
            return;

        AuthHelper.setCredentials(sClient, mSource);

        long modifiedSince;

        SQLiteDatabase db = serverDiffs.getDatabase();
        db.beginTransaction();
        try {
            modifiedSince = getServerDiffsImpl(context, serverDiffs, FEED_ARTISTS);
            if (modifiedSince >= 0)
                getImageData(context, serverDiffs, FEED_ARTISTS, modifiedSince);

            modifiedSince = getServerDiffsImpl(context, serverDiffs, FEED_ALBUMS);
            if (modifiedSince >= 0)
                getImageData(context, serverDiffs, FEED_ALBUMS, modifiedSince);

            getServerDiffsImpl(context, serverDiffs, FEED_SONGS);
            getServerDiffsImpl(context, serverDiffs, FEED_PLAYLISTS);
            getServerDiffsImpl(context, serverDiffs, FEED_PLAYLIST_SONGS);

            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }

        /* This is a very naive implementation... */
        if (context.hasCanceled() == false && context.hasError() == false)
            context.moreRecordsToGet = false;
    }

    private static void markErrorUnlessCanceled(SyncContext context, Exception e) {
        if (!context.hasCanceled()) {
            Log.e(Constants.TAG, "Sync error", e);
            context.networkError = true;
            context.errorMessage = e.getMessage();
        }
    }

    private long getServerDiffsImpl(SyncContext context, AbstractSyncProvider serverDiffs, String feedType) {
        if (context.hasError() == true || context.hasCanceled() == true)
            return -1;

        String feedUrl = mSource.getFeedUrl(feedType);
        final HttpGet feeds = new HttpGet(feedUrl);
        final Thread currentThread = Thread.currentThread();

        context.trigger = new CancelTrigger() {
            public void onCancel() {
                feeds.abort();
                currentThread.interrupt();
            }
        };

        try {
            return getServerDiffsCancelable(context, serverDiffs, feedType, feeds);
        } finally {
            context.trigger = null;
        }
    }

    private long getServerDiffsCancelable(final SyncContext context, final AbstractSyncProvider serverDiffs,
            final String feedType, final HttpGet feedRequest) {
        /* TODO: Optimize with another URI inside the provider. */
        long modifiedSince = getModifiedSinceArgument(serverDiffs, feedType);
        feedRequest.setHeader(MODIFIED_SINCE_HEADER, String.valueOf(modifiedSince));

        if (context.hasCanceled() == true)
            return -1;

        Log.i(TAG, "Downloading changes from feed=" + feedRequest.getURI() + ", " + "starting at modifiedSince="
                + modifiedSince);

        /**
         * Abstract object to perform insert records (and delete records) into
         * the temporary provider passed here to store downloaded results from
         * the server.
         */
        final RecordDispatcher recordDispatcher = getRecordDispatcher(feedType);

        try {
            /**
             * Issue a request to download all entries from the server for the
             * given feed (artists, albums, etc) with a modification time
             * exceeding <code>modifiedSince</code>. The expected response is a
             * manually crafted protobufs stream first listing all server ids that have
             * been deleted followed by all records which have either been
             * modified or newly inserted.
             */
            sClient.execute(feedRequest, new ResponseHandler<Void>() {
                public Void handleResponse(HttpResponse response) throws ClientProtocolException, IOException {
                    if (context.hasCanceled())
                        return null;

                    StatusLine status = response.getStatusLine();
                    int statusCode = status.getStatusCode();

                    if (statusCode != HttpStatus.SC_OK)
                        throw new IOException("HTTP GET failed: " + status);

                    for (Header header : response.getAllHeaders())
                        System.out.println(header.getName() + ": " + header.getValue());

                    System.out.println(" ");

                    adjustNewestSyncTime(context, response);

                    HttpEntity entity = response.getEntity();
                    InputStream in = entity.getContent();
                    try {
                        CodedInputStream stream = CodedInputStream.newInstance(in);

                        int deleteCount = stream.readRawLittleEndian32();
                        while (deleteCount-- > 0 && context.hasCanceled() == false) {
                            long deletedId = stream.readRawLittleEndian64();
                            recordDispatcher.delete(context, serverDiffs, deletedId);
                        }

                        int modCount = stream.readRawLittleEndian32();
                        while (modCount-- > 0 && context.hasCanceled() == false) {
                            int size = stream.readRawLittleEndian32();
                            byte[] recordData = stream.readRawBytes(size);
                            Protos.Record record = Protos.Record.parseFrom(recordData);

                            /* Sanity check the record type returned by the server. */
                            validateRecordType(record.getType(), recordDispatcher);

                            recordDispatcher.insert(context, serverDiffs, record);
                        }
                    } finally {
                        IOUtilities.close(in);
                    }

                    return null;
                }
            });
        } catch (IOException e) {
            markErrorUnlessCanceled(context, e);
        }

        if (context.hasCanceled())
            return -1;

        return modifiedSince;
    }

    /**
     * Awkward way to validate that the record type from the server matches what
     * we expect based on our request. Compares the RecordDispatcher simply
     * because the API throughout the sync adapter prefers to work with string
     * feedTypes instead of integers aligning with the protobufs record types
     * for some silly reason.
     */
    private void validateRecordType(Protos.Record.Type type, RecordDispatcher dispatcher) {
        RecordDispatcher expected;

        switch (type) {
        case ARTIST:
            expected = mArtistDispatcher;
            break;
        case ALBUM:
            expected = mAlbumDispatcher;
            break;
        case SONG:
            expected = mSongDispatcher;
            break;
        case PLAYLIST:
            expected = mPlaylistDispatcher;
            break;
        case PLAYLIST_SONG:
            expected = mPlaylistSongDispatcher;
            break;
        default:
            throw new IllegalStateException("Server produced unknown record of type " + type);
        }

        if (expected != dispatcher) {
            throw new IllegalStateException("Server produced unusual record of type " + type
                    + " when we expected to dispatch with " + dispatcher);
        }
    }

    private void getImageData(SyncContext context, AbstractSyncProvider serverDiffs, String feedType,
            long modifiedSince) {
        if (context.hasError() == true || context.hasCanceled() == true)
            return;

        Uri localFeedUri = getLocalFeedUri(feedType);
        String tablePrefix = (feedType.equals(FEED_ALBUMS) ? "a." : "");
        Cursor newRecords = serverDiffs.query(localFeedUri,
                new String[] { AbstractTableMerger.SyncableColumns._ID,
                        AbstractTableMerger.SyncableColumns._SYNC_ID },
                tablePrefix + AbstractTableMerger.SyncableColumns._SYNC_TIME + " > " + modifiedSince, null, null);

        Resources res = getContext().getResources();

        int thumbWidth = res.getDimensionPixelSize(R.dimen.image_thumb_width);
        int thumbHeight = res.getDimensionPixelSize(R.dimen.image_thumb_height);

        int fullWidth = res.getDimensionPixelSize(R.dimen.large_artwork_width);
        int fullHeight = res.getDimensionPixelSize(R.dimen.large_artwork_height);

        try {
            while (newRecords.moveToNext() && !context.hasError() && !context.hasCanceled()) {
                long id = newRecords.getLong(0);
                long syncId = newRecords.getLong(1);

                try {
                    Uri localFeedItemUri = ContentUris.withAppendedId(localFeedUri, id);

                    if (feedType.equals(FEED_ARTISTS)) {
                        downloadFileAndUpdateProvider(context, serverDiffs,
                                mSource.getImageUrl(feedType, syncId, thumbWidth, thumbHeight),
                                Five.makeArtistPhotoUri(id), localFeedItemUri, Five.Music.Artists.PHOTO);
                    } else if (feedType.equals(FEED_ALBUMS)) {
                        downloadFileAndUpdateProvider(context, serverDiffs,
                                mSource.getImageUrl(feedType, syncId, thumbWidth, thumbHeight),
                                Five.makeAlbumArtworkUri(id), localFeedItemUri, Five.Music.Albums.ARTWORK);
                        downloadFileAndUpdateProvider(context, serverDiffs,
                                mSource.getImageUrl(feedType, syncId, fullWidth, fullHeight),
                                Five.makeAlbumArtworkBigUri(id), localFeedItemUri, Five.Music.Albums.ARTWORK_BIG);
                    }
                } catch (IOException e) {
                    markErrorUnlessCanceled(context, e);
                }
            }
        } finally {
            newRecords.close();
        }
    }

    /**
     * Issue an HTTP GET request and store the result in a content provider.
     * Also triggers an update to <code>localFeedItemUri</code>, storing
     * <code>localUri</code> in <code>columnToUpdate</code>.
     */
    private static void downloadFileAndUpdateProvider(SyncContext context, AbstractSyncProvider serverDiffs,
            String httpUrl, Uri localUri, Uri localFeedItemUri, String columnToUpdate) throws IOException {
        if (context.hasError() || context.hasCanceled())
            return;

        final HttpGet request = new HttpGet(httpUrl);
        final Thread currentThread = Thread.currentThread();

        context.trigger = new CancelTrigger() {
            public void onCancel() {
                request.abort();
                currentThread.interrupt();
            }
        };

        try {
            downloadFileAndUpdateProviderCancelable(context, serverDiffs, request, localUri, localFeedItemUri,
                    columnToUpdate);
        } finally {
            context.trigger = null;
        }
    }

    private static void downloadFileAndUpdateProviderCancelable(final SyncContext context,
            final AbstractSyncProvider serverDiffs, final HttpGet request, final Uri localUri,
            final Uri localFeedItemUri, final String columnToUpdate) throws ClientProtocolException, IOException {
        sClient.execute(request, new ResponseHandler<Void>() {
            public Void handleResponse(HttpResponse response) throws ClientProtocolException, IOException {
                if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK)
                    return null;

                if (context.hasCanceled())
                    return null;

                /*
                 * Access a temp file path (FiveProvider treats this as a
                 * special case when isTemporary is true and uses a temporary
                 * path to be moved manually during merging).
                 */
                ParcelFileDescriptor pfd = serverDiffs.openFile(localUri, "w");

                InputStream in = response.getEntity().getContent();
                OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(pfd);

                try {
                    IOUtilities.copyStream(in, out);

                    if (context.hasCanceled() == true)
                        return null;

                    /*
                     * Update the record to reflect the newly downloaded uri.
                     * During table merging we'll need to move the file and
                     * update the uri we store here.
                     */
                    ContentValues values = new ContentValues();
                    values.put(columnToUpdate, localUri.toString());
                    serverDiffs.update(localFeedItemUri, values, null, null);
                } finally {
                    if (in != null)
                        IOUtilities.close(in);

                    if (out != null)
                        IOUtilities.close(out);
                }

                return null;
            }
        });
    }

    private void adjustNewestSyncTime(SyncContext context, HttpResponse response) {
        Header header = response.getLastHeader(LAST_MODIFIED_HEADER);
        if (header == null)
            return;

        try {
            long lastModified = Long.parseLong(header.getValue());
            context.newestSyncTime = Math.max(context.newestSyncTime, lastModified);
        } catch (NumberFormatException e) {
            Log.w(TAG, "Couldn't understand " + LAST_MODIFIED_HEADER + " response header");
        }
    }

    private RecordDispatcher getRecordDispatcher(String feedType) {
        if (feedType.equals(FEED_ARTISTS))
            return mArtistDispatcher;
        else if (feedType.equals(FEED_ALBUMS))
            return mAlbumDispatcher;
        else if (feedType.equals(FEED_SONGS))
            return mSongDispatcher;
        else if (feedType.equals(FEED_PLAYLISTS))
            return mPlaylistDispatcher;
        else if (feedType.equals(FEED_PLAYLIST_SONGS))
            return mPlaylistSongDispatcher;

        throw new IllegalArgumentException();
    }

    private static Uri getLocalFeedUri(String feedType) {
        if (feedType.equals(FEED_ARTISTS))
            return Five.Music.Artists.CONTENT_URI;
        else if (feedType.equals(FEED_ALBUMS))
            return Five.Music.Albums.CONTENT_URI;
        else if (feedType.equals(FEED_SONGS))
            return Five.Music.Songs.CONTENT_URI;
        else if (feedType.equals(FEED_PLAYLISTS))
            return Five.Music.Playlists.CONTENT_URI;
        else if (feedType.equals(FEED_PLAYLIST_SONGS))
            return Five.Music.PlaylistSongs.CONTENT_URI;

        throw new IllegalArgumentException();
    }

    private long getModifiedSinceArgument(AbstractSyncProvider serverDiffs, String feedType) {
        Uri localFeedUri = getLocalFeedUri(feedType);

        /**
         * First check if the sync provider instance already has some entries
         * populated from a previously interrupted sync. If yes, the greatest
         * _SYNC_TIME of those records is considered our next starting point;
         * otherwise, look for the latest record in the main provider. If no
         * records are present, assume this is first-time sync and start with 0.
         * <p>
         * TODO: This query sucks, we need to issue something that effectively
         * does SELECT MAX(_SYNC_TIME).
         */
        String[] projection = new String[] { SyncableColumns._SYNC_TIME };
        String orderBy = SyncableColumns._SYNC_TIME + " DESC";

        long maxSyncTime = DbUtils.cursorForLong(serverDiffs.query(localFeedUri, projection, null, null, orderBy),
                -1);

        if (maxSyncTime < 0) {
            /* Check with the real thing. */
            maxSyncTime = DbUtils.cursorForLong(
                    getContext().getContentResolver().query(localFeedUri, projection, null, null, orderBy), -1);

            /*
             * Ok fine, no records have been synced, so start at 0 (which
             * fetches them all).
             */
            if (maxSyncTime < 0)
                return 0;
        }

        return maxSyncTime;
    }

    /**
     * Standard interface to simplify dispatching records received from a server
     * feed. Inserts into temporary provider to be later merged with the main
     * tables.
     */
    private abstract class RecordDispatcher {
        private final Uri mDeletedUri;

        public RecordDispatcher(Uri deletedUri) {
            mDeletedUri = deletedUri;
        }

        public abstract void insert(SyncContext context, AbstractSyncProvider serverDiffs, Protos.Record record);

        public void delete(SyncContext context, AbstractSyncProvider serverDiffs, long deletedId) {
            ContentValues values = mTmpValues;
            values.clear();
            values.put(SyncableColumns._SYNC_ID, deletedId);
            serverDiffs.insert(mDeletedUri, values);
        }
    }

    private class ArtistRecordDispatcher extends RecordDispatcher {
        public ArtistRecordDispatcher() {
            super(Five.Music.Artists.CONTENT_DELETED_URI);
        }

        @Override
        public void insert(SyncContext context, AbstractSyncProvider serverDiffs, Protos.Record record) {
            Protos.Artist artist = record.getArtist();
            ContentValues values = mTmpValues;
            values.clear();
            values.put(Five.Music.Artists._SYNC_ID, artist.getId());
            values.put(Five.Music.Artists._SYNC_TIME, artist.getSyncTime());
            values.put(Five.Music.Artists.MBID, artist.getMbid());
            values.put(Five.Music.Artists.NAME, artist.getName());
            values.put(Five.Music.Artists.DISCOVERY_DATE, artist.getDiscoveryDate());
            serverDiffs.insert(Five.Music.Artists.CONTENT_URI, values);
        }
    }

    private class AlbumRecordDispatcher extends RecordDispatcher {
        public AlbumRecordDispatcher() {
            super(Five.Music.Albums.CONTENT_DELETED_URI);
        }

        @Override
        public void insert(SyncContext context, AbstractSyncProvider serverDiffs, Protos.Record record) {
            Protos.Album album = record.getAlbum();
            ContentValues values = mTmpValues;
            values.clear();
            values.put(Five.Music.Albums._SYNC_ID, album.getId());
            values.put(Five.Music.Albums._SYNC_TIME, album.getSyncTime());
            values.put(Five.Music.Albums.MBID, album.getMbid());
            values.put(Five.Music.Albums.ARTIST_ID, album.getArtistId());
            values.put(Five.Music.Albums.NAME, album.getName());
            values.put(Five.Music.Albums.DISCOVERY_DATE, album.getDiscoveryDate());
            values.put(Five.Music.Albums.RELEASE_DATE, album.getReleaseDate());
            serverDiffs.insert(Five.Music.Albums.CONTENT_URI, values);
        }
    }

    private class SongRecordDispatcher extends RecordDispatcher {
        public SongRecordDispatcher() {
            super(Five.Music.Songs.CONTENT_DELETED_URI);
        }

        @Override
        public void insert(SyncContext context, AbstractSyncProvider serverDiffs, Protos.Record record) {
            Protos.Song song = record.getSong();
            ContentValues values = mTmpValues;
            values.clear();
            values.put(Five.Music.Songs._SYNC_ID, song.getId());
            values.put(Five.Music.Songs._SYNC_TIME, song.getSyncTime());
            values.put(Five.Music.Songs.SOURCE_ID, mSource.getId());
            values.put(Five.Music.Songs.MBID, song.getMbid());
            values.put(Five.Music.Songs.ARTIST_ID, song.getArtistId());
            values.put(Five.Music.Songs.ALBUM_ID, song.getAlbumId());
            values.put(Five.Music.Songs.BITRATE, song.getBitrate());
            values.put(Five.Music.Songs.LENGTH, song.getLength());
            values.put(Five.Music.Songs.TITLE, song.getTitle());
            values.put(Five.Music.Songs.TRACK, song.getTrack());
            values.put(Five.Music.Songs.MIME_TYPE, song.getMimeType());
            values.put(Five.Music.Songs.SIZE, song.getFilesize());
            serverDiffs.insert(Five.Music.Songs.CONTENT_URI, values);
        }
    }

    private class PlaylistRecordDispatcher extends RecordDispatcher {
        public PlaylistRecordDispatcher() {
            super(Five.Music.Playlists.CONTENT_DELETED_URI);
        }

        @Override
        public void insert(SyncContext context, AbstractSyncProvider serverDiffs, Protos.Record record) {
            Protos.Playlist playlist = record.getPlaylist();
            ContentValues values = mTmpValues;
            values.clear();
            values.put(Five.Music.Playlists._SYNC_ID, playlist.getId());
            values.put(Five.Music.Playlists._SYNC_TIME, playlist.getSyncTime());
            values.put(Five.Music.Playlists.NAME, playlist.getName());
            values.put(Five.Music.Playlists.CREATED_DATE, playlist.getCreatedDate());
            serverDiffs.insert(Five.Music.Playlists.CONTENT_URI, values);
        }
    }

    private class PlaylistSongRecordDispatcher extends RecordDispatcher {
        public PlaylistSongRecordDispatcher() {
            super(Five.Music.PlaylistSongs.CONTENT_DELETED_URI);
        }

        @Override
        public void insert(SyncContext context, AbstractSyncProvider serverDiffs, Protos.Record record) {
            Protos.PlaylistSong playlistSong = record.getPlaylistSong();
            ContentValues values = mTmpValues;
            values.clear();
            values.put(Five.Music.PlaylistSongs._SYNC_ID, playlistSong.getId());
            values.put(Five.Music.PlaylistSongs._SYNC_TIME, playlistSong.getSyncTime());
            values.put(Five.Music.PlaylistSongs.PLAYLIST_ID, playlistSong.getPlaylistId());
            values.put(Five.Music.PlaylistSongs.POSITION, playlistSong.getPosition());
            values.put(Five.Music.PlaylistSongs.SONG_ID, playlistSong.getSongId());
            serverDiffs.insert(Five.Music.PlaylistSongs.CONTENT_URI, values);
        }
    }
}