/*
* 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);
}
}
}