/* * Copyright 2013 The Android Open Source Project * * 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 org.tasks.gtasks; import android.accounts.Account; import android.app.PendingIntent; import android.content.ContentProviderClient; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.SyncResult; import android.os.Bundle; import android.support.v4.app.NotificationCompat; import android.text.TextUtils; import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException; import com.google.api.services.tasks.model.TaskList; import com.google.api.services.tasks.model.TaskLists; import com.google.api.services.tasks.model.Tasks; import com.todoroo.andlib.data.AbstractModel; import com.todoroo.andlib.sql.Criterion; import com.todoroo.andlib.sql.Join; import com.todoroo.andlib.sql.Query; import com.todoroo.andlib.utility.DateUtilities; import com.todoroo.astrid.dao.MetadataDao; import com.todoroo.astrid.dao.StoreObjectDao; import com.todoroo.astrid.dao.TaskDao; import com.todoroo.astrid.data.Metadata; import com.todoroo.astrid.data.SyncFlags; import com.todoroo.astrid.data.Task; import com.todoroo.astrid.gtasks.GtasksList; import com.todoroo.astrid.gtasks.GtasksListService; import com.todoroo.astrid.gtasks.GtasksMetadata; import com.todoroo.astrid.gtasks.GtasksPreferenceService; import com.todoroo.astrid.gtasks.GtasksTaskListUpdater; import com.todoroo.astrid.gtasks.api.GtasksApiUtilities; import com.todoroo.astrid.gtasks.api.GtasksInvoker; import com.todoroo.astrid.gtasks.api.HttpNotFoundException; import com.todoroo.astrid.gtasks.sync.GtasksSyncService; import com.todoroo.astrid.gtasks.sync.GtasksTaskContainer; import com.todoroo.astrid.utility.Constants; import org.tasks.LocalBroadcastManager; import org.tasks.R; import org.tasks.analytics.Tracker; import org.tasks.injection.InjectingAbstractThreadedSyncAdapter; import org.tasks.injection.SyncAdapterComponent; import org.tasks.notifications.NotificationManager; import org.tasks.preferences.Preferences; import org.tasks.sync.RecordSyncStatusCallback; import org.tasks.time.DateTime; import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import javax.inject.Inject; import timber.log.Timber; import static org.tasks.date.DateTimeUtils.newDateTime; /** * Define a sync adapter for the app. * *

This class is instantiated in {@link GoogleTaskSyncService}, which also binds SyncAdapter to the system. * SyncAdapter should only be initialized in SyncService, never anywhere else. * *

The system calls onPerformSync() via an RPC call through the IBinder object supplied by * SyncService. */ public class GoogleTaskSyncAdapter extends InjectingAbstractThreadedSyncAdapter { private static final String DEFAULT_LIST = "@default"; //$NON-NLS-1$ @Inject GtasksPreferenceService gtasksPreferenceService; @Inject LocalBroadcastManager localBroadcastManager; @Inject StoreObjectDao storeObjectDao; @Inject GtasksSyncService gtasksSyncService; @Inject GtasksListService gtasksListService; @Inject GtasksTaskListUpdater gtasksTaskListUpdater; @Inject Preferences preferences; @Inject GtasksInvoker gtasksInvoker; @Inject TaskDao taskDao; @Inject MetadataDao metadataDao; @Inject GtasksMetadata gtasksMetadataFactory; @Inject Tracker tracker; @Inject NotificationManager notificationManager; public GoogleTaskSyncAdapter(Context context, boolean autoInitialize) { super(context, autoInitialize); } /** * Called by the Android system in response to a request to run the sync adapter. The work * required to read data from the network, parse it, and store it in the content provider is * done here. Extending AbstractThreadedSyncAdapter ensures that all methods within SyncAdapter * run on a background thread. For this reason, blocking I/O and other long-running tasks can be * run in situ, and you don't have to set up a separate thread for them. . * *

This is where we actually perform any work required to perform a sync. * {@link android.content.AbstractThreadedSyncAdapter} guarantees that this will be called on a non-UI thread, * so it is safe to peform blocking I/O here. * *

The syncResult argument allows you to pass information back to the method that triggered * the sync. */ @Override public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { if (!account.name.equals(gtasksPreferenceService.getUserName())) { Timber.d("Sync not enabled for %s", account); syncResult.stats.numAuthExceptions++; return; } Timber.d("%s: start sync", account); RecordSyncStatusCallback callback = new RecordSyncStatusCallback(gtasksPreferenceService, localBroadcastManager); try { callback.started(); synchronize(); gtasksPreferenceService.recordSuccessfulSync(); } catch (UserRecoverableAuthIOException e) { Timber.e(e, e.getMessage()); sendNotification(getContext(), e.getIntent()); } catch (IOException e) { Timber.e(e, e.getMessage()); } catch (Exception e) { tracker.reportException(e); } finally { callback.finished(); Timber.d("%s: end sync", account); } } private void sendNotification(Context context, Intent intent) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_FROM_BACKGROUND); PendingIntent resolve = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); NotificationCompat.Builder builder = new NotificationCompat.Builder(context).setAutoCancel(true) .setContentIntent(resolve) .setContentTitle(context.getString(R.string.sync_error_permissions)) .setContentText(context.getString(R.string.common_google_play_services_notification_ticker)) .setAutoCancel(true) .setSmallIcon(android.R.drawable.ic_dialog_alert) .setTicker(context.getString(R.string.common_google_play_services_notification_ticker)); notificationManager.notify(Constants.NOTIFICATION_SYNC_ERROR, builder.build()); } private void synchronize() throws IOException { pushLocalChanges(); List gtaskLists = new ArrayList<>(); String nextPageToken = null; do { TaskLists remoteLists = gtasksInvoker.allGtaskLists(nextPageToken); if (remoteLists == null) { break; } List items = remoteLists.getItems(); if (items != null) { gtaskLists.addAll(items); } nextPageToken = remoteLists.getNextPageToken(); } while (nextPageToken != null); gtasksListService.updateLists(gtaskLists); if (gtasksListService.getList(gtasksPreferenceService.getDefaultList()) == null) { gtasksPreferenceService.setDefaultList(null); } for (final GtasksList list : gtasksListService.getListsToUpdate(gtaskLists)) { fetchAndApplyRemoteChanges(list); } } private void pushLocalChanges() throws UserRecoverableAuthIOException { List tasks = taskDao.toList(Query.select(Task.PROPERTIES) .join(Join.left(Metadata.TABLE, Criterion.and(MetadataDao.MetadataCriteria.withKey(GtasksMetadata.METADATA_KEY), Task.ID.eq(Metadata.TASK)))) .where(Criterion.or(Task.MODIFICATION_DATE.gt(GtasksMetadata.LAST_SYNC), GtasksMetadata.ID.eq("")))); for (Task task : tasks) { try { pushTask(task, task.getMergedValues(), gtasksInvoker); } catch (UserRecoverableAuthIOException e) { throw e; } catch (IOException e) { Timber.e(e, e.getMessage()); } } } /** * Synchronize with server when data changes */ private void pushTask(Task task, ContentValues values, GtasksInvoker invoker) throws IOException { for (Metadata deleted : getDeleted(task.getId())) { gtasksInvoker.deleteGtask(deleted.getValue(GtasksMetadata.LIST_ID), deleted.getValue(GtasksMetadata.ID)); metadataDao.delete(deleted.getId()); } Metadata gtasksMetadata = metadataDao.getFirstActiveByTaskAndKey(task.getId(), GtasksMetadata.METADATA_KEY); com.google.api.services.tasks.model.Task remoteModel; boolean newlyCreated = false; String remoteId; String listId = gtasksPreferenceService.getDefaultList(); if (listId == null) { com.google.api.services.tasks.model.TaskList defaultList = invoker.getGtaskList(DEFAULT_LIST); if (defaultList != null) { listId = defaultList.getId(); gtasksPreferenceService.setDefaultList(listId); } else { listId = DEFAULT_LIST; } } if (gtasksMetadata == null || !gtasksMetadata.containsNonNullValue(GtasksMetadata.ID) || TextUtils.isEmpty(gtasksMetadata.getValue(GtasksMetadata.ID))) { //Create case if (gtasksMetadata == null) { gtasksMetadata = gtasksMetadataFactory.createEmptyMetadata(task.getId()); } if (gtasksMetadata.containsNonNullValue(GtasksMetadata.LIST_ID)) { listId = gtasksMetadata.getValue(GtasksMetadata.LIST_ID); } remoteModel = new com.google.api.services.tasks.model.Task(); newlyCreated = true; } else { //update case remoteId = gtasksMetadata.getValue(GtasksMetadata.ID); listId = gtasksMetadata.getValue(GtasksMetadata.LIST_ID); remoteModel = new com.google.api.services.tasks.model.Task(); remoteModel.setId(remoteId); } //If task was newly created but without a title, don't sync--we're in the middle of //creating a task which may end up being cancelled. Also don't sync new but already //deleted tasks if (newlyCreated && (!values.containsKey(Task.TITLE.name) || TextUtils.isEmpty(task.getTitle()) || task.getDeletionDate() > 0)) { return; } //Update the remote model's changed properties if (values.containsKey(Task.DELETION_DATE.name) && task.isDeleted()) { remoteModel.setDeleted(true); } if (values.containsKey(Task.TITLE.name)) { remoteModel.setTitle(task.getTitle()); } if (values.containsKey(Task.NOTES.name)) { remoteModel.setNotes(task.getNotes()); } if (values.containsKey(Task.DUE_DATE.name) && task.hasDueDate()) { remoteModel.setDue(GtasksApiUtilities.unixTimeToGtasksDueDate(task.getDueDate())); } if (values.containsKey(Task.COMPLETION_DATE.name)) { if (task.isCompleted()) { remoteModel.setCompleted(GtasksApiUtilities.unixTimeToGtasksCompletionTime(task.getCompletionDate())); remoteModel.setStatus("completed"); //$NON-NLS-1$ } else { remoteModel.setCompleted(null); remoteModel.setStatus("needsAction"); //$NON-NLS-1$ } } if (!newlyCreated) { try { invoker.updateGtask(listId, remoteModel); } catch(HttpNotFoundException e) { Timber.e(e, e.getMessage()); metadataDao.delete(gtasksMetadata.getId()); return; } } else { String parent = gtasksSyncService.getRemoteParentId(gtasksMetadata); String priorSibling = gtasksSyncService.getRemoteSiblingId(listId, gtasksMetadata); com.google.api.services.tasks.model.Task created = invoker.createGtask(listId, remoteModel, parent, priorSibling); if (created != null) { //Update the metadata for the newly created task gtasksMetadata.setValue(GtasksMetadata.ID, created.getId()); gtasksMetadata.setValue(GtasksMetadata.LIST_ID, listId); } else { return; } } task.setModificationDate(DateUtilities.now()); gtasksMetadata.setValue(GtasksMetadata.LAST_SYNC, DateUtilities.now() + 1000L); metadataDao.persist(gtasksMetadata); task.putTransitory(SyncFlags.GTASKS_SUPPRESS_SYNC, true); taskDao.saveExistingWithSqlConstraintCheck(task); } private List getDeleted(long taskId) { return metadataDao.toList(Criterion.and( MetadataDao.MetadataCriteria.byTaskAndwithKey(taskId, GtasksMetadata.METADATA_KEY), MetadataDao.MetadataCriteria.isDeleted())); } private synchronized void fetchAndApplyRemoteChanges(GtasksList list) throws UserRecoverableAuthIOException { String listId = list.getRemoteId(); long lastSyncDate = list.getLastSync(); boolean includeDeletedAndHidden = lastSyncDate != 0; try { List tasks = new ArrayList<>(); String nextPageToken = null; do { Tasks taskList = gtasksInvoker.getAllGtasksFromListId(listId, includeDeletedAndHidden, includeDeletedAndHidden, lastSyncDate + 1000L, nextPageToken); if (taskList == null) { break; } List items = taskList.getItems(); if (items != null) { tasks.addAll(items); } nextPageToken = taskList.getNextPageToken(); } while (nextPageToken != null); if (!tasks.isEmpty()) { for (com.google.api.services.tasks.model.Task t : tasks) { GtasksTaskContainer container = new GtasksTaskContainer(t, listId, GtasksMetadata.createEmptyMetadataWithoutList(AbstractModel.NO_ID)); findLocalMatch(container); container.gtaskMetadata.setValue(GtasksMetadata.GTASKS_ORDER, Long.parseLong(t.getPosition())); container.gtaskMetadata.setValue(GtasksMetadata.PARENT_TASK, localIdForGtasksId(t.getParent())); container.gtaskMetadata.setValue(GtasksMetadata.LAST_SYNC, DateUtilities.now() + 1000L); write(container); lastSyncDate = Math.max(lastSyncDate, container.getUpdateTime()); } list.setLastSync(lastSyncDate); storeObjectDao.persist(list); gtasksTaskListUpdater.correctOrderAndIndentForList(listId); } } catch (UserRecoverableAuthIOException e) { throw e; } catch (IOException e) { Timber.e(e, e.getMessage()); } } private long localIdForGtasksId(String gtasksId) { Metadata metadata = getMetadataByGtaskId(gtasksId); return metadata == null ? AbstractModel.NO_ID : metadata.getTask(); } private void findLocalMatch(GtasksTaskContainer remoteTask) { if(remoteTask.task.getId() != Task.NO_ID) { return; } Metadata metadata = getMetadataByGtaskId(remoteTask.gtaskMetadata.getValue(GtasksMetadata.ID)); if (metadata != null) { remoteTask.task.setId(metadata.getValue(Metadata.TASK)); remoteTask.task.setUuid(taskDao.uuidFromLocalId(remoteTask.task.getId())); remoteTask.gtaskMetadata = metadata; } } private Metadata getMetadataByGtaskId(String gtaskId) { return metadataDao.getFirst(Query.select(Metadata.PROPERTIES).where(Criterion.and( Metadata.KEY.eq(GtasksMetadata.METADATA_KEY), GtasksMetadata.ID.eq(gtaskId)))); } private void write(GtasksTaskContainer task) { // merge astrid dates with google dates if(task.task.isSaved()) { Task local = taskDao.fetch(task.task.getId(), Task.PROPERTIES); if (local == null) { task.task.clearValue(Task.ID); task.task.clearValue(Task.UUID); } else { mergeDates(task.task, local); } } else { // Set default importance and reminders for remotely created tasks task.task.setImportance(preferences.getIntegerFromString( R.string.p_default_importance_key, Task.IMPORTANCE_SHOULD_DO)); TaskDao.setDefaultReminders(preferences, task.task); } if (!TextUtils.isEmpty(task.task.getTitle())) { task.task.putTransitory(SyncFlags.GTASKS_SUPPRESS_SYNC, true); task.task.putTransitory(TaskDao.TRANS_SUPPRESS_REFRESH, true); saveTaskAndMetadata(task); } } /** * Saves a task and its metadata */ private void saveTaskAndMetadata(GtasksTaskContainer task) { task.prepareForSaving(); taskDao.save(task.task); synchronizeMetadata(task.task.getId(), task.metadata, GtasksMetadata.METADATA_KEY); } /** * Synchronize metadata for given task id. Deletes rows in database that * are not identical to those in the metadata list, creates rows that * have no match. * * @param taskId id of task to perform synchronization on * @param metadata list of new metadata items to save * @param metadataKey metadata key */ private void synchronizeMetadata(long taskId, ArrayList metadata, String metadataKey) { final Set newMetadataValues = new HashSet<>(); for(Metadata metadatum : metadata) { metadatum.setTask(taskId); metadatum.clearValue(Metadata.ID); newMetadataValues.add(metadatum.getMergedValues()); } metadataDao.byTaskAndKey(taskId, metadataKey, item -> { long id = item.getId(); // clear item id when matching with incoming values item.clearValue(Metadata.ID); ContentValues itemMergedValues = item.getMergedValues(); if(newMetadataValues.contains(itemMergedValues)) { newMetadataValues.remove(itemMergedValues); } else { // not matched. cut it metadataDao.delete(id); } }); // everything that remains shall be written for(ContentValues values : newMetadataValues) { Metadata item = new Metadata(); item.mergeWith(values); metadataDao.persist(item); } } static void mergeDates(Task remote, Task local) { if (remote.hasDueDate() && local.hasDueTime()) { DateTime oldDate = newDateTime(local.getDueDate()); DateTime newDate = newDateTime(remote.getDueDate()) .withHourOfDay(oldDate.getHourOfDay()) .withMinuteOfHour(oldDate.getMinuteOfHour()) .withSecondOfMinute(oldDate.getSecondOfMinute()); local.setDueDateAdjustingHideUntil( Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, newDate.getMillis())); } else { local.setDueDateAdjustingHideUntil(remote.getDueDate()); } remote.setHideUntil(local.getHideUntil()); remote.setDueDate(local.getDueDate()); } @Override protected void inject(SyncAdapterComponent component) { component.inject(this); } }