From ff375c1eb5d042664c01d0e2e1783e08d255dc78 Mon Sep 17 00:00:00 2001 From: Jimmy Shih Date: Wed, 23 Jan 2013 17:00:33 -0800 Subject: [PATCH] Add two-ways sync to Google Drive. --- MyTracks/AndroidManifest.xml | 14 + MyTracks/res/drawable-hdpi/menu_sync_now.png | Bin 0 -> 3138 bytes MyTracks/res/drawable-mdpi/menu_sync_now.png | Bin 0 -> 3033 bytes MyTracks/res/drawable-xhdpi/menu_sync_now.png | Bin 0 -> 3219 bytes MyTracks/res/menu/track_list.xml | 4 + MyTracks/res/values/strings.xml | 39 +- MyTracks/res/xml/sync_adapter.xml | 21 + .../apps/mytracks/TrackEditActivity.java | 1 + .../apps/mytracks/TrackListActivity.java | 20 +- .../mytracks/content/MyTracksProvider.java | 5 +- .../apps/mytracks/io/sync/SyncAdapter.java | 564 ++++++++++++++++++ .../apps/mytracks/io/sync/SyncService.java | 47 ++ .../apps/mytracks/io/sync/SyncUtils.java | 105 ++++ .../settings/GoogleSettingsActivity.java | 275 ++++++++- .../apps/mytracks/util/PreferencesUtils.java | 5 + 15 files changed, 1064 insertions(+), 36 deletions(-) create mode 100644 MyTracks/res/drawable-hdpi/menu_sync_now.png create mode 100644 MyTracks/res/drawable-mdpi/menu_sync_now.png create mode 100644 MyTracks/res/drawable-xhdpi/menu_sync_now.png create mode 100644 MyTracks/res/xml/sync_adapter.xml create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncAdapter.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncService.java create mode 100644 MyTracks/src/com/google/android/apps/mytracks/io/sync/SyncUtils.java 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 0000000000000000000000000000000000000000..bb9d855f77692343e173f814ca1e546393b707d8 GIT binary patch literal 3138 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 MSl>bq2X&7AABcbM^#A|> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bd611e8e24d2e211a94d1658a49c823c4201716f GIT binary patch literal 3033 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 IFo%f$0VKS%G5`Po literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a7fdc0dfcb1dc50bd0ebd2527d174398b8470797 GIT binary patch literal 3219 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 literal 0 HcmV?d00001 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);