/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.google.android.apps.mytracks.io.sync;
import com.google.android.apps.mytracks.content.MyTracksProviderUtils;
import com.google.android.apps.mytracks.content.Track;
import com.google.android.apps.mytracks.io.file.exporter.KmzTrackExporter;
import com.google.android.apps.mytracks.io.file.importer.KmlFileTrackImporter;
import com.google.android.apps.mytracks.io.file.importer.KmzTrackImporter;
import com.google.android.apps.mytracks.io.file.importer.TrackImporter;
import com.google.android.apps.mytracks.io.sendtogoogle.SendToGoogleUtils;
import com.google.android.apps.mytracks.util.FileUtils;
import com.google.android.apps.mytracks.util.PreferencesUtils;
import com.google.android.gms.auth.GoogleAuthException;
import com.google.android.gms.auth.UserRecoverableAuthException;
import com.google.android.maps.mytracks.R;
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential;
import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpResponse;
import com.google.api.services.drive.Drive;
import com.google.api.services.drive.Drive.Changes;
import com.google.api.services.drive.Drive.Files;
import com.google.api.services.drive.model.About;
import com.google.api.services.drive.model.Change;
import com.google.api.services.drive.model.ChangeList;
import com.google.api.services.drive.model.File;
import com.google.api.services.drive.model.FileList;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* SyncAdapter to sync tracks with Google Drive.
*
* @author Jimmy Shih
*/
public class SyncAdapter extends AbstractThreadedSyncAdapter {
private static final String TAG = SyncAdapter.class.getSimpleName();
// drive.about.get fields. Contains one field, largestChangeId
private static final String ABOUT_GET_FIELDS = "largestChangeId";
private final Context context;
private final MyTracksProviderUtils myTracksProviderUtils;
private Drive drive;
private String driveAccountName; // the account name associated with the drive
private String folderId;
public SyncAdapter(Context context) {
super(context, true);
this.context = context;
this.myTracksProviderUtils = MyTracksProviderUtils.Factory.get(context);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority,
ContentProviderClient provider, SyncResult syncResult) {
if (!PreferencesUtils.getBoolean(
context, R.string.drive_sync_key, PreferencesUtils.DRIVE_SYNC_DEFAULT)) {
return;
}
if (account == null) {
return;
}
String googleAccount = PreferencesUtils.getString(
context, R.string.google_account_key, PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT);
if (googleAccount == null || googleAccount.equals(PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT)) {
return;
}
if (!googleAccount.equals(account.name)) {
return;
}
try {
GoogleAccountCredential credential = SendToGoogleUtils.getGoogleAccountCredential(
context, account.name, SendToGoogleUtils.DRIVE_SCOPE);
if (credential == null) {
return;
}
if (drive == null || !driveAccountName.equals(account.name)) {
drive = SyncUtils.getDriveService(credential);
driveAccountName = account.name;
}
folderId = getFolderId();
long largestChangeId = PreferencesUtils.getLong(
context, R.string.drive_largest_change_id_key);
if (largestChangeId == PreferencesUtils.DRIVE_LARGEST_CHANGE_ID_DEFAULT) {
performInitialSync();
} else {
performIncrementalSync(largestChangeId);
}
insertNewDriveFiles();
} catch (UserRecoverableAuthException e) {
SendToGoogleUtils.sendNotification(
context, account.name, e.getIntent(), SendToGoogleUtils.DRIVE_NOTIFICATION_ID);
} catch (GoogleAuthException e) {
Log.e(TAG, "GoogleAuthException", e);
} catch (UserRecoverableAuthIOException e) {
SendToGoogleUtils.sendNotification(
context, account.name, e.getIntent(), SendToGoogleUtils.DRIVE_NOTIFICATION_ID);
} catch (IOException e) {
Log.e(TAG, "IOException", e);
}
}
/**
* Gets the folder id..
*/
private String getFolderId() throws IOException {
File folder = SyncUtils.getMyTracksFolder(context, drive);
if (folder == null) {
throw new IOException("folder is null");
}
String id = folder.getId();
if (id == null) {
throw new IOException("folder id is null");
}
return id;
}
/**
* Performs initial sync.
*/
private void performInitialSync() throws IOException {
// Get the largest change id first to avoid race conditions
About about = drive.about().get().setFields(ABOUT_GET_FIELDS).execute();
long largestChangeId = about.getLargestChangeId();
// Get all the KML/KMZ files in the "My Drive:/My Tracks" folder
Files.List myTracksFolderRequest = drive.files()
.list().setQ(String.format(Locale.US, SyncUtils.MY_TRACKS_FOLDER_FILES_QUERY, folderId));
Map<String, File> myTracksFolderMap = getFiles(myTracksFolderRequest, true);
// Handle tracks that are already uploaded to Google Drive
Set<String> syncedDriveIds = updateSyncedTracks();
for (String driveId : syncedDriveIds) {
myTracksFolderMap.remove(driveId);
}
// Get all the KML/KMZ files in the "Shared with me:/" folder
Files.List sharedWithMeRequest = drive.files()
.list().setQ(SyncUtils.SHARED_WITH_ME_FILES_QUERY);
Map<String, File> sharedWithMeMap = getFiles(sharedWithMeRequest, false);
try {
insertNewTracks(myTracksFolderMap.values());
insertNewTracks(sharedWithMeMap.values());
PreferencesUtils.setLong(context, R.string.drive_largest_change_id_key, largestChangeId);
} catch (IOException e) {
// Remove all imported tracks
Cursor cursor = null;
try {
cursor = myTracksProviderUtils.getTrackCursor(SyncUtils.DRIVE_ID_TRACKS_QUERY, null, null);
if (cursor != null && cursor.moveToFirst()) {
do {
Track track = myTracksProviderUtils.createTrack(cursor);
if (!syncedDriveIds.contains(track.getDriveId())) {
myTracksProviderUtils.deleteTrack(context, track.getId());
}
} while (cursor.moveToNext());
}
} finally {
if (cursor != null) {
cursor.close();
}
}
throw e;
}
}
/**
* Updates synced tracks.
*
* @return drive ids of the synced tracks
*/
private Set<String> updateSyncedTracks() throws IOException {
Set<String> result = new HashSet<String>();
Cursor cursor = null;
try {
cursor = myTracksProviderUtils.getTrackCursor(SyncUtils.DRIVE_ID_TRACKS_QUERY, null, null);
if (cursor != null && cursor.moveToFirst()) {
do {
Track track = myTracksProviderUtils.createTrack(cursor);
String driveId = track.getDriveId();
if (driveId != null && !driveId.equals("")) {
if (!track.isSharedWithMe()) {
File driveFile = drive.files().get(driveId).execute();
if (SyncUtils.isInMyTracksAndValid(driveFile, folderId)) {
merge(track, driveFile);
result.add(driveId);
} else {
/*
* Track has a drive id, but the drive id is no longer valid.
* E.g., the file is moved to another folder. Clear the drive
* id.
*/
SyncUtils.updateTrack(myTracksProviderUtils, track, null);
}
}
}
} while (cursor.moveToNext());
}
} finally {
if (cursor != null) {
cursor.close();
}
}
return result;
}
/**
* Performs incremental sync.
*
* @param largestChangeId the largest change id
*/
private void performIncrementalSync(long largestChangeId) throws IOException {
// Handle deleted tracks
String driveDeletedList = PreferencesUtils.getString(
context, R.string.drive_deleted_list_key, PreferencesUtils.DRIVE_DELETED_LIST_DEFAULT);
if (!PreferencesUtils.DRIVE_DELETED_LIST_DEFAULT.equals(driveDeletedList)) {
String deletedIds[] = TextUtils.split(driveDeletedList, ";");
for (String driveId : deletedIds) {
deleteDriveFile(driveId, true);
}
PreferencesUtils.setString(
context, R.string.drive_deleted_list_key, PreferencesUtils.DRIVE_DELETED_LIST_DEFAULT);
}
// Handle edited tracks
String driveEditedList = PreferencesUtils.getString(
context, R.string.drive_edited_list_key, PreferencesUtils.DRIVE_EDITED_LIST_DEFAULT);
if (!PreferencesUtils.DRIVE_EDITED_LIST_DEFAULT.equals(driveEditedList)) {
String editedIds[] = TextUtils.split(driveEditedList, ";");
for (String id : editedIds) {
Track track = myTracksProviderUtils.getTrack(Long.valueOf(id));
if (track == null) {
continue;
}
if (track.isSharedWithMe()) {
continue;
}
String driveId = track.getDriveId();
if (driveId == null || driveId.equals("")) {
continue;
}
File driveFile = drive.files().get(driveId).execute();
if (SyncUtils.isInMyTracksAndValid(driveFile, folderId)) {
merge(track, driveFile);
}
}
PreferencesUtils.setString(
context, R.string.drive_edited_list_key, PreferencesUtils.DRIVE_EDITED_LIST_DEFAULT);
}
// Handle changes from Google Drive
Map<String, File> changes = new HashMap<String, File>();
long newLargestChangeId = getDriveChangesInfo(largestChangeId, changes);
if (newLargestChangeId != largestChangeId) {
Cursor cursor = null;
try {
// Get all the local tracks with drive file id
cursor = myTracksProviderUtils.getTrackCursor(SyncUtils.DRIVE_ID_TRACKS_QUERY, null, null);
if (cursor != null && cursor.moveToFirst()) {
do {
Track track = myTracksProviderUtils.createTrack(cursor);
String driveId = track.getDriveId();
if (changes.containsKey(driveId)) {
// Track has changed
File driveFile = changes.get(driveId);
if (driveFile == null) {
Log.d(TAG, "Delete local track " + track.getName());
myTracksProviderUtils.deleteTrack(context, track.getId());
} else {
if (SyncUtils.isInMyTracksAndValid(driveFile, folderId)
|| SyncUtils.isInSharedWithMe(driveFile)) {
merge(track, driveFile);
} else {
SyncUtils.updateTrack(myTracksProviderUtils, track, null);
}
}
changes.remove(driveId);
}
} while (cursor.moveToNext());
}
// Insert valid new drive file changes as new tracks
Iterator<String> iterator = changes.keySet().iterator();
while (iterator.hasNext()) {
String driveId = iterator.next();
File file = changes.get(driveId);
if (!SyncUtils.isInMyTracksAndValid(file, folderId)
&& !SyncUtils.isInSharedWithMeAndValid(file)) {
iterator.remove();
}
}
insertNewTracks(changes.values());
PreferencesUtils.setLong(context, R.string.drive_largest_change_id_key, newLargestChangeId);
} finally {
if (cursor != null) {
cursor.close();
}
}
}
}
/**
* Inserts new drive files from tracks without a drive id.
*/
private void insertNewDriveFiles() throws IOException {
Cursor cursor = null;
try {
cursor = myTracksProviderUtils.getTrackCursor(SyncUtils.NO_DRIVE_ID_TRACKS_QUERY, null, null);
long recordingTrackId = PreferencesUtils.getLong(context, R.string.recording_track_id_key);
if (cursor != null && cursor.moveToFirst()) {
do {
Track track = myTracksProviderUtils.createTrack(cursor);
if (track.getId() == recordingTrackId) {
continue;
}
// If not successful, the next sync will retry again
SyncUtils.insertDriveFile(
drive, folderId, context, myTracksProviderUtils, track, true, true);
} while (cursor.moveToNext());
}
} finally {
if (cursor != null) {
cursor.close();
}
}
}
/**
* Inserts new tracks from a collection of drive files.
*
* @param driveFiles the drive files
*/
private void insertNewTracks(Collection<File> driveFiles) throws IOException {
for (File driveFile : driveFiles) {
if (driveFile == null) {
return;
}
updateTrack(-1L, driveFile);
}
}
/**
* Gets all the files from a request.
*
* @param request the request
* @param excludeSharedWithMe true to exclude shared with me files
* @return a map of file id to file
*/
private Map<String, File> getFiles(Files.List request, boolean excludeSharedWithMe)
throws IOException {
Map<String, File> idToFileMap = new HashMap<String, File>();
do {
FileList files = request.execute();
for (File file : files.getItems()) {
if (excludeSharedWithMe && file.getSharedWithMeDate() != null) {
continue;
}
idToFileMap.put(file.getId(), file);
}
request.setPageToken(files.getNextPageToken());
} while (request.getPageToken() != null && request.getPageToken().length() > 0);
return idToFileMap;
}
/**
* Gets the drive changes info in the My Tracks folder, including deleted
* files.
*
* @param changeId the largest change id
* @param changes a map of drive id to file for the changes
* @return an updated largest change id
*/
private long getDriveChangesInfo(long changeId, Map<String, File> changes) throws IOException {
Changes.List request = drive.changes().list().setStartChangeId(changeId + 1);
do {
ChangeList changeList = request.execute();
long newId = changeList.getLargestChangeId().longValue();
for (Change change : changeList.getItems()) {
if (change.getDeleted()) {
changes.put(change.getFileId(), null);
} else {
File file = change.getFile();
if (file.getLabels().getTrashed()) {
changes.put(change.getFileId(), null);
} else {
changes.put(change.getFileId(), file);
}
}
}
if (newId > changeId) {
changeId = newId;
}
request.setPageToken(changeList.getNextPageToken());
} while (request.getPageToken() != null && request.getPageToken().length() > 0);
Log.d(TAG, "Got drive changes: " + changes.size() + " " + changeId);
return changeId;
}
/**
* Merges a track with a drive file.
*
* @param track the track
* @param driveFile the drive file
*/
private void merge(Track track, File driveFile) throws IOException {
long modifiedTime = track.getModifiedTime();
long driveModifiedTime = driveFile.getModifiedDate().getValue();
if (modifiedTime > driveModifiedTime) {
Log.d(TAG, "Updating track change for track " + track.getName() + " and drive file "
+ driveFile.getTitle());
if (!SyncUtils.updateDriveFile(
drive, driveFile, context, myTracksProviderUtils, track, true)) {
Log.e(TAG, "Unable to update drive file");
track.setModifiedTime(driveModifiedTime);
myTracksProviderUtils.updateTrack(track);
}
} else if (modifiedTime < driveModifiedTime) {
Log.d(TAG, "Updating drive change for track " + track.getName() + " and drive file "
+ driveFile.getTitle());
if (!updateTrack(track.getId(), driveFile)) {
Log.e(TAG, "Unable to update drive change");
// The track could have been deleted in the unsuccessful update
track = myTracksProviderUtils.getTrack(track.getId());
if (track != null) {
track.setModifiedTime(driveModifiedTime);
myTracksProviderUtils.updateTrack(track);
}
}
}
}
/**
* Updates a track based on a drive file. Returns true if successful.
*
* @param trackId the track id. -1L to insert a new track
* @param driveFile the drive file
*/
private boolean updateTrack(final long trackId, File driveFile) throws IOException {
Track track = null;
boolean success = false;
try {
track = importDriveFile(trackId, driveFile);
if (track == null) {
return false;
}
File updatedDriveFile;
String trackName = FileUtils.getName(driveFile.getTitle());
if (SyncUtils.isInMyTracks(driveFile, folderId) && !track.getName().equals(trackName)) {
track.setName(trackName);
/*
* The drive file title and the track name inside the drive file do not
* match, update the drive file.
*/
java.io.File file = null;
try {
file = SyncUtils.getTempFile(context, myTracksProviderUtils, track, true);
updatedDriveFile = SyncUtils.updateDriveFile(
drive, driveFile, trackName + "." + KmzTrackExporter.KMZ_EXTENSION, file, true);
if (updatedDriveFile == null) {
Log.e(TAG, "Unable to update drive file");
return false;
}
} finally {
if (file != null) {
file.delete();
}
}
} else {
updatedDriveFile = driveFile;
}
SyncUtils.updateTrack(myTracksProviderUtils, track, updatedDriveFile);
success = true;
return true;
} finally {
if (!success) {
// if the track is new, delete it
if (trackId == -1L && track != null) {
myTracksProviderUtils.deleteTrack(context, track.getId());
}
}
}
}
/**
* Imports drive file to track.
*
* @param trackId the track id. -1L to insert a new track
* @param driveFile the drive file
*/
private Track importDriveFile(long trackId, File driveFile) throws IOException {
InputStream inputStream = null;
try {
inputStream = downloadDriveFile(driveFile, true);
if (inputStream == null) {
Log.e(TAG, "Unable to import drive file. Input stream is null.");
return null;
}
TrackImporter trackImporter;
boolean useKmz = KmzTrackExporter.KMZ_EXTENSION.equals(driveFile.getFileExtension());
if (useKmz) {
if (trackId == -1L) {
Uri uri = myTracksProviderUtils.insertTrack(new Track());
trackId = Long.parseLong(uri.getLastPathSegment());
}
trackImporter = new KmzTrackImporter(context, trackId);
} else {
trackImporter = new KmlFileTrackImporter(context, trackId);
}
long importedId = trackImporter.importFile(inputStream);
if (importedId == -1L) {
Log.e(TAG, "Unable to import drive file. Imported id is -1L.");
return null;
}
Track track = myTracksProviderUtils.getTrack(importedId);
if (track == null) {
Log.e(TAG, "Unable to import drive file. Imported track is null.");
return null;
} else {
return track;
}
} catch (IOException e) {
Log.e(TAG, "Unable to import drive file.", e);
return null;
} finally {
if (inputStream != null) {
inputStream.close();
}
}
}
/**
* Deletes a drive file.
*
* @param driveId the drive id
* @param canRetry true if can retry the request
* @throws IOException
*/
private void deleteDriveFile(String driveId, boolean canRetry) throws IOException {
try {
File driveFile = drive.files().get(driveId).execute();
if (SyncUtils.isInMyTracks(driveFile, folderId)) {
if (!driveFile.getLabels().getTrashed()) {
drive.files().trash(driveId).execute();
}
// if trashed, ignore
} else if (SyncUtils.isInSharedWithMe(driveFile)) {
if (!driveFile.getLabels().getTrashed()) {
drive.files().delete(driveId).execute();
}
// if trashed, ignore
}
} catch (UserRecoverableAuthIOException e) {
throw e;
} catch (IOException e) {
if (canRetry) {
deleteDriveFile(driveId, false);
return;
}
Log.e(TAG, "Unable to delete Drive file for " + driveId, e);
}
}
/**
* Downloads a drive file.
*
* @param driveFile the drive file
*/
private InputStream downloadDriveFile(File driveFile, boolean canRetry) throws IOException {
if (driveFile.getDownloadUrl() == null || driveFile.getDownloadUrl().length() == 0) {
Log.d(TAG, "Drive file download url doesn't exist: " + driveFile.getTitle());
return null;
}
try {
HttpResponse httpResponse = drive.getRequestFactory()
.buildGetRequest(new GenericUrl(driveFile.getDownloadUrl())).execute();
if (httpResponse == null) {
Log.e(TAG, "http response is null");
return null;
}
return httpResponse.getContent();
} catch (UserRecoverableAuthIOException e) {
throw e;
} catch (IOException e) {
if (canRetry) {
return downloadDriveFile(driveFile, false);
}
throw e;
}
}
}