Add two-ways sync to Google Drive.

This commit is contained in:
Jimmy Shih
2013-01-23 17:00:33 -08:00
parent 77e6dc1b73
commit ff375c1eb5
15 changed files with 1064 additions and 36 deletions
+14
View File
@@ -41,6 +41,10 @@ limitations under the License.
<uses-permission android:name="com.google.android.googleapps.permission.GOOGLE_AUTH.local" />
<uses-permission android:name="com.google.android.googleapps.permission.GOOGLE_AUTH.wise" />
<uses-permission android:name="com.google.android.googleapps.permission.GOOGLE_AUTH.writely" />
<!-- Permission for Android sync provider -->
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<!-- Permission to access Google services to communicate with the map servers. -->
<uses-permission android:name="com.google.android.providers.gsf.permission.READ_GSERVICES" />
<!-- Permission to read phone state so voice announcement will not interrupt ongoing calls -->
@@ -225,6 +229,16 @@ limitations under the License.
</intent-filter>
</activity>
<!-- Services -->
<service
android:exported="true"
android:name="com.google.android.apps.mytracks.io.sync.SyncService" >
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
</service>
<service
android:exported="false"
android:name="com.google.android.apps.mytracks.services.ControlRecordingService" />
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+4
View File
@@ -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"/>
<item
android:icon="@drawable/menu_sync_now"
android:id="@+id/track_list_sync_now"
android:title="@string/menu_sync_now"/>
<item
android:icon="@drawable/menu_settings"
android:id="@+id/track_list_settings"
+37 -2
View File
@@ -1277,6 +1277,12 @@ limitations under the License.
Character limit: ~20 (1 line)">
Stop GPS
</string>
<string name="menu_sync_now"
translation_description="In the menu, to sync now with Google Drive.
Character limit: ~20 (1 line)">
Sync now
</string>
<string name="menu_terrain"
translation_description="In the menu, under the map layer option, the submenu to display
terrain.
@@ -1342,6 +1348,15 @@ limitations under the License.
installation when the app wants to read My Tracks data.">
read Google My Tracks data
</string>
<string name="permission_request_message"
translation_description="The message when requesting permission.
%1$s account name. E.g., 'foo@foo.com'.">
for account %1$s
</string>
<string name="permission_request_title"
translation_description="The title when requesting permission.">
Request permission
</string>
<string name="permission_write_description"
translation_description="The permission description the Google Play Store shows during app
installation when the app wants to write data to My Tracks.">
@@ -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
</string>
<!-- Save -->
<string name="save_error"
translation_description="When saving to the external storage, the error message indicating that
@@ -1393,7 +1408,7 @@ limitations under the License.
the track that the marker belongs to.
%1$s: track name. E.g., 'Grand Canyon trail'.">
In track \'%1$s\'.
In track &#8216;%1$s&#8217;.
</string>
<string name="search_voice_prompt"
translation_description="Prompt shown when the user activates voice search, asking him to speak
@@ -1707,6 +1722,26 @@ limitations under the License.
Character limit: ~23 (1 line)">
Switch account
</string>
<string name="settings_google_drive_sync_confirm_message_off"
translation_description="In the 'Google' settings, the confirmation message when unchecking
'Sync Google Drive' checkbox.
%1$s account name. E.g., 'foo@foo.com'.">
This will remove sync with %1$s Google Drive. If sync again, all tracks will be treated as new.
</string>
<string name="settings_google_drive_sync_confirm_message_on"
translation_description="In the 'Google' settings, the confirmation message when checking the
'Sync Google Drive' checkbox.
%1$s account name. E.g., 'foo@foo.com'.
%2$s folder name. E.g., 'My Tracks'.">
This will sync with %1$s Google Drive &#8216;%2$s&#8217; folder. All files in the folder will be
synced as new tracks. If not desired, move those files to a different folder beforehand.
</string>
<string name="settings_google_drive_sync_no_permission"
translation_description="The message when unable to get Google Drive permission.">
Unable to get Google Drive permission.
</string>
<string name="settings_google_drive_sync_summary_on"
translation_description="In the 'Google' settings, the summary for the 'Sync Google Drive'
checkbox when it is checked explaining that all tracks will be synced to a Google Drive
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:contentAuthority="com.google.android.maps.mytracks"
android:accountType="com.google"
android:userVisible="true"
android:supportsUploading="true" />
@@ -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();
}
@@ -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);
}
}
/**
@@ -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);
}
}
@@ -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<String, File> idToFileMap = new HashMap<String, File>();
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<String, File> changes = new HashMap<String, File>();
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<ParentReference> parents = new ArrayList<ParentReference>();
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<File> 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<String, File> 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;
}
}
@@ -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();
}
}
@@ -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());
}
}
@@ -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<String> entries = new ArrayList<String>();
List<String> entryValues = new ArrayList<String>();
@@ -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();
}
}
@@ -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);