/** * Copyright (c) 2012 Todoroo Inc * * See the file "LICENSE" for the full license governing this code. */ package com.todoroo.astrid.sync; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import android.app.Activity; import android.app.Notification; import android.content.Context; import android.widget.Toast; import com.todoroo.andlib.data.Property.LongProperty; import com.todoroo.andlib.data.TodorooCursor; import com.todoroo.andlib.service.Autowired; import com.todoroo.andlib.service.ContextManager; import com.todoroo.andlib.service.DependencyInjectionService; import com.todoroo.andlib.service.ExceptionService; import com.todoroo.andlib.service.NotificationManager; import com.todoroo.andlib.utility.DialogUtilities; import com.todoroo.astrid.api.R; import com.todoroo.astrid.data.Task; /** * A helper class for writing synchronization services for Astrid. This class * contains logic for merging incoming changes and writing outgoing changes. * <p> * Use {@link #initiateManual} as the entry point for your synchronization * service, which should check if a user is logged in. If not, you should * handle that in the UI, otherwise, you should launch your background * service to perform synchronization in the background. * <p> * Your background service should {@link #synchronize}, which in turn * invokes {@link #initiateBackground} to initiate synchronization. * * @author Tim Su <tim@todoroo.com> * */ public abstract class SyncProvider<TYPE extends SyncContainer> { // --- abstract methods - your services should implement these /** * @return sync utility instance */ abstract protected SyncProviderUtilities getUtilities(); /** * Perform log in (launching activity if necessary) and sync. This is * invoked when users manually request synchronization * * @param activity * context */ abstract protected void initiateManual(Activity activity); /** * Perform synchronize. Since this can be called from background services, * you should not open up new activities. Instead, if the user is not signed * in, your service should do nothing. */ abstract protected void initiateBackground(); /** * Updates the text of a notification and the intent to open when tapped * @param context * @param notification * @return notification id (in Android, there is at most one notification * in the tray for a given id) */ abstract protected int updateNotification(Context context, Notification n); /** * Create a task on the remote server. * * @param task * task to create */ abstract protected TYPE create(TYPE task) throws IOException; /** * Push variables from given task to the remote server, and read the newly * updated task. * * @param task * task proxy to push * @param remoteTask * remote task that we merged with. may be null * @return task pulled on remote server */ abstract protected TYPE push(TYPE task, TYPE remote) throws IOException; /** * Fetch remote task. Used to re-read merged tasks * * @param task * task with id's to re-read * @return new Task */ abstract protected TYPE pull(TYPE task) throws IOException; /** * Reads a task container from a task in the database * * @param task */ abstract protected TYPE read(TodorooCursor<Task> task) throws IOException; /** * Save task. Used to save local tasks that have been updated and remote * tasks that need to be created locally * * @param task */ abstract protected void write(TYPE task) throws IOException; /** * Finds a task in the list with the same remote identifier(s) as * the task passed in * * @return task from list if matches, null otherwise */ abstract protected int matchTask(ArrayList<TYPE> tasks, TYPE target); /** * Transfer remote identifier(s) from one task to another */ abstract protected void transferIdentifiers(TYPE source, TYPE destination); // --- implementation private final Notification notification; @Autowired protected ExceptionService exceptionService; public SyncProvider() { DependencyInjectionService.getInstance().inject(this); // initialize notification int icon = android.R.drawable.stat_notify_sync; long when = System.currentTimeMillis(); notification = new Notification(icon, null, when); notification.flags |= Notification.FLAG_ONGOING_EVENT; } /** * Synchronize this provider with sync toast * @param context */ public void synchronize(final Context context) { synchronize(context, true); } /** * Synchronize this provider * @param context * @param showSyncToast should we toast to indicate synchronizing? */ public void synchronize(final Context context, final boolean showSyncToast) { // display toast if(context instanceof Activity) { if(getUtilities().isLoggedIn() && getUtilities().shouldShowToast()) { ((Activity) context).runOnUiThread(new Runnable() { @Override public void run() { if(showSyncToast) makeSyncToast(context); } }); } initiateManual((Activity)context); } else if(context instanceof SyncBackgroundService) { // display notification final int notificationId = updateNotification(context, notification); final NotificationManager nm = new NotificationManager.AndroidNotificationManager(context); nm.notify(notificationId, notification); // start next step in background thread new Thread(new Runnable() { public void run() { try { initiateBackground(); } finally { nm.cancel(notificationId); ((SyncBackgroundService)context).stop(); } } }).start(); } else { // unit test initiateBackground(); } } protected void makeSyncToast(Context context) { Toast.makeText(context, R.string.SyP_progress_toast, Toast.LENGTH_LONG).show(); } // --- synchronization logic /** * Helper to synchronize remote tasks with our local database. * * This initiates the following process: 1. local changes are read 2. remote * changes are read 3. local tasks are merged with remote changes and pushed * across 4. remote changes are then read in * * @param data synchronization data structure */ protected void synchronizeTasks(SyncData<TYPE> data) throws IOException { int length; // create internal data structures HashMap<String, Integer> remoteNewTaskNameMap = new HashMap<String, Integer>(); length = data.remoteUpdated.size(); for(int i = 0; i < length; i++) { TYPE remote = data.remoteUpdated.get(i); if(remote.task.getId() != Task.NO_ID) continue; remoteNewTaskNameMap.put(remote.task.getValue(Task.TITLE), i); } // 1. CREATE: grab newly created tasks and create them remotely sendLocallyCreated(data, remoteNewTaskNameMap); // 2. UPDATE: for each updated local task sendLocallyUpdated(data); // 3. REMOTE: load remote information readRemotelyUpdated(data); } @SuppressWarnings("nls") protected String getFinalSyncStatus() { if (getUtilities().getLastError() != null || getUtilities().getLastAttemptedSyncDate() != 0) { if (getUtilities().getLastAttemptedSyncDate() == 0) return "errors"; else return "failed"; } else { return "success"; } } protected void readRemotelyUpdated(SyncData<TYPE> data) throws IOException { int length; // Rearrange remoteTasks so completed tasks get synchronized first. // This prevents bugs where a repeated task has two copies come down // the wire, the new version and the completed old version. The new // version would get merged, then completed, if done in the wrong order. Collections.sort(data.remoteUpdated, new Comparator<TYPE>() { private static final int SENTINEL = -2; private final int check(TYPE o1, TYPE o2, LongProperty property) { long o1Property = o1.task.getValue(property); long o2Property = o2.task.getValue(property); if(o1Property != 0 && o2Property != 0) return 0; else if(o1Property != 0) return -1; else if(o2Property != 0) return 1; return SENTINEL; } public int compare(TYPE o1, TYPE o2) { int comparison = check(o1, o2, Task.DELETION_DATE); if(comparison != SENTINEL) return comparison; comparison = check(o1, o2, Task.COMPLETION_DATE); if(comparison != SENTINEL) return comparison; return 0; } }); length = data.remoteUpdated.size(); for(int i = 0; i < length; i++) { TYPE remote = data.remoteUpdated.get(i); // don't synchronize new & deleted tasks if(!remote.task.isSaved() && (remote.task.isDeleted())) continue; try { write(remote); } catch (Exception e) { handleException("sync-remote-updated", e, false); //$NON-NLS-1$ } } } protected void sendLocallyUpdated(SyncData<TYPE> data) throws IOException { int length; length = data.localUpdated.getCount(); for(int i = 0; i < length; i++) { data.localUpdated.moveToNext(); TYPE local = read(data.localUpdated); try { if(local.task == null) continue; // if there is a conflict, merge int remoteIndex = matchTask((ArrayList<TYPE>)data.remoteUpdated, local); if(remoteIndex != -1) { TYPE remote = data.remoteUpdated.get(remoteIndex); remote = push(local, remote); // re-read remote task after merge (with local's title) remote.task.setId(local.task.getId()); data.remoteUpdated.set(remoteIndex, remote); } else { push(local, null); } } catch (Exception e) { handleException("sync-local-updated", e, false); //$NON-NLS-1$ } write(local); } } protected void sendLocallyCreated(SyncData<TYPE> data, HashMap<String, Integer> remoteNewTaskNameMap) throws IOException { int length; length = data.localCreated.getCount(); for(int i = 0; i < length; i++) { data.localCreated.moveToNext(); TYPE local = read(data.localCreated); try { String taskTitle = local.task.getValue(Task.TITLE); /* If there exists an incoming remote task with the same name and no * mapping, we don't want to create this on the remote server, * because user could have synchronized this before. Instead, * we create a mapping and do an update. */ if (remoteNewTaskNameMap.containsKey(taskTitle)) { int remoteIndex = remoteNewTaskNameMap.remove(taskTitle); TYPE remote = data.remoteUpdated.get(remoteIndex); transferIdentifiers(remote, local); remote = push(local, remote); // re-read remote task after merge, update remote task list remote.task.setId(local.task.getId()); data.remoteUpdated.set(remoteIndex, remote); } else { create(local); } } catch (Exception e) { handleException("sync-local-created", e, false); //$NON-NLS-1$ } write(local); } } // --- exception handling /** * Deal with a synchronization exception. If requested, will show an error * to the user (unless synchronization is happening in background) * * @param context * @param tag * error tag * @param e * exception * @param showError * whether to display a dialog */ protected void handleException(String tag, Exception e, boolean displayError) { //TODO: When Crittercism supports it, report error to them final Context context = ContextManager.getContext(); getUtilities().setLastError(e.toString(), ""); String message = null; // occurs when application was closed if(e instanceof IllegalStateException) { exceptionService.reportError(tag + "-caught", e); //$NON-NLS-1$ } // occurs when network error else if(e instanceof IOException) { exceptionService.reportError(tag + "-io", e); //$NON-NLS-1$ message = context.getString(R.string.SyP_ioerror); } // unhandled error else { message = context.getString(R.string.DLG_error, e.toString()); exceptionService.reportError(tag + "-unhandled", e); //$NON-NLS-1$ } if(displayError && context instanceof Activity && message != null) { DialogUtilities.okDialog((Activity)context, message, null); } } // --- helper classes /** data structure builder */ protected static class SyncData<TYPE extends SyncContainer> { public ArrayList<TYPE> remoteUpdated; public TodorooCursor<Task> localCreated; public TodorooCursor<Task> localUpdated; public SyncData(ArrayList<TYPE> remoteUpdated, TodorooCursor<Task> localCreated, TodorooCursor<Task> localUpdated) { super(); this.remoteUpdated = remoteUpdated; this.localCreated = localCreated; this.localUpdated = localUpdated; } } }