diff --git a/MyTracks/AndroidManifest.xml b/MyTracks/AndroidManifest.xml index 0a7e83581..32e570d0e 100755 --- a/MyTracks/AndroidManifest.xml +++ b/MyTracks/AndroidManifest.xml @@ -41,6 +41,10 @@ limitations under the License. + + + + @@ -225,6 +229,16 @@ limitations under the License. + + + + + + diff --git a/MyTracks/res/drawable-hdpi/menu_sync_now.png b/MyTracks/res/drawable-hdpi/menu_sync_now.png new file mode 100644 index 000000000..bb9d855f7 Binary files /dev/null and b/MyTracks/res/drawable-hdpi/menu_sync_now.png differ diff --git a/MyTracks/res/drawable-mdpi/menu_sync_now.png b/MyTracks/res/drawable-mdpi/menu_sync_now.png new file mode 100644 index 000000000..bd611e8e2 Binary files /dev/null and b/MyTracks/res/drawable-mdpi/menu_sync_now.png differ diff --git a/MyTracks/res/drawable-xhdpi/menu_sync_now.png b/MyTracks/res/drawable-xhdpi/menu_sync_now.png new file mode 100644 index 000000000..a7fdc0dfc Binary files /dev/null and b/MyTracks/res/drawable-xhdpi/menu_sync_now.png differ diff --git a/MyTracks/res/menu/track_list.xml b/MyTracks/res/menu/track_list.xml index 3d5aae419..9f8034117 100644 --- a/MyTracks/res/menu/track_list.xml +++ b/MyTracks/res/menu/track_list.xml @@ -66,6 +66,10 @@ limitations under the License. android:icon="@drawable/menu_aggregated_statistics" android:id="@+id/track_list_aggregated_statistics" android:title="@string/menu_aggregated_statistics"/> + Stop GPS + + Sync now + read Google My Tracks data + + for account %1$s + + + Request permission + @@ -1353,7 +1368,7 @@ limitations under the License. installation when the app wants to write data to My Tracks."> write data to Google My Tracks and start/stop Google My Tracks recording - + - In track \'%1$s\'. + In track ‘%1$s’. Switch account + + This will remove sync with %1$s Google Drive. If sync again, all tracks will be treated as new. + + + This will sync with %1$s Google Drive ‘%2$s’ folder. All files in the folder will be + synced as new tracks. If not desired, move those files to a different folder beforehand. + + + Unable to get Google Drive permission. + + + diff --git a/MyTracks/src/com/google/android/apps/mytracks/TrackEditActivity.java b/MyTracks/src/com/google/android/apps/mytracks/TrackEditActivity.java index 0173d6f24..f625c0c4c 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/TrackEditActivity.java +++ b/MyTracks/src/com/google/android/apps/mytracks/TrackEditActivity.java @@ -116,6 +116,7 @@ public class TrackEditActivity extends AbstractMyTracksActivity { track.setCategory(category); track.setIcon(TrackIconUtils.getIconValue(TrackEditActivity.this, category)); track.setDescription(description.getText().toString()); + track.setModifiedTime(System.currentTimeMillis()); myTracksProviderUtils.updateTrack(track); finish(); } diff --git a/MyTracks/src/com/google/android/apps/mytracks/TrackListActivity.java b/MyTracks/src/com/google/android/apps/mytracks/TrackListActivity.java index 38bfbdd3f..16ba868d1 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/TrackListActivity.java +++ b/MyTracks/src/com/google/android/apps/mytracks/TrackListActivity.java @@ -30,6 +30,7 @@ import com.google.android.apps.mytracks.fragments.WelcomeDialogFragment; import com.google.android.apps.mytracks.io.file.ImportActivity; import com.google.android.apps.mytracks.io.file.SaveActivity; import com.google.android.apps.mytracks.io.file.TrackFileFormat; +import com.google.android.apps.mytracks.io.sync.SyncUtils; import com.google.android.apps.mytracks.services.ITrackRecordingService; import com.google.android.apps.mytracks.services.TrackRecordingServiceConnection; import com.google.android.apps.mytracks.settings.SettingsActivity; @@ -165,6 +166,12 @@ public class TrackListActivity extends FragmentActivity implements DeleteOneTrac R.string.recording_track_paused_key, PreferencesUtils.RECORDING_TRACK_PAUSED_DEFAULT); } + if (key == null || key.equals(PreferencesUtils.getKey( + TrackListActivity.this, R.string.drive_sync_key))) { + driveSync = PreferencesUtils.getBoolean(TrackListActivity.this, + R.string.drive_sync_key, + PreferencesUtils.DRIVE_SYNC_DEFAULT); + } if (key != null) { runOnUiThread(new Runnable() { @Override @@ -320,13 +327,15 @@ public class TrackListActivity extends FragmentActivity implements DeleteOneTrac private boolean metricUnits = PreferencesUtils.METRIC_UNITS_DEFAULT; private long recordingTrackId = PreferencesUtils.RECORDING_TRACK_ID_DEFAULT; private boolean recordingTrackPaused = PreferencesUtils.RECORDING_TRACK_PAUSED_DEFAULT; - + private boolean driveSync = PreferencesUtils.DRIVE_SYNC_DEFAULT; + // Menu items private MenuItem searchMenuItem; private MenuItem startGpsMenuItem; private MenuItem importAllMenuItem; private MenuItem saveAllMenuItem; private MenuItem deleteAllMenuItem; + private MenuItem syncNowMenuItem; private boolean startNewRecording = false; // true to start a new recording private boolean startGps = false; @@ -507,7 +516,8 @@ public class TrackListActivity extends FragmentActivity implements DeleteOneTrac importAllMenuItem = menu.findItem(R.id.track_list_import_all); saveAllMenuItem = menu.findItem(R.id.track_list_save_all); deleteAllMenuItem = menu.findItem(R.id.track_list_delete_all); - + syncNowMenuItem = menu.findItem(R.id.track_list_sync_now); + ApiAdapterFactory.getApiAdapter().configureSearchWidget(this, searchMenuItem); updateMenuItems(recordingTrackId != PreferencesUtils.RECORDING_TRACK_ID_DEFAULT); return true; @@ -562,6 +572,9 @@ public class TrackListActivity extends FragmentActivity implements DeleteOneTrac intent = IntentUtils.newIntent(this, AggregatedStatsActivity.class); startActivity(intent); return true; + case R.id.track_list_sync_now: + SyncUtils.syncNow(this); + return true; case R.id.track_list_settings: intent = IntentUtils.newIntent(this, SettingsActivity.class); startActivity(intent); @@ -669,6 +682,9 @@ public class TrackListActivity extends FragmentActivity implements DeleteOneTrac if (deleteAllMenuItem != null) { deleteAllMenuItem.setVisible(!isRecording); } + if (syncNowMenuItem != null) { + syncNowMenuItem.setVisible(driveSync); + } } /** diff --git a/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProvider.java b/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProvider.java index a767178fc..0c3c028f3 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProvider.java +++ b/MyTracks/src/com/google/android/apps/mytracks/content/MyTracksProvider.java @@ -16,6 +16,7 @@ package com.google.android.apps.mytracks.content; +import com.google.android.apps.mytracks.io.sync.SyncUtils; import com.google.android.apps.mytracks.util.PreferencesUtils; import com.google.android.maps.mytracks.R; import com.google.common.annotations.VisibleForTesting; @@ -49,8 +50,6 @@ public class MyTracksProvider extends ContentProvider { private static final String TAG = MyTracksProvider.class.getSimpleName(); private static final int DATABASE_VERSION = 21; - private static final String DRIVE_IDS_QUERY = TracksColumns.DRIVEID + " IS NOT NULL AND " - + TracksColumns.DRIVEID + "!=''"; @VisibleForTesting static final String DATABASE_NAME = "mytracks.db"; @@ -209,7 +208,7 @@ public class MyTracksProvider extends ContentProvider { getContext(), R.string.drive_sync_key, PreferencesUtils.DRIVE_SYNC_DEFAULT); if (driveSync) { driveIds = where != null ? getDriveIds(null, where, selectionArgs) - : getDriveIds(new String[] { TracksColumns.DRIVEID }, DRIVE_IDS_QUERY, null); + : getDriveIds(new String[] { TracksColumns.DRIVEID }, SyncUtils.DRIVE_IDS_QUERY, null); } } diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncAdapter.java b/MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncAdapter.java new file mode 100644 index 000000000..376695f91 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncAdapter.java @@ -0,0 +1,564 @@ +/* + * 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.KmlImporter; +import com.google.android.apps.mytracks.io.file.TrackFileFormat; +import com.google.android.apps.mytracks.io.file.TrackWriter; +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.extensions.android.http.AndroidHttp; +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential; +import com.google.api.client.http.FileContent; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.json.gson.GsonFactory; +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.Drive.Files.List; +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 com.google.api.services.drive.model.ParentReference; + +import android.accounts.Account; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.Context; +import android.content.Intent; +import android.content.SyncResult; +import android.database.Cursor; +import android.os.Bundle; +import android.os.RemoteException; +import android.support.v4.app.NotificationCompat; +import android.text.TextUtils; +import android.util.Log; + +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * SyncAdapter to sync tracks with Google Drive. + * + * @author Jimmy Shih + */ +public class SyncAdapter extends AbstractThreadedSyncAdapter { + + private static final String TAG = SyncAdapter.class.getSimpleName(); + private static final String KML_MIME_TYPE = "application/vnd.google-earth.kml+xml"; + private static final String FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; + private static final String GET_KML_FILES_QUERY = "'%s' in parents and mimeType = '" + + KML_MIME_TYPE + "' and trashed = false"; + private static final String GET_MY_TRACKS_FOLDER_QUERY = + "'root' in parents and title = '%s' and mimeType = '" + FOLDER_MIME_TYPE + + "' and trashed = false"; + private static final int NOTIFICATION_ID = 0; + + // drive.about.get fields to get the 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 + + 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; + } + + GoogleAccountCredential credential = getCredential(account.name); + if (credential == null) { + return; + } + + drive = getDriveService(account, credential); + if (drive == null) { + return; + } + + String folderId = getMyTracksFolder(); + if (folderId == null) { + return; + } + + try { + long largestChangeId = PreferencesUtils.getLong( + context, R.string.drive_largest_change_id_key); + if (largestChangeId == PreferencesUtils.DRIVE_LARGEST_CHANGE_ID_DEFAULT) { + performInitialSync(folderId); + } else { + performIncrementalSync(folderId, largestChangeId); + } + insertNewTracks(folderId); + } catch (Exception e) { + Log.e(TAG, "Exception", e); + } + } + + /** + * Gets the google account credential for an account. + * + * @param accountName the account name + */ + private GoogleAccountCredential getCredential(String accountName) { + try { + return SyncUtils.checkDrivePermission(context, accountName); + } catch (UserRecoverableAuthException e) { + Intent intent = e.getIntent(); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK).addFlags(Intent.FLAG_FROM_BACKGROUND); + + PendingIntent pendingIntent = PendingIntent.getActivity( + context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context).setAutoCancel( + true).setContentIntent(pendingIntent) + .setContentText(context.getString(R.string.permission_request_message, accountName)) + .setContentTitle(context.getString(R.string.permission_request_title)) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setTicker(context.getString(R.string.permission_request_title)); + NotificationManager notificationManager = (NotificationManager) context.getSystemService( + Context.NOTIFICATION_SERVICE); + notificationManager.notify(NOTIFICATION_ID, builder.build()); + } catch (IOException e) { + Log.e(TAG, "IOException", e); + } catch (GoogleAuthException e) { + Log.e(TAG, "GoogleAuthException", e); + } + return null; + } + + /** + * Gets the drive service. + * + * @param account the account + * @param credential the credential + */ + private Drive getDriveService(Account account, GoogleAccountCredential credential) { + if (drive == null || !driveAccountName.equals(account.name)) { + drive = new Drive.Builder(AndroidHttp.newCompatibleTransport(), new GsonFactory(), credential) + .build(); + driveAccountName = account.name; + } + return drive; + } + + /** + * Gets the My Tracks folder. + */ + private String getMyTracksFolder() { + try { + String folderName = context.getString(R.string.my_tracks_app_name); + List list = drive.files() + .list().setQ(String.format(Locale.US, GET_MY_TRACKS_FOLDER_QUERY, folderName)); + FileList result = list.execute(); + for (File file : result.getItems()) { + if (file.getTitle().equals(folderName)) { + return file.getId(); + } + } + File file = new File(); + file.setTitle(folderName); + file.setMimeType(FOLDER_MIME_TYPE); + return drive.files().insert(file).execute().getId(); + } catch (IOException e) { + Log.e(TAG, "IOException", e); + return null; + } + } + + /** + * Performs initial sync. + * + * @param folderId the folder id + */ + private void performInitialSync(String folderId) throws Exception { + + // 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 drive files + Files.List request = drive.files() + .list().setQ(String.format(Locale.US, GET_KML_FILES_QUERY, folderId)); + Map idToFileMap = new HashMap(); + + do { + try { + FileList files = request.execute(); + + for (File file : files.getItems()) { + idToFileMap.put(file.getId(), file); + } + request.setPageToken(files.getNextPageToken()); + } catch (IOException e) { + Log.e(TAG, "IOException", e); + request.setPageToken(null); + } + } while (request.getPageToken() != null && request.getPageToken().length() > 0); + + // Handle new drive files + insertNewDriveFiles(idToFileMap.values()); + PreferencesUtils.setLong(context, R.string.drive_largest_change_id_key, largestChangeId); + } + + /** + * Performs incremental sync. + * + * @param folderId the folder id + * @param largestChangeId the largest change id + */ + private void performIncrementalSync(String folderId, long largestChangeId) throws Exception { + + // Move to trash in Drive all deleted tracks + String driveDeletedList = PreferencesUtils.getString( + context, R.string.drive_deleted_list_key, PreferencesUtils.DRIVE_DELETED_LIST_DEFAULT); + String deletedIds[] = TextUtils.split(driveDeletedList, ";"); + for (String id : deletedIds) { + drive.files().trash(id).execute(); + } + PreferencesUtils.setString( + context, R.string.drive_deleted_list_key, PreferencesUtils.DRIVE_DELETED_LIST_DEFAULT); + + Map changes = new HashMap(); + largestChangeId = getDriveChanges(folderId, largestChangeId, changes); + + try { + // Get all the local tracks with drive file id + Cursor cursor = myTracksProviderUtils.getTrackCursor(SyncUtils.DRIVE_IDS_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(track.getId()); + } else { + mergeFiles(track, driveFile); + } + changes.remove(driveId); + } else { + + // Handle the case the track has changed + File driveFile = drive.files().get(driveId).execute(); + mergeFiles(track, driveFile); + } + } while (cursor.moveToNext()); + } + + // Handle new drive files + insertNewDriveFiles(changes.values()); + PreferencesUtils.setLong(context, R.string.drive_largest_change_id_key, largestChangeId); + } catch (IOException e) { + Log.e(TAG, "IOException", e); + } catch (RemoteException e) { + Log.e(TAG, "RemoteException", e); + } + } + + /** + * Inserts new tracks to Drive. + * + * @param folderId the folder id + */ + private void insertNewTracks(String folderId) throws IOException { + + // Get tracks without driveid + Cursor cursor = myTracksProviderUtils.getTrackCursor(SyncUtils.NO_DRIVE_ID_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; + } + + java.io.File file = getFile(track); + if (file == null) { + Log.e(TAG, "Unable to insert new track. File is null for " + track.getName()); + continue; + } + + try { + Log.d(TAG, "Add to Google Drive " + track.getName()); + FileContent fileContent = new FileContent(KML_MIME_TYPE, file); + + // file's parent + ParentReference parentReference = new ParentReference(); + parentReference.setId(folderId); + ArrayList parents = new ArrayList(); + parents.add(parentReference); + + // file's metadata + File newMetaData = new File(); + newMetaData.setTitle(track.getName() + "." + TrackFileFormat.KML.getExtension()); + newMetaData.setMimeType(KML_MIME_TYPE); + newMetaData.setParents(parents); + + File insertedFile = drive.files().insert(newMetaData, fileContent).execute(); + if (insertedFile == null) { + Log.e(TAG, "Unable to insert new track. Inserted file is null for " + track.getName()); + } else { + track.setDriveId(insertedFile.getId()); + track.setModifiedTime(insertedFile.getModifiedDate().getValue()); + myTracksProviderUtils.updateTrack(track); + } + } finally { + file.delete(); + } + } while (cursor.moveToNext()); + } + } + + /** + * Inserts new Google Drive files. + * + * @param driveFiles a collection of drive files to insert + */ + private void insertNewDriveFiles(Collection driveFiles) throws Exception { + for (File driveFile : driveFiles) { + if (driveFile != null) { + InputStream inputStream = downloadDriveFile(driveFile); + if (inputStream != null) { + KmlImporter kmlImporter = new KmlImporter(context, -1L); + long[] tracksIds = kmlImporter.importFile(inputStream); + if (tracksIds.length == 1) { + Track track = myTracksProviderUtils.getTrack(tracksIds[0]); + track.setDriveId(driveFile.getId()); + track.setModifiedTime(driveFile.getModifiedDate().getValue()); + myTracksProviderUtils.updateTrack(track); + Log.d(TAG, "Add from Google Drive " + track.getName()); + } + } + } + } + } + + /** + * Gets the Drive changes. + * + * @param folderId the folder id + * @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 getDriveChanges(String folderId, long changeId, Map changes) { + try { + 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 (KML_MIME_TYPE.equals(file.getMimeType())) { + for (ParentReference parentReference : file.getParents()) { + if (parentReference.getId().equals(folderId)) { + changes.put(change.getFileId(), file.getLabels().getTrashed() ? null : file); + } + } + } + } + } + if (newId > changeId) { + changeId = newId; + } + request.setPageToken(changeList.getNextPageToken()); + } while (request.getPageToken() != null && request.getPageToken().length() > 0); + } catch (IOException e) { + Log.e(TAG, "IOException", e); + } + 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 mergeFiles(Track track, File driveFile) throws Exception { + long modifiedTime = track.getModifiedTime(); + long driveModifiedTime = driveFile.getModifiedDate().getValue(); + if (modifiedTime > driveModifiedTime) { + Log.d(TAG, "Updating track change " + track.getName()); + java.io.File file = getFile(track); + + long newModifiedTime = -1L; + if (file == null) { + + // Do not retry, skip this track change + Log.e(TAG, "Unable to update track change. File is null for " + track.getName()); + newModifiedTime = driveModifiedTime; + } else { + try { + FileContent fileContent = new FileContent(KML_MIME_TYPE, file); + + driveFile.setTitle(track.getName() + "." + TrackFileFormat.KML.getExtension()); + File updatedFile = drive.files() + .update(driveFile.getId(), driveFile, fileContent).execute(); + if (updatedFile == null) { + + // Do no retry, skip this track change + Log.e(TAG, "Unable to update track change. Update file is null for " + track.getName()); + newModifiedTime = driveModifiedTime; + } else { + newModifiedTime = updatedFile.getModifiedDate().getValue(); + } + } finally { + file.delete(); + } + } + if (modifiedTime != -1L) { + track.setModifiedTime(newModifiedTime); + myTracksProviderUtils.updateTrack(track); + } + } else if (modifiedTime < driveModifiedTime) { + Log.d(TAG, "Updating drive change " + track.getName()); + InputStream inputStream = downloadDriveFile(driveFile); + Track newTrack = null; + if (inputStream == null) { + + // Do not retry, skip this drive change + Log.e(TAG, "Unable to update drive change. Input stream is null for " + track.getName()); + newTrack = track; + } else { + KmlImporter kmlImporter = new KmlImporter(context, track.getId()); + long[] tracksIds = kmlImporter.importFile(inputStream); + if (tracksIds.length == 1) { + newTrack = myTracksProviderUtils.getTrack(tracksIds[0]); + } else { + + /* + * Do not retry, skip this drive change. Note at this point, the track + * waypoints and track points are deleted. + */ + Log.e( + TAG, "Unable to update drive change. Imported size is not 1 for " + track.getName()); + newTrack = track; + } + } + + if (newTrack != null) { + newTrack.setDriveId(driveFile.getId()); + newTrack.setModifiedTime(driveModifiedTime); + myTracksProviderUtils.updateTrack(newTrack); + } + } + } + + /** + * Downloads a drive file. + * + * @param driveFile the drive file + */ + private InputStream downloadDriveFile(File driveFile) { + if (driveFile.getDownloadUrl() != null && driveFile.getDownloadUrl().length() > 0) { + try { + HttpResponse httpResponse = drive.getRequestFactory() + .buildGetRequest(new GenericUrl(driveFile.getDownloadUrl())).execute(); + if (httpResponse != null) { + return httpResponse.getContent(); + } else { + Log.e(TAG, "http response is null"); + return null; + } + } catch (IOException e) { + Log.e(TAG, "IOException", e); + return null; + } + } else { + Log.d(TAG, "Drive file download url doesn't exist: " + driveFile.getTitle()); + return null; + } + } + + /** + * Gets a file from a track. + * + * @param track the track + */ + private java.io.File getFile(Track track) throws FileNotFoundException { + TrackFileFormat trackFileFormat = TrackFileFormat.KML; + + java.io.File directory = new java.io.File( + context.getCacheDir(), trackFileFormat.getExtension()); + if (!FileUtils.ensureDirectoryExists(directory)) { + Log.d(TAG, "Unable to create " + directory.getAbsolutePath()); + return null; + } + + java.io.File file = new java.io.File(directory, + FileUtils.buildUniqueFileName(directory, track.getName(), trackFileFormat.getExtension())); + + TrackWriter trackWriter = new TrackWriter( + context, myTracksProviderUtils, track, trackFileFormat, null); + trackWriter.writeTrack(new FileOutputStream(file)); + if (trackWriter.wasSuccess()) { + return file; + } + Log.d(TAG, "Unable to get file for track " + track.getName()); + return null; + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncService.java b/MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncService.java new file mode 100644 index 000000000..74d1e64c0 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncService.java @@ -0,0 +1,47 @@ +/* + * 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 android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +/** + * Service to sync with Google Drive. + * + * @author Jimmy Shih + */ +public class SyncService extends Service { + + private static final Object lock = new Object(); + + private static SyncAdapter syncAdapter = null; + + @Override + public void onCreate() { + synchronized (lock) { + if (syncAdapter == null) { + syncAdapter = new SyncAdapter(getApplicationContext()); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return syncAdapter.getSyncAdapterBinder(); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncUtils.java b/MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncUtils.java new file mode 100644 index 000000000..3681aedf1 --- /dev/null +++ b/MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncUtils.java @@ -0,0 +1,105 @@ +/* + * 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.Constants; +import com.google.android.apps.mytracks.content.TracksColumns; +import com.google.android.apps.mytracks.util.PreferencesUtils; +import com.google.android.gms.auth.GoogleAuthException; +import com.google.android.maps.mytracks.R; +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential; +import com.google.api.services.drive.DriveScopes; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.ContentResolver; +import android.content.Context; +import android.os.Bundle; + +import java.io.IOException; + +/** + * Utilites for Google Drive sync. + * + * @author Jimmy Shih + */ +public class SyncUtils { + + private SyncUtils() {} + + public static final String DRIVE_IDS_QUERY = TracksColumns.DRIVEID + " IS NOT NULL AND " + + TracksColumns.DRIVEID + "!=''"; + public static final String NO_DRIVE_ID_QUERY = TracksColumns.DRIVEID + " IS NULL OR " + + TracksColumns.DRIVEID + "=''"; + + private static final String SYNC_AUTHORITY = "com.google.android.maps.mytracks"; + + /** + * Checks permission to access Google Drive. + * + * @param context the context + * @param accountName the account name + */ + public static GoogleAccountCredential checkDrivePermission(Context context, String accountName) + throws IOException, GoogleAuthException { + GoogleAccountCredential credential = GoogleAccountCredential.usingOAuth2( + context, DriveScopes.DRIVE); + credential.setSelectedAccountName(accountName); + credential.getToken(); + return credential; + } + + /** + * Syncs now for the current account. + * + * @param context the context + */ + public static void syncNow(Context context) { + Account[] accounts = AccountManager.get(context).getAccountsByType(Constants.ACCOUNT_TYPE); + String googleAccount = PreferencesUtils.getString( + context, R.string.google_account_key, PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT); + for (Account account : accounts) { + if (account.name.equals(googleAccount)) { + ContentResolver.cancelSync(account, SYNC_AUTHORITY); + ContentResolver.requestSync(account, SYNC_AUTHORITY, new Bundle()); + break; + } + } + } + + /** + * Disables sync. + * + * @param account the account + */ + public static void disableSync(Account account) { + ContentResolver.cancelSync(account, SYNC_AUTHORITY); + ContentResolver.setIsSyncable(account, SYNC_AUTHORITY, 0); + ContentResolver.setSyncAutomatically(account, SYNC_AUTHORITY, false); + } + + /** + * Enables sync. + * + * @param account the account + */ + public static void enableSync(Account account) { + ContentResolver.setIsSyncable(account, SYNC_AUTHORITY, 1); + ContentResolver.setSyncAutomatically(account, SYNC_AUTHORITY, true); + ContentResolver.requestSync(account, SYNC_AUTHORITY, new Bundle()); + } +} diff --git a/MyTracks/src/com/google/android/apps/mytracks/settings/GoogleSettingsActivity.java b/MyTracks/src/com/google/android/apps/mytracks/settings/GoogleSettingsActivity.java index 441c0b213..7ef1c307d 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/settings/GoogleSettingsActivity.java +++ b/MyTracks/src/com/google/android/apps/mytracks/settings/GoogleSettingsActivity.java @@ -17,17 +17,34 @@ package com.google.android.apps.mytracks.settings; import com.google.android.apps.mytracks.Constants; +import com.google.android.apps.mytracks.content.MyTracksProviderUtils; +import com.google.android.apps.mytracks.content.Track; +import com.google.android.apps.mytracks.io.sync.SyncUtils; +import com.google.android.apps.mytracks.util.DialogUtils; 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 android.accounts.Account; import android.accounts.AccountManager; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.database.Cursor; import android.os.Bundle; import android.preference.CheckBoxPreference; import android.preference.ListPreference; import android.preference.Preference; import android.preference.Preference.OnPreferenceChangeListener; +import android.util.Log; +import android.widget.Toast; +import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -38,8 +55,16 @@ import java.util.List; */ public class GoogleSettingsActivity extends AbstractSettingsActivity { - private ListPreference googleAccount; - private CheckBoxPreference driveSync; + private static final String TAG = GoogleSettingsActivity.class.getSimpleName(); + private static final int REQUEST_AUTHORIZATION = 1; + + private static final String ACCOUNT_NAME_KEY = "accountName"; + private static final int DIALOG_CONFIRM_SWITCH_ACCOUNT = 0; + private static final int DIALOG_CONFIRM_DRIVE_SYNC_ON = 1; + private static final int DIALOG_CONFIRM_DRIVE_SYNC_OFF = 2; + + private ListPreference googleAccountPreference; + private CheckBoxPreference driveSyncPreference; @SuppressWarnings("deprecation") @Override @@ -47,7 +72,7 @@ public class GoogleSettingsActivity extends AbstractSettingsActivity { super.onCreate(bundle); addPreferencesFromResource(R.xml.google_settings); - googleAccount = (ListPreference) findPreference( + googleAccountPreference = (ListPreference) findPreference( getString(R.string.google_account_key)); List entries = new ArrayList(); List entryValues = new ArrayList(); @@ -58,49 +83,241 @@ public class GoogleSettingsActivity extends AbstractSettingsActivity { } entries.add(getString(R.string.value_none)); entryValues.add(PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT); - - googleAccount.setEntries(entries.toArray(new CharSequence[entries.size()])); - googleAccount.setEntryValues(entryValues.toArray(new CharSequence[entries.size()])); - googleAccount.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { + + googleAccountPreference.setEntries(entries.toArray(new CharSequence[entries.size()])); + googleAccountPreference.setEntryValues(entryValues.toArray(new CharSequence[entries.size()])); + googleAccountPreference.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { - updateUi((String) newValue, true); + String newGoogleAccount = (String) newValue; + String googleAccount = PreferencesUtils.getString( + GoogleSettingsActivity.this, R.string.google_account_key, + PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT); + boolean driveSync = PreferencesUtils.getBoolean( + GoogleSettingsActivity.this, R.string.drive_sync_key, + PreferencesUtils.DRIVE_SYNC_DEFAULT); + if (driveSync && !newGoogleAccount.equals(googleAccount)) { + Bundle newBundle = new Bundle(); + newBundle.putString(ACCOUNT_NAME_KEY, newGoogleAccount); + showDialog(DIALOG_CONFIRM_SWITCH_ACCOUNT, newBundle); + return false; + } + updateUiByAccountName(newGoogleAccount); return true; } }); - driveSync = (CheckBoxPreference) findPreference( - getString(R.string.drive_sync_key)); + driveSyncPreference = (CheckBoxPreference) findPreference(getString(R.string.drive_sync_key)); + driveSyncPreference.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + showDialog( + (Boolean) newValue ? DIALOG_CONFIRM_DRIVE_SYNC_ON : DIALOG_CONFIRM_DRIVE_SYNC_OFF); + return false; + } + }); - CheckBoxPreference settingsGoogleMapsPublic = (CheckBoxPreference) findPreference( + CheckBoxPreference defaultMapPublicPreference = (CheckBoxPreference) findPreference( getString(R.string.default_map_public_key)); - settingsGoogleMapsPublic.setSummaryOn(getString(R.string.settings_google_maps_public_summary_on, + defaultMapPublicPreference.setSummaryOn(getString( + R.string.settings_google_maps_public_summary_on, getString(R.string.maps_public_unlisted_url))); - settingsGoogleMapsPublic.setSummaryOff(getString( + defaultMapPublicPreference.setSummaryOff(getString( R.string.settings_google_maps_public_summary_off, getString(R.string.maps_public_unlisted_url))); - String account = PreferencesUtils.getString(this, R.string.google_account_key, - PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT); - updateUi(account, false); + updateUiByAccountName(PreferencesUtils.getString( + this, R.string.google_account_key, PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT)); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case REQUEST_AUTHORIZATION: + if (resultCode == Activity.RESULT_OK) { + handleSync(true); + } else { + showDriveNoPermission(this); + } + break; + default: + super.onActivityResult(requestCode, resultCode, data); + } + } + + @Override + protected Dialog onCreateDialog(int id, Bundle bundle) { + Dialog dialog; + switch (id) { + case DIALOG_CONFIRM_SWITCH_ACCOUNT: + dialog = DialogUtils.createConfirmationDialog( + this, R.string.settings_google_drive_sync_confirm_message_off, null); + break; + case DIALOG_CONFIRM_DRIVE_SYNC_ON: + dialog = DialogUtils.createConfirmationDialog(this, + R.string.settings_google_drive_sync_confirm_message_on, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface d, int button) { + String googleAccount = PreferencesUtils.getString( + GoogleSettingsActivity.this, R.string.google_account_key, + PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT); + checkDrivePermission(GoogleSettingsActivity.this, googleAccount); + } + }); + break; + case DIALOG_CONFIRM_DRIVE_SYNC_OFF: + dialog = DialogUtils.createConfirmationDialog(this, + R.string.settings_google_drive_sync_confirm_message_off, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface d, int button) { + handleSync(false); + } + }); + break; + default: + dialog = null; + } + return dialog; + } + + @Override + protected void onPrepareDialog(int id, Dialog dialog, Bundle bundle) { + AlertDialog alertDialog = (AlertDialog) dialog; + String googleAccount = PreferencesUtils.getString( + this, R.string.google_account_key, PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT); + switch (id) { + case DIALOG_CONFIRM_SWITCH_ACCOUNT: + final String newValue = bundle.getString(ACCOUNT_NAME_KEY); + alertDialog.setMessage( + getString(R.string.settings_google_drive_sync_confirm_message_off, googleAccount)); + alertDialog.setButton(AlertDialog.BUTTON_POSITIVE, getString(android.R.string.ok), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface d, int button) { + googleAccountPreference.setValue(newValue); + handleSync(false); + updateUiByAccountName(newValue); + } + }); + break; + case DIALOG_CONFIRM_DRIVE_SYNC_ON: + alertDialog.setMessage(getString( + R.string.settings_google_drive_sync_confirm_message_on, googleAccount, + getString(R.string.my_tracks_app_name))); + break; + case DIALOG_CONFIRM_DRIVE_SYNC_OFF: + alertDialog.setMessage( + getString(R.string.settings_google_drive_sync_confirm_message_off, googleAccount)); + break; + default: + } + super.onPrepareDialog(id, dialog, bundle); } /** - * Updates the UI. + * Handles sync. * - * @param account the account - * @param uncheckDriveSync true to uncheck drive sync + * @param value true to sync */ - private void updateUi(String account, boolean uncheckDriveSync) { - googleAccount.setSummary(PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT.equals( - account) ? getString(R.string.value_unknown) - : account); - boolean hasAccount = !PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT.equals(account); - driveSync.setEnabled(hasAccount); - driveSync.setSummaryOn( - getString(R.string.settings_google_drive_sync_summary_on, account)); - if (uncheckDriveSync) { - driveSync.setChecked(false); + private void handleSync(boolean value) { + driveSyncPreference.setChecked(value); + + // Turn off everything + Account[] accounts = AccountManager.get(GoogleSettingsActivity.this) + .getAccountsByType(Constants.ACCOUNT_TYPE); + for (Account account : accounts) { + SyncUtils.disableSync(account); + } + + PreferencesUtils.setLong(this, R.string.drive_largest_change_id_key, + PreferencesUtils.DRIVE_LARGEST_CHANGE_ID_DEFAULT); + PreferencesUtils.setString( + this, R.string.drive_deleted_list_key, PreferencesUtils.DRIVE_DELETED_LIST_DEFAULT); + MyTracksProviderUtils myTracksProviderUtils = MyTracksProviderUtils.Factory.get(this); + + Cursor cursor = myTracksProviderUtils.getTrackCursor(SyncUtils.DRIVE_IDS_QUERY, null, null); + if (cursor != null && cursor.moveToFirst()) { + do { + Track track = myTracksProviderUtils.createTrack(cursor); + track.setDriveId(""); + track.setModifiedTime(-1L); + track.setSharedWithMe(false); + myTracksProviderUtils.updateTrack(track); + } while (cursor.moveToNext()); + } + + if (value) { + + // Turn on sync + ContentResolver.setMasterSyncAutomatically(true); + + // Enable sync for account + String googleAccount = PreferencesUtils.getString(GoogleSettingsActivity.this, + R.string.google_account_key, PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT); + for (Account account : accounts) { + if (account.name.equals(googleAccount)) { + SyncUtils.enableSync(account); + break; + } + } } } + + /** + * Updates UI by account. + * + * @param accountName the account name + */ + private void updateUiByAccountName(String accountName) { + boolean hasAccount = !PreferencesUtils.GOOGLE_ACCOUNT_DEFAULT.equals(accountName); + googleAccountPreference.setSummary( + hasAccount ? accountName : getString(R.string.value_unknown)); + driveSyncPreference.setEnabled(hasAccount); + driveSyncPreference.setSummaryOn( + getString(R.string.settings_google_drive_sync_summary_on, accountName)); + } + + /** + * Checks Drive permission. + * + * @param context the context + * @param accountName the account name + */ + private void checkDrivePermission(final Context context, final String accountName) { + Thread thread = new Thread(new Runnable() { + @Override + public void run() { + try { + SyncUtils.checkDrivePermission(context, accountName); + runOnUiThread(new Runnable() { + @Override + public void run() { + handleSync(true); + } + }); + } catch (UserRecoverableAuthException e) { + startActivityForResult(e.getIntent(), REQUEST_AUTHORIZATION); + } catch (IOException e) { + Log.e(TAG, "IOException", e); + showDriveNoPermission(context); + } catch (GoogleAuthException e) { + Log.e(TAG, "GoogleAuthException", e); + showDriveNoPermission(context); + } + } + }); + thread.start(); + } + + /** + * Shows Drive no permission toast. + * + * @param context the context + */ + private void showDriveNoPermission(Context context) { + Toast.makeText(context, R.string.settings_google_drive_sync_no_permission, Toast.LENGTH_LONG) + .show(); + } } diff --git a/MyTracks/src/com/google/android/apps/mytracks/util/PreferencesUtils.java b/MyTracks/src/com/google/android/apps/mytracks/util/PreferencesUtils.java index e3a0ac363..83af28af2 100644 --- a/MyTracks/src/com/google/android/apps/mytracks/util/PreferencesUtils.java +++ b/MyTracks/src/com/google/android/apps/mytracks/util/PreferencesUtils.java @@ -18,6 +18,7 @@ package com.google.android.apps.mytracks.util; import com.google.android.apps.mytracks.Constants; +import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; @@ -131,6 +132,7 @@ public class PreferencesUtils { * @param keyId the key id * @param value the value */ + @SuppressLint("CommitPrefEdits") public static void setBoolean(Context context, int keyId, boolean value) { SharedPreferences sharedPreferences = context.getSharedPreferences( Constants.SETTINGS_NAME, Context.MODE_PRIVATE); @@ -159,6 +161,7 @@ public class PreferencesUtils { * @param keyId the key id * @param value the value */ + @SuppressLint("CommitPrefEdits") public static void setInt(Context context, int keyId, int value) { SharedPreferences sharedPreferences = context.getSharedPreferences( Constants.SETTINGS_NAME, Context.MODE_PRIVATE); @@ -186,6 +189,7 @@ public class PreferencesUtils { * @param keyId the key id * @param value the value */ + @SuppressLint("CommitPrefEdits") public static void setLong(Context context, int keyId, long value) { SharedPreferences sharedPreferences = context.getSharedPreferences( Constants.SETTINGS_NAME, Context.MODE_PRIVATE); @@ -214,6 +218,7 @@ public class PreferencesUtils { * @param keyId the key id * @param value the value */ + @SuppressLint("CommitPrefEdits") public static void setString(Context context, int keyId, String value) { SharedPreferences sharedPreferences = context.getSharedPreferences( Constants.SETTINGS_NAME, Context.MODE_PRIVATE);