package edu.mit.mobile.android.locast.data;
/*
* Copyright (C) 2011 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.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Queue;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.impl.cookie.DateUtils;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Bitmap.CompressFormat;
import android.media.MediaScannerConnection;
import android.media.MediaScannerConnection.MediaScannerConnectionClient;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Binder;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.provider.BaseColumns;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.Video;
import android.util.Log;
import edu.mit.mobile.android.locast.Constants;
import edu.mit.mobile.android.locast.accounts.Authenticator;
import edu.mit.mobile.android.locast.net.NetworkClient;
import edu.mit.mobile.android.locast.net.NetworkClient.InputStreamWatcher;
import edu.mit.mobile.android.locast.net.NotificationProgressListener;
import edu.mit.mobile.android.locast.notifications.ProgressNotification;
import edu.mit.mobile.android.locast.ver2.R;
import edu.mit.mobile.android.utils.StreamUtils;
public class MediaSync extends Service implements MediaScannerConnectionClient {
private final static String TAG = MediaSync.class.getSimpleName();
private final boolean DEBUG = Constants.DEBUG;
/**
* If this many errors are encountered, media sync gives up.
*/
private static final int TOO_MANY_ERRORS = 5;
private final Map<String, ScanQueueItem> scanMap = new TreeMap<String, ScanQueueItem>();
private MediaScannerConnection msc;
private final Queue<String> toScan = new LinkedList<String>();
public static final String ACTION_SYNC_RESOURCES = "edu.mit.mobile.android.locast.ACTION_SYNC_RESOURCES";
private final IBinder mBinder = new LocalBinder();
public static final long TIMEOUT_LAST_SYNC = 10 * 1000 * 1000; // nanoseconds
public final static String DEVICE_EXTERNAL_MEDIA_PATH = "/locast/",
NO_MEDIA = ".nomedia";
protected final ConcurrentLinkedQueue<SyncQueueItem> mSyncQueue = new ConcurrentLinkedQueue<SyncQueueItem>();
private MessageDigest mDigest;
private final HashMap<Uri, Long> mRecentlySyncd = new HashMap<Uri, Long>();
private ContentResolver cr;
public MediaSync() {
super();
try {
mDigest = MessageDigest.getInstance("SHA-1");
} catch (final NoSuchAlgorithmException e) {
e.printStackTrace();
mDigest = null;
}
}
@Override
public IBinder onBind(Intent arg0) {
return mBinder;
}
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;
}
}
};
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
final Uri data = intent.getData();
final SyncQueueItem syncQueueItem = new SyncQueueItem(data,
intent.getExtras());
if (!mSyncQueue.contains(syncQueueItem) && !checkRecentlySyncd(data)) {
if (DEBUG) {
Log.d(TAG, "enqueueing " + syncQueueItem);
}
mSyncQueue.add(syncQueueItem);
} else {
if (DEBUG) {
Log.d(TAG, syncQueueItem.toString()
+ " already in the queue. Skipping.");
}
}
maybeStartTask();
return START_REDELIVER_INTENT;
}
private SyncTask mSyncTask;
private synchronized void maybeStartTask() {
if (mSyncTask == null) {
mSyncTask = new SyncTask();
mSyncTask.execute();
}
}
@Override
public void onCreate() {
super.onCreate();
cr = getContentResolver();
}
@Override
public void onDestroy() {
super.onDestroy();
if (mSyncTask != null) {
mSyncTask.cancel(true);
}
if (msc != null) {
msc.disconnect();
}
}
/**
* @param uri
* @return true if the item has been synchronized recently
*/
private boolean checkRecentlySyncd(Uri uri) {
synchronized (mRecentlySyncd) {
final Long lastSyncd = mRecentlySyncd.get(uri);
if (lastSyncd != null) {
return (System.nanoTime() - lastSyncd) < TIMEOUT_LAST_SYNC;
} else {
return false;
}
}
}
private void addUriToRecentlySyncd(Uri uri) {
synchronized (mRecentlySyncd) {
mRecentlySyncd.put(uri, System.nanoTime());
}
}
/**
* Goes through the queue and syncs all the items in it.
*
* @author steve
*
*/
private class SyncTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
int errorCount = 0;
while (!mSyncQueue.isEmpty()) {
try {
final SyncQueueItem qi = mSyncQueue.remove();
syncItemMedia(qi.uri);
addUriToRecentlySyncd(qi.uri);
} catch (final SyncException se) {
Log.e(TAG, se.getLocalizedMessage(), se);
errorCount++;
} catch (final IllegalArgumentException e){
Log.e(TAG, e.getLocalizedMessage(), e);
errorCount++;
}
if (errorCount >= TOO_MANY_ERRORS){
Log.e(TAG, "Too many errors. Stopping sync.");
break;
}
}
scheduleSelfDestruct();
mSyncTask = null;
return null;
}
@Override
protected void onCancelled() {
mSyncTask = null;
}
}
private class SyncQueueItem {
public SyncQueueItem(Uri uri, Bundle extras) {
this.uri = uri;
this.extras = extras;
}
Uri uri;
Bundle extras;
@Override
public boolean equals(Object o) {
final SyncQueueItem o2 = (SyncQueueItem) o;
return o == null ? false : (this.uri == null ? false : this.uri
.equals(o2.uri)
&& ((this.extras == null && o2.extras == null)
|| this.extras == null ? false : this.extras
.equals(o2.extras)));
}
@Override
public String toString() {
return SyncQueueItem.class.getSimpleName() + ": " + uri.toString()
+ ((extras != null) ? " with extras " + extras : "");
}
}
final static String[] PROJECTION = { CastMedia._ID, CastMedia._MIME_TYPE,
CastMedia._LOCAL_URI, CastMedia._MEDIA_URL, CastMedia._KEEP_OFFLINE,
CastMedia._PUBLIC_URI,
CastMedia._THUMB_LOCAL};
final static String[] CAST_PROJECTION = {Cast._ID, Cast._FAVORITED };
/**
* Synchronize the media of the given castMedia. It will download or upload
* as needed.
*
* @param castMediaUri
* @throws SyncException
*/
public void syncItemMedia(Uri castMediaUri) throws SyncException {
final Cursor castMedia = cr.query(castMediaUri, PROJECTION, null, null,
null);
final Uri castUri = CastMedia.getCast(castMediaUri);
final Cursor cast = cr.query(castUri, CAST_PROJECTION, null, null,
null);
try {
if (!castMedia.moveToFirst()) {
throw new IllegalArgumentException("uri " + castMediaUri
+ " has no content");
}
if (!cast.moveToFirst()){
throw new IllegalArgumentException(castMediaUri + " cast " + castUri + " has no content");
}
// cache the column numbers
final int mediaUrlCol = castMedia
.getColumnIndex(CastMedia._MEDIA_URL);
final int localUriCol = castMedia
.getColumnIndex(CastMedia._LOCAL_URI);
final boolean isFavorite = cast.getInt(cast.getColumnIndex(Cast._FAVORITED)) != 0;
final boolean keepOffline = castMedia.getInt(castMedia.getColumnIndex(CastMedia._KEEP_OFFLINE)) != 0;
final String mimeType = castMedia.getString(castMedia
.getColumnIndex(CastMedia._MIME_TYPE));
final boolean isImage = (mimeType != null) && mimeType.startsWith("image/");
// we don't need to sync this
if ("text/html".equals(mimeType)) {
return;
}
final Uri locMedia = castMedia.isNull(localUriCol) ? null : Uri
.parse(castMedia.getString(localUriCol));
final String pubMedia = castMedia.getString(mediaUrlCol);
final boolean hasLocMedia = locMedia != null
&& new File(locMedia.getPath()).exists();
final boolean hasPubMedia = pubMedia != null
&& pubMedia.length() > 0;
final String localThumb = castMedia.getString(castMedia.getColumnIndex(CastMedia._THUMB_LOCAL));
if (hasLocMedia && !hasPubMedia) {
final String uploadPath = castMedia.getString(castMedia.getColumnIndex(CastMedia._PUBLIC_URI));
uploadMedia(uploadPath, castMediaUri, mimeType, locMedia);
} else if (!hasLocMedia && hasPubMedia) {
// only have a public copy, so download it and store locally.
final Uri pubMediaUri = Uri.parse(pubMedia);
final File destfile = getFilePath(pubMediaUri);
// the following conditions indicate that the cast media should be downloaded.
if (keepOffline || isFavorite){
final boolean anythingChanged = downloadMediaFile(pubMedia,
destfile, castMediaUri);
// the below is inverted from what seems logical, because downloadMediaFile()
// will actually update the castmedia if it downloads anything. We'll only be getting
// here if we don't have any local record of the file, so we should make the association
// by ourselves.
if (!anythingChanged) {
File thumb = null;
if (isImage && localThumb == null){
thumb = destfile;
}
updateLocalFile(castMediaUri, destfile, thumb);
// disabled to avoid spamming the user with downloaded
// items.
// checkForMediaEntry(castMediaUri, pubMediaUri, mimeType);
}
}
}
} finally {
cast.close();
castMedia.close();
}
}
private void updateLocalFile(Uri castMediaUri, File localFile, File localThumbnail) {
final ContentValues cv = new ContentValues();
cv.put(CastMedia._LOCAL_URI, Uri.fromFile(localFile).toString());
if (localThumbnail != null){
cv.put(CastMedia._THUMB_LOCAL, Uri.fromFile(localFile).toString());
}
cv.put(MediaProvider.CV_FLAG_DO_NOT_MARK_DIRTY, true);
getContentResolver().update(castMediaUri, cv, null, null);
}
private void uploadMedia(String uploadPath, Uri castMediaUri,
String contentType, final Uri locMedia) throws SyncException {
// upload
try {
// TODO this should get the account info from something else.
final NetworkClient nc = NetworkClient.getInstance(this,
Authenticator.getFirstAccount(this));
nc.uploadContentWithNotification(this,
CastMedia.getCast(castMediaUri), uploadPath, locMedia,
contentType, NetworkClient.UploadType.FORM_POST);
} catch (final Exception e) {
final SyncException se = new SyncException(
getString(R.string.error_uploading_cast_video));
se.initCause(e);
throw se;
}
}
/**
* Gets, makes and verifies that the location is writable. Also checks that
* the special .nomedia file that tells Android to not index the path is
* present.
*
* @return the location to save locast media.
* @throws SyncException
*/
private File getSaveLocation() throws SyncException {
final File sdcardPath = Environment.getExternalStorageDirectory();
final File locastSavePath = new File(sdcardPath,
DEVICE_EXTERNAL_MEDIA_PATH);
if (!locastSavePath.exists()) {
locastSavePath.mkdirs();
}
if (!locastSavePath.canWrite()) {
throw new SyncException("cannot write to external storage '"
+ locastSavePath.getAbsolutePath() + "'");
}
// this special file tells Android's media framework to not index the
// given folder.
final File noMedia = new File(locastSavePath, NO_MEDIA);
if (!noMedia.exists()) {
try {
noMedia.createNewFile();
} catch (final IOException e) {
final SyncException se = new SyncException("cannot create "
+ NO_MEDIA + " file");
se.initCause(e);
throw se;
}
}
return locastSavePath;
}
/**
* @param pubUri
* public media uri
* @return The local path on disk for the given remote video.
* @throws SyncException
*/
public File getFilePath(Uri pubUri) throws SyncException {
// pull off the server's name for this file.
final String localFile = pubUri.getLastPathSegment();
final File saveFile = new File(getSaveLocation(), localFile);
return saveFile;
}
/**
* Checks the local file system and checks to see if the given media
* resource has been downloaded successfully already. If not, it will
* download it from the server and store it in the filesystem. This uses the
* last-modified header and file length to determine if a media resource is
* up to date.
*
* This method blocks for the course of the download, but shows a progress
* notification.
*
* @param pubUri
* the http:// uri of the public resource
* @param saveFile
* the file that the resource will be saved to
* @param castMediaUri
* the content:// uri of the cast
* @return true if anything has changed. False if this function has
* determined it doesn't need to do anything.
* @throws SyncException
*/
public boolean downloadMediaFile(String pubUri, File saveFile,
Uri castMediaUri) throws SyncException {
final NetworkClient nc = NetworkClient.getInstance(this,
Authenticator.getFirstAccount(this));
try {
boolean dirty = true;
//String contentType = null;
if (saveFile.exists()) {
final HttpResponse headRes = nc.head(pubUri);
final long serverLength = Long.valueOf(headRes.getFirstHeader(
"Content-Length").getValue());
// XXX should really be checking the e-tag too, but this will be
// fine for our application.
final Header remoteLastModifiedHeader = headRes
.getFirstHeader("last-modified");
long remoteLastModified = 0;
if (remoteLastModifiedHeader != null) {
remoteLastModified = DateUtils.parseDate(
remoteLastModifiedHeader.getValue()).getTime();
}
final HttpEntity entity = headRes.getEntity();
if (entity != null) {
entity.consumeContent();
}
if (saveFile.length() == serverLength
&& saveFile.lastModified() >= remoteLastModified) {
if (DEBUG) {
Log.i(TAG,
"Local copy of cast "
+ saveFile
+ " seems to be the same as the one on the server. Not re-downloading.");
}
dirty = false;
}
// fall through and re-download, as we have a different size
// file locally.
}
if (dirty) {
final Uri castUri = CastMedia.getCast(castMediaUri);
String castTitle = Cast.getTitle(this, castUri);
if (castTitle == null) {
castTitle = "untitled";
}
final HttpResponse res = nc.get(pubUri);
final HttpEntity ent = res.getEntity();
final ProgressNotification notification = new ProgressNotification(
this, getString(R.string.sync_downloading_cast,
castTitle), ProgressNotification.TYPE_DOWNLOAD,
PendingIntent.getActivity(this, 0, new Intent(
Intent.ACTION_VIEW, castUri)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), 0),
false);
final NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
final NotificationProgressListener npl = new NotificationProgressListener(
nm, notification, ent.getContentLength(), 0);
final InputStreamWatcher is = new InputStreamWatcher(
ent.getContent(), npl);
try {
if (DEBUG) {
Log.d(TAG, "Downloading " + pubUri + " and saving it in "
+ saveFile.getAbsolutePath());
}
final FileOutputStream fos = new FileOutputStream(saveFile);
StreamUtils.inputStreamToOutputStream(is, fos);
fos.close();
// set the file's last modified to match the remote.
// We can check this later to see if everything is up to
// date.
final Header lastModified = res
.getFirstHeader("last-modified");
if (lastModified != null) {
saveFile.setLastModified(DateUtils.parseDate(
lastModified.getValue()).getTime());
}
//contentType = ent.getContentType().getValue();
} finally {
npl.done();
ent.consumeContent();
is.close();
}
// XXX avoid this to prevent adding to local collection
// final String filePath = saveFile.getAbsolutePath();
// scanMediaItem(castMediaUri, filePath, contentType);
return true;
}
} catch (final Exception e) {
final SyncException se = new SyncException(
"Error downloading content item.");
se.initCause(e);
throw se;
}
return false;
}
/**
* Enqueues a media item to be scanned. onScanComplete() will be called once
* the item has been scanned successfully.
*
* @param castMediaUri
* @param filePath
* @param contentType
*/
public void scanMediaItem(Uri castMediaUri, String filePath,
String contentType) {
scanMap.put(filePath, new ScanQueueItem(castMediaUri, contentType));
if (msc == null) {
toScan.add(filePath);
this.msc = new MediaScannerConnection(this, this);
this.msc.connect();
} else if (msc.isConnected()) {
msc.scanFile(filePath, contentType);
// if we're not connected yet, we need to remember what we want
// scanned,
// so that we can queue it up once connected.
} else {
toScan.add(filePath);
}
}
public void onScanCompleted(String path, Uri locMediaUri) {
if (locMediaUri == null) {
Log.e(TAG, "Scan failed for newly downloaded content: " + path);
return;
}
final ScanQueueItem item = scanMap.get(path);
if (item == null) {
Log.e(TAG, "Couldn't find media item (" + path
+ ") in scan map, so we couldn't update any casts.");
return;
}
updateCastMediaLocalUri(item.castMediaUri, locMediaUri.toString(),
item.contentType);
}
private String sha1Sum(String data) {
if (mDigest == null) {
throw new RuntimeException("no message digest available");
}
mDigest.reset();
mDigest.update(data.getBytes());
return new BigInteger(mDigest.digest()).toString(16);
}
private class ThumbnailException extends Exception {
/**
*
*/
private static final long serialVersionUID = 4949920781556749566L;
public ThumbnailException() {
super();
}
public ThumbnailException(String message) {
super(message);
}
}
// TODO this should probably look to see if the thumbnail file already
// exists for the given media, but should check for updates too.
private String generateThumbnail(Uri castMedia, String mimeType,
String locMedia) throws ThumbnailException {
final long locId = ContentUris.parseId(Uri.parse(locMedia));
Bitmap thumb;
if (mimeType.startsWith("image/")) {
thumb = Images.Thumbnails.getThumbnail(getContentResolver(), locId,
Images.Thumbnails.MINI_KIND, null);
} else if (mimeType.startsWith("video/")) {
thumb = Video.Thumbnails.getThumbnail(getContentResolver(), locId,
Video.Thumbnails.MINI_KIND, null);
} else {
throw new IllegalArgumentException(
"cannot generate thumbnail for item with MIME type: '"
+ mimeType + "'");
}
if (thumb == null) {
throw new ThumbnailException(
"Android thumbnail generator returned null");
}
try {
final File outFile = new File(getCacheDir(), "thumb"
+ sha1Sum(locMedia) + ".jpg");
// final File outFile = File.createTempFile("thumb", ".jpg",
// getCacheDir());
if (!outFile.exists()) {
if (!outFile.createNewFile()) {
throw new IOException("cannot create new file");
}
if (DEBUG) {
Log.d(TAG, "attempting to save thumb in " + outFile);
}
final FileOutputStream fos = new FileOutputStream(outFile);
thumb.compress(CompressFormat.JPEG, 75, fos);
thumb.recycle();
fos.close();
if (DEBUG) {
Log.d(TAG, "generated thumbnail for " + locMedia
+ " and saved it in " + outFile.getAbsolutePath());
}
}
return Uri.fromFile(outFile).toString();
} catch (final IOException ioe) {
final ThumbnailException te = new ThumbnailException();
te.initCause(ioe);
throw te;
}
}
private void updateCastMediaLocalUri(Uri castMedia, String locMedia,
String mimeType) {
final ContentValues cvCastMedia = new ContentValues();
cvCastMedia.put(CastMedia._LOCAL_URI, locMedia);
cvCastMedia.put(MediaProvider.CV_FLAG_DO_NOT_MARK_DIRTY, true);
try {
final String locThumb = generateThumbnail(castMedia, mimeType,
locMedia);
if (locThumb != null) {
cvCastMedia.put(CastMedia._THUMB_LOCAL, locThumb);
}
} catch (final ThumbnailException e) {
Log.e(TAG, "could not generate thumbnail for " + locMedia + ": "
+ e.getLocalizedMessage());
e.printStackTrace();
}
if (DEBUG) {
Log.d(TAG, "new local uri " + locMedia + " for cast media " + castMedia);
}
getContentResolver().update(castMedia, cvCastMedia, null, null);
}
// TODO should this be on a separate thread?
public void onMediaScannerConnected() {
while (!toScan.isEmpty()) {
final String scanme = toScan.remove();
final ScanQueueItem item = scanMap.get(scanme);
this.msc.scanFile(scanme, item.contentType);
}
scheduleSelfDestruct();
}
private void scheduleSelfDestruct() {
mDoneTimeout.removeMessages(MSG_DONE);
mDoneTimeout.sendEmptyMessageDelayed(MSG_DONE, 5000);
}
private void stopIfQueuesEmpty() {
if (mSyncQueue.isEmpty() && toScan.isEmpty()) {
this.stopSelf();
}
}
private class ScanQueueItem {
public Uri castMediaUri;
public String contentType;
public ScanQueueItem(Uri castMediaUri, String contentType) {
this.castMediaUri = castMediaUri;
this.contentType = contentType;
}
}
/**
* Scans the media database to see if the given item is currently there. If
* it is, update the cast media to point to the local content: URI for it.
*
* @param context
* @param castMedia
* Local URI to the cast.
* @param pubUri
* public URI to the media file.
* @return local URI if it exists.
* @throws SyncException
*/
public String checkForMediaEntry(Uri castMedia, Uri pubUri, String mimeType)
throws SyncException {
final ContentResolver cr = getContentResolver();
String newLocUri = null;
final File destfile = getFilePath(pubUri);
if (mimeType == null) {
throw new SyncException("missing MIME type");
}
String[] projection;
String selection;
Uri contentUri;
if (mimeType.startsWith("image/")) {
projection = new String[] { Images.Media._ID, Images.Media.DATA };
selection = Images.Media.DATA + "=?";
contentUri = Images.Media.EXTERNAL_CONTENT_URI;
} else if (mimeType.startsWith("video/")) {
projection = new String[] { Video.Media._ID, Video.Media.DATA };
selection = Video.Media.DATA + "=?";
contentUri = Video.Media.EXTERNAL_CONTENT_URI;
} else {
throw new SyncException("unknown MIME type: '" + mimeType + "'");
}
final String[] selectionArgs = { destfile.getAbsolutePath() };
final Cursor mediaEntry = cr.query(contentUri, projection, selection,
selectionArgs, null);
try {
if (mediaEntry.moveToFirst()) {
newLocUri = ContentUris.withAppendedId(
contentUri,
mediaEntry.getLong(mediaEntry
.getColumnIndex(BaseColumns._ID))).toString();
}
} finally {
mediaEntry.close();
}
if (newLocUri != null) {
updateCastMediaLocalUri(castMedia, newLocUri, mimeType);
} else {
Log.e(TAG, "The media provider doesn't seem to know about "
+ destfile.getAbsolutePath()
+ " which is on the filesystem. Strange...");
}
return newLocUri;
}
public class LocalBinder extends Binder {
MediaSync getService() {
return MediaSync.this;
}
}
}