diff --git a/src/com/mdt/rtm/ServiceException.java b/src/com/mdt/rtm/ServiceException.java index ff25555bd..ad3b2bfd6 100644 --- a/src/com/mdt/rtm/ServiceException.java +++ b/src/com/mdt/rtm/ServiceException.java @@ -19,11 +19,13 @@ */ package com.mdt.rtm; +import java.io.IOException; + /** - * + * * @author Will Ross Jun 21, 2007 */ -public class ServiceException extends Exception { +public class ServiceException extends IOException { private static final long serialVersionUID = -6711156026040643361L; diff --git a/src/com/timsu/astrid/data/AbstractController.java b/src/com/timsu/astrid/data/AbstractController.java index eaa56f1ca..f54a330a4 100644 --- a/src/com/timsu/astrid/data/AbstractController.java +++ b/src/com/timsu/astrid/data/AbstractController.java @@ -40,6 +40,7 @@ abstract public class AbstractController { protected static final String TAG_TABLE_NAME = "tags"; protected static final String TAG_TASK_MAP_NAME = "tagTaskMap"; protected static final String ALERT_TABLE_NAME = "alerts"; + protected static final String SYNC_TABLE_NAME = "sync"; // cursor iterator diff --git a/src/com/timsu/astrid/data/alerts/Alert.java b/src/com/timsu/astrid/data/alerts/Alert.java index 96441ca4d..5306cf687 100644 --- a/src/com/timsu/astrid/data/alerts/Alert.java +++ b/src/com/timsu/astrid/data/alerts/Alert.java @@ -33,7 +33,7 @@ import com.timsu.astrid.data.AbstractModel; import com.timsu.astrid.data.task.TaskIdentifier; -/** A single tag on a task */ +/** A single alert on a task */ public class Alert extends AbstractModel { /** Version number of this model */ @@ -116,7 +116,7 @@ public class Alert extends AbstractModel { } public TaskIdentifier getTask() { - return new TaskIdentifier(retrieveInteger(TASK)); + return new TaskIdentifier(retrieveLong(TASK)); } public Date getDate() { diff --git a/src/com/timsu/astrid/data/sync/SyncDataController.java b/src/com/timsu/astrid/data/sync/SyncDataController.java new file mode 100644 index 000000000..2815faf3d --- /dev/null +++ b/src/com/timsu/astrid/data/sync/SyncDataController.java @@ -0,0 +1,127 @@ +/* + * ASTRID: Android's Simple Task Recording Dashboard + * + * Copyright (c) 2009 Tim Su + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ +package com.timsu.astrid.data.sync; + +import java.util.HashSet; +import java.util.Set; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; + +import com.timsu.astrid.data.AbstractController; +import com.timsu.astrid.data.sync.SyncMapping.SyncMappingDatabaseHelper; +import com.timsu.astrid.data.task.TaskIdentifier; + +/** Controller for Tag-related operations */ +public class SyncDataController extends AbstractController { + + private SQLiteDatabase syncDatabase; + + + // --- updated tasks list + + /** Mark all updated tasks as finished synchronizing */ + public boolean clearUpdatedTaskList(int syncServiceId) throws SQLException { + ContentValues values = new ContentValues(); + values.put(SyncMapping.UPDATED, 0); + return syncDatabase.update(SYNC_TABLE_NAME, values, + SyncMapping.SYNC_SERVICE + " = " + syncServiceId, null) > 0; + } + + /** Indicate that this task's properties were updated */ + public boolean addToUpdatedList(TaskIdentifier taskId) throws SQLException { + ContentValues values = new ContentValues(); + values.put(SyncMapping.UPDATED, 1); + return syncDatabase.update(SYNC_TABLE_NAME, values, + SyncMapping.TASK + " = " + taskId.getId(), null) > 0; + } + + // --- sync mapping + + /** Get all mappings for the given synchronization service */ + public Set getSyncMapping(int syncServiceId) throws SQLException { + Set list = new HashSet(); + Cursor cursor = syncDatabase.query(SYNC_TABLE_NAME, + SyncMapping.FIELD_LIST, + SyncMapping.SYNC_SERVICE + " = " + syncServiceId, + null, null, null, null); + + try { + if(cursor.getCount() == 0) + return list; + do { + cursor.moveToNext(); + list.add(new SyncMapping(cursor)); + } while(!cursor.isLast()); + + return list; + } finally { + cursor.close(); + } + } + + /** Saves the given task to the database. Returns true on success. */ + public boolean saveSyncMapping(SyncMapping mapping) { + long newRow = syncDatabase.insert(SYNC_TABLE_NAME, SyncMapping.TASK, + mapping.getMergedValues()); + + return newRow >= 0; + } + + /** Deletes the given mapping. Returns true on success */ + public boolean deleteSyncMapping(SyncMapping mapping) { + return syncDatabase.delete(SYNC_TABLE_NAME, KEY_ROWID + "=" + + mapping.getId(), null) > 0; + } + + // --- boilerplate + + /** + * Constructor - takes the context to allow the database to be + * opened/created + */ + public SyncDataController(Context context) { + this.context = context; + } + + /** + * Open the notes database. If it cannot be opened, try to create a new + * instance of the database. If it cannot be created, throw an exception to + * signal the failure + * + * @return this (self reference, allowing this to be chained in an + * initialization call) + * @throws SQLException if the database could be neither opened or created + */ + public SyncDataController open() throws SQLException { + syncDatabase = new SyncMappingDatabaseHelper(context, + SYNC_TABLE_NAME, SYNC_TABLE_NAME).getWritableDatabase(); + + return this; + } + + /** Closes database resource */ + public void close() { + syncDatabase.close(); + } +} diff --git a/src/com/timsu/astrid/data/sync/SyncMapping.java b/src/com/timsu/astrid/data/sync/SyncMapping.java new file mode 100644 index 000000000..98d2c580c --- /dev/null +++ b/src/com/timsu/astrid/data/sync/SyncMapping.java @@ -0,0 +1,167 @@ +/* + * ASTRID: Android's Simple Task Recording Dashboard + * + * Copyright (c) 2009 Tim Su + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ +package com.timsu.astrid.data.sync; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.util.Log; + +import com.timsu.astrid.data.AbstractController; +import com.timsu.astrid.data.AbstractModel; +import com.timsu.astrid.data.task.TaskIdentifier; +import com.timsu.astrid.sync.TaskProxy; + + +/** A single tag on a task */ +public class SyncMapping extends AbstractModel { + + + /** Version number of this model */ + static final int VERSION = 1; + + // field names + + static final String TASK = "task"; + static final String SYNC_SERVICE = "service"; + static final String REMOTE_ID = "remoteId"; + static final String UPDATED = "updated"; + + /** Default values container */ + private static final ContentValues defaultValues = new ContentValues(); + static { + defaultValues.put(UPDATED, 0); + } + + @Override + public ContentValues getDefaultValues() { + return defaultValues; + } + + static String[] FIELD_LIST = new String[] { + AbstractController.KEY_ROWID, + TASK, + SYNC_SERVICE, + REMOTE_ID, + UPDATED, + }; + + // --- database helper + + /** Database Helper manages creating new tables and updating old ones */ + static class SyncMappingDatabaseHelper extends SQLiteOpenHelper { + String tableName; + + SyncMappingDatabaseHelper(Context context, String databaseName, String tableName) { + super(context, databaseName, null, VERSION); + this.tableName = tableName; + } + + @Override + public void onCreate(SQLiteDatabase db) { + String sql = new StringBuilder(). + append("CREATE TABLE ").append(tableName).append(" ("). + append(AbstractController.KEY_ROWID).append(" integer primary key autoincrement, "). + append(TASK).append(" integer not null,"). + append(SYNC_SERVICE).append(" integer not null,"). + append(REMOTE_ID).append(" text not null,"). + append(UPDATED).append(" integer not null,"). + append("unique (").append(TASK).append(",").append(SYNC_SERVICE).append(")"). + append(");").toString(); + db.execSQL(sql); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + Log.w(getClass().getSimpleName(), "Upgrading database from version " + + oldVersion + " to " + newVersion + "."); + + switch(oldVersion) { + default: + // we don't know how to handle it... do the unfortunate thing + Log.e(getClass().getSimpleName(), "Unsupported migration, table dropped!"); + db.execSQL("DROP TABLE IF EXISTS " + tableName); + onCreate(db); + } + } + } + + + // --- constructor pass-through + + public SyncMapping(TaskIdentifier task, TaskProxy taskProxy) { + this(task, taskProxy.getSyncServiceId(), taskProxy.getRemoteId()); + } + + public SyncMapping(TaskIdentifier task, int syncServiceId, String remoteId) { + super(); + setTask(task); + setSyncServiceId(syncServiceId); + setRemoteId(remoteId); + } + + SyncMapping(Cursor cursor) { + super(cursor); + getId(); + getTask(); + getSyncServiceId(); + getRemoteId(); + } + + // --- getters and setters + + public long getId() { + return retrieveLong(AbstractController.KEY_ROWID); + } + + public TaskIdentifier getTask() { + return new TaskIdentifier(retrieveLong(TASK)); + } + + public int getSyncServiceId() { + return retrieveInteger(SYNC_SERVICE); + } + + public String getRemoteId() { + return retrieveString(REMOTE_ID); + } + + public boolean isUpdated() { + return retrieveInteger(UPDATED) == 1; + } + + private void setTask(TaskIdentifier task) { + setValues.put(TASK, task.getId()); + } + + private void setSyncServiceId(int id) { + setValues.put(SYNC_SERVICE, id); + } + + private void setRemoteId(String remoteId) { + setValues.put(REMOTE_ID, remoteId); + } + + private void setUpdated(boolean updated) { + setValues.put(UPDATED, updated ? 1 : 0); + } +} diff --git a/src/com/timsu/astrid/data/task/TaskController.java b/src/com/timsu/astrid/data/task/TaskController.java index 74d015de6..a9f5afd66 100644 --- a/src/com/timsu/astrid/data/task/TaskController.java +++ b/src/com/timsu/astrid/data/task/TaskController.java @@ -103,7 +103,7 @@ public class TaskController extends AbstractController { null, null, null, null, null, null); } - /** Create a weighted list of tasks from the db cursor given */ + /** Create a list of tasks from the db cursor given */ public List createTaskListFromCursor(Cursor cursor) { List list = new ArrayList(); @@ -118,6 +118,23 @@ public class TaskController extends AbstractController { return list; } + /** Get identifiers for all tasks */ + public Set getAllTaskIdentifiers() { + Set list = new HashSet(); + Cursor cursor = database.query(TASK_TABLE_NAME, new String[] { KEY_ROWID }, + null, null, null, null, null, null); + if(cursor.getCount() == 0) + return list; + + do { + cursor.moveToNext(); + list.add(new TaskIdentifier(cursor.getInt( + cursor.getColumnIndexOrThrow(KEY_ROWID)))); + } while(!cursor.isLast()); + + return list; + } + /** Create a weighted list of tasks from the db cursor given */ public Cursor getTaskListCursorById(List idList) { @@ -244,6 +261,14 @@ public class TaskController extends AbstractController { return model; } + /** Returns a TaskModelForView corresponding to the given TaskIdentifier */ + public TaskModelForSync fetchTaskForSync(TaskIdentifier taskId) throws SQLException { + Cursor cursor = fetchTaskCursor(taskId, TaskModelForSync.FIELD_LIST); + TaskModelForSync model = new TaskModelForSync(cursor); + cursor.close(); + return model; + } + /** Returns a TaskModelForView corresponding to the given TaskIdentifier */ public TaskModelForNotify fetchTaskForNotify(TaskIdentifier taskId) throws SQLException { Cursor cursor = fetchTaskCursor(taskId, TaskModelForNotify.FIELD_LIST); diff --git a/src/com/timsu/astrid/data/task/TaskModelForSync.java b/src/com/timsu/astrid/data/task/TaskModelForSync.java new file mode 100644 index 000000000..1ca32466c --- /dev/null +++ b/src/com/timsu/astrid/data/task/TaskModelForSync.java @@ -0,0 +1,200 @@ +/* + * ASTRID: Android's Simple Task Recording Dashboard + * + * Copyright (c) 2009 Tim Su + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ +package com.timsu.astrid.data.task; + +import java.util.Date; + +import android.database.Cursor; + +import com.timsu.astrid.data.enums.Importance; + + + +/** Fields that you would want to synchronize in the TaskModel */ +public class TaskModelForSync extends AbstractTaskModel { + + static String[] FIELD_LIST = new String[] { + NAME, + IMPORTANCE, + ESTIMATED_SECONDS, + ELAPSED_SECONDS, + DEFINITE_DUE_DATE, + PREFERRED_DUE_DATE, + HIDDEN_UNTIL, + BLOCKING_ON, + PROGRESS_PERCENTAGE, + CREATION_DATE, + COMPLETION_DATE, + NOTES, + REPEAT, + }; + + // --- constructors + + public TaskModelForSync() { + super(); + setCreationDate(new Date()); + } + + public TaskModelForSync(Cursor cursor) { + super(cursor); + prefetchData(FIELD_LIST); + } + + // --- getters and setters + + @Override + public boolean isTaskCompleted() { + return super.isTaskCompleted(); + } + + @Override + public Date getDefiniteDueDate() { + return super.getDefiniteDueDate(); + } + + @Override + public Integer getEstimatedSeconds() { + return super.getEstimatedSeconds(); + } + + @Override + public int getProgressPercentage() { + return super.getProgressPercentage(); + } + + @Override + public Date getCreationDate() { + return super.getCreationDate(); + } + + @Override + public Date getCompletionDate() { + return super.getCompletionDate(); + } + + @Override + public Integer getElapsedSeconds() { + return super.getElapsedSeconds(); + } + + @Override + public Date getHiddenUntil() { + return super.getHiddenUntil(); + } + + @Override + public Importance getImportance() { + return super.getImportance(); + } + + @Override + public String getName() { + return super.getName(); + } + + @Override + public String getNotes() { + return super.getNotes(); + } + + @Override + public Date getPreferredDueDate() { + return super.getPreferredDueDate(); + } + + @Override + public TaskIdentifier getBlockingOn() { + return super.getBlockingOn(); + } + + @Override + public RepeatInfo getRepeat() { + return super.getRepeat(); + } + + @Override + public void setDefiniteDueDate(Date definiteDueDate) { + super.setDefiniteDueDate(definiteDueDate); + } + + @Override + public void setEstimatedSeconds(Integer estimatedSeconds) { + super.setEstimatedSeconds(estimatedSeconds); + } + + @Override + public void setElapsedSeconds(int elapsedSeconds) { + super.setElapsedSeconds(elapsedSeconds); + } + + @Override + public void setHiddenUntil(Date hiddenUntil) { + super.setHiddenUntil(hiddenUntil); + } + + @Override + public void setImportance(Importance importance) { + super.setImportance(importance); + } + + @Override + public void setName(String name) { + super.setName(name); + } + + @Override + public void setNotes(String notes) { + super.setNotes(notes); + } + + @Override + public void setPreferredDueDate(Date preferredDueDate) { + super.setPreferredDueDate(preferredDueDate); + } + + @Override + public void setBlockingOn(TaskIdentifier blockingOn) { + super.setBlockingOn(blockingOn); + } + + @Override + public void setRepeat(RepeatInfo taskRepeat) { + super.setRepeat(taskRepeat); + } + + @Override + public void setCompletionDate(Date completionDate) { + super.setCompletionDate(completionDate); + } + + @Override + public void setCreationDate(Date creationDate) { + super.setCreationDate(creationDate); + } + + @Override + public void setProgressPercentage(int progressPercentage) { + super.setProgressPercentage(progressPercentage); + } + + +} + diff --git a/src/com/timsu/astrid/sync/RTMSyncService.java b/src/com/timsu/astrid/sync/RTMSyncService.java index f8f886cf7..8018f75d8 100644 --- a/src/com/timsu/astrid/sync/RTMSyncService.java +++ b/src/com/timsu/astrid/sync/RTMSyncService.java @@ -1,6 +1,9 @@ package com.timsu.astrid.sync; -import java.util.Map.Entry; +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import java.util.StringTokenizer; import android.app.Activity; import android.content.DialogInterface; @@ -10,21 +13,35 @@ import android.net.Uri; import android.util.Log; import com.mdt.rtm.ApplicationInfo; +import com.mdt.rtm.ServiceException; import com.mdt.rtm.ServiceImpl; import com.mdt.rtm.data.RtmList; import com.mdt.rtm.data.RtmLists; +import com.mdt.rtm.data.RtmTask; +import com.mdt.rtm.data.RtmTaskList; +import com.mdt.rtm.data.RtmTaskNote; +import com.mdt.rtm.data.RtmTaskSeries; +import com.mdt.rtm.data.RtmTasks; import com.mdt.rtm.data.RtmAuth.Perms; +import com.mdt.rtm.data.RtmTask.Priority; import com.timsu.astrid.R; +import com.timsu.astrid.data.enums.Importance; +import com.timsu.astrid.data.sync.SyncMapping; import com.timsu.astrid.utilities.DialogUtilities; import com.timsu.astrid.utilities.Preferences; -public class RTMSyncService implements SynchronizationService { +public class RTMSyncService extends SynchronizationService { private ServiceImpl rtmService = null; - private int id; + private static final String ASTRID_LIST_NAME = "Astrid"; public RTMSyncService(int id) { - this.id = id; + super(id); + } + + @Override + String getName() { + return "RTM"; } @Override @@ -33,7 +50,7 @@ public class RTMSyncService implements SynchronizationService { } @Override - public void synchronizationDisabled(Activity activity) { + public void clearPersonalData(Activity activity) { Preferences.setSyncRTMToken(activity, null); } @@ -45,7 +62,7 @@ public class RTMSyncService implements SynchronizationService { String appName = null; String authToken = Preferences.getSyncRTMToken(activity); - // check if our auth token works + // check if we have a token & it works if(authToken != null) { rtmService = new ServiceImpl(new ApplicationInfo( apiKey, sharedSecret, appName, authToken)); @@ -54,7 +71,7 @@ public class RTMSyncService implements SynchronizationService { } if(authToken == null) { - // try completing the authorization. + // try completing the authorization if it was partial if(rtmService != null) { try { String token = rtmService.completeAuthorization(); @@ -88,32 +105,141 @@ public class RTMSyncService implements SynchronizationService { } } catch (Exception e) { - Synchronizer.showError(activity, e); + showError(activity, e); } } private void performSync(Activity activity) { try { - Log.i("astrid", "isAuthorized: " + rtmService.isServiceAuthorized()); + // get RTM timeline + final String timeline = rtmService.timelines_create(); + + // get / create astrid list + RtmList astridList = null; RtmLists lists = rtmService.lists_getList(); - for(Entry list : lists.getLists().entrySet()) { - Log.i("astrid", "look, " + list.getKey()); + for(RtmList list : lists.getLists().values()) { + if(ASTRID_LIST_NAME.equals(list.getName())) { + astridList = list; + break; + } + } + if(astridList == null) + astridList = rtmService.lists_add(timeline, ASTRID_LIST_NAME); + final RtmList newTaskCreationList = astridList; + + RtmTasks tasks = rtmService.tasks_getList(null, null, + Preferences.getSyncRTMLastSync(activity)); + + List remoteChanges = new LinkedList(); + for(RtmTaskList taskList : tasks.getLists()) { + for(RtmTaskSeries taskSeries : taskList.getSeries()) { + TaskProxy remoteTask = parseRemoteTask(taskList.getId(), taskSeries); + remoteChanges.add(remoteTask); + } } - // fetch tasks that've changed since last sync + synchronizeTasks(activity, remoteChanges, new SynchronizeHelper() { + @Override + public String createTask() throws IOException { + RtmTaskSeries s = rtmService.tasks_add(timeline, + newTaskCreationList.getId(), "tmp"); + return new RtmId(newTaskCreationList.getId(), s).toString(); + } + @Override + public void deleteTask(SyncMapping mapping) throws IOException { + RtmId id = new RtmId(mapping.getRemoteId()); + rtmService.tasks_delete(timeline, id.listId, id.taskSeriesId, + id.taskId); + } + @Override + public void pushTask(TaskProxy task, SyncMapping mapping) throws IOException { + pushLocalTask(timeline, task, mapping); + } + @Override + public TaskProxy refetchTask(TaskProxy task) throws IOException { + RtmId id = new RtmId(task.getRemoteId()); + RtmTaskSeries rtmTask = rtmService.tasks_getTask(task.getRemoteId(), + task.name); + return parseRemoteTask(id.listId, rtmTask); + } + }); - // grab my own list of tasks that have changed since last sync + } catch (Exception e) { + showError(activity, e); + } + } + + /** Helper class for processing RTM id's into one field */ + private static class RtmId { + String taskId; + String taskSeriesId; + String listId; + public RtmId(String listId, RtmTaskSeries taskSeries) { + this.taskId = taskSeries.getTask().getId(); + this.taskSeriesId = taskSeries.getId(); + this.listId = listId; + } - // if we find a conflict... remember and ignore + public RtmId(String id) { + StringTokenizer strtok = new StringTokenizer(id, "|"); + taskId = strtok.nextToken(); + taskSeriesId = strtok.nextToken(); + listId = strtok.nextToken(); + } + @Override + public String toString() { + return taskId + "|" + taskSeriesId + "|" + listId; + } + } - // update tasks that have changed + /** Send changes for the given TaskProxy across the wire */ + public void pushLocalTask(String timeline, TaskProxy task, SyncMapping mapping) + throws ServiceException { + RtmId id = new RtmId(mapping.getRemoteId()); + + if(task.name != null) + rtmService.tasks_setName(timeline, id.listId, id.taskSeriesId, + id.taskId, task.name); + if(task.importance != null) + rtmService.tasks_setPriority(timeline, id.listId, id.taskSeriesId, + id.taskId, Priority.values()[task.importance.ordinal()]); + rtmService.tasks_setDueDate(timeline, id.listId, id.taskSeriesId, + id.taskId, task.definiteDueDate, task.definiteDueDate != null); + if(task.progressPercentage != null) { + if(task.progressPercentage == 100) + rtmService.tasks_complete(timeline, id.listId, id.taskSeriesId, + id.taskId); + else + rtmService.tasks_uncomplete(timeline, id.listId, id.taskSeriesId, + id.taskId); + } + } + /** Create a task proxy for the given RtmTaskSeries */ + private TaskProxy parseRemoteTask(String listId, RtmTaskSeries rtmTaskSeries) { + TaskProxy task = new TaskProxy(getId(), + new RtmId(listId, rtmTaskSeries).toString(), + rtmTaskSeries.getTask().getDeleted() != null); - } catch (Exception e) { - Synchronizer.showError(activity, e); + task.name = rtmTaskSeries.getName(); + StringBuilder sb = new StringBuilder(); + for(RtmTaskNote note: rtmTaskSeries.getNotes().getNotes()) { + sb.append(note.getText() + "\n"); } + task.notes = sb.toString(); + + RtmTask rtmTask = rtmTaskSeries.getTask(); + task.creationDate = rtmTaskSeries.getCreated(); + task.completionDate = rtmTask.getCompleted(); + if(rtmTask.getHasDueTime() > 0) + task.definiteDueDate = rtmTask.getDue(); + task.progressPercentage = (rtmTask.getCompleted() == null) ? null : 100; + + task.importance = Importance.values()[rtmTask.getPriority().ordinal()]; + + return task; } } diff --git a/src/com/timsu/astrid/sync/SynchronizationService.java b/src/com/timsu/astrid/sync/SynchronizationService.java index caa938bff..6725e8d71 100644 --- a/src/com/timsu/astrid/sync/SynchronizationService.java +++ b/src/com/timsu/astrid/sync/SynchronizationService.java @@ -1,17 +1,281 @@ package com.timsu.astrid.sync; +import java.io.IOException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.util.Log; + +import com.timsu.astrid.R; +import com.timsu.astrid.data.sync.SyncMapping; +import com.timsu.astrid.data.task.TaskIdentifier; +import com.timsu.astrid.data.task.TaskModelForSync; +import com.timsu.astrid.utilities.DialogUtilities; /** A service that synchronizes with Astrid * * @author timsu * */ -public interface SynchronizationService { +public abstract class SynchronizationService { + + private int id; + + public SynchronizationService(int id) { + this.id = id; + } /** Synchronize with the service */ - void synchronize(Activity activity); + abstract void synchronize(Activity activity); + + /** Called when user requests a data clear */ + abstract void clearPersonalData(Activity activity); + + /** Get this service's id */ + public int getId() { + return id; + } + + /** Gets this service's name */ + abstract String getName(); + + // --- utilities + + /** Utility class for showing synchronization errors */ + static void showError(Context context, Throwable e) { + Log.e("astrid", "Synchronization Error", e); + + Resources r = context.getResources(); + DialogUtilities.okDialog(context, + r.getString(R.string.sync_error) + " " + + e.getLocalizedMessage(), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // do nothing? + } + }); + } + + // --- synchronization logic + + /** interface to assist with synchronization */ + protected interface SynchronizeHelper { + /** Push the given task to the remote server. + * + * @param task task proxy to push + * @param mapping local/remote mapping. + */ + void pushTask(TaskProxy task, SyncMapping mapping) throws IOException; + + /** Create a task on the remote server + * + * @return remote id + */ + String createTask() throws IOException; + + /** Fetch remote task. Used to re-read merged tasks + * + * @param task TaskProxy of the original task + * @return new TaskProxy + */ + TaskProxy refetchTask(TaskProxy task) throws IOException; + + /** Delete the task from the remote server + * + * @param mapping mapping to delete + */ + void deleteTask(SyncMapping mapping) throws IOException; + } + + /** 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 remoteTasks remote tasks that have been updated + * @return local tasks that need to be pushed across + */ + protected void synchronizeTasks(Activity activity, List remoteTasks, + SynchronizeHelper helper) throws IOException { + SyncStats stats = new SyncStats(); + + // get data out of the database + Set mappings = Synchronizer.getSyncController().getSyncMapping(getId()); + Set localTasks = Synchronizer.getTaskController().getAllTaskIdentifiers(); + + // build local maps / lists + Map remoteIdToSyncMapping = + new HashMap(); + Map localIdToSyncMapping = + new HashMap(); + Set localChanges = new HashSet(); + Set mappedTasks = new HashSet(); + for(SyncMapping mapping : mappings) { + if(mapping.isUpdated()) + localChanges.add(mapping); + remoteIdToSyncMapping.put(mapping.getRemoteId(), mapping); + localIdToSyncMapping.put(mapping.getTask(), mapping); + mappedTasks.add(mapping.getTask()); + } + + // build remote map + Map remoteChangeMap = + new HashMap(); + for(TaskProxy remoteTask : remoteTasks) { + if(remoteIdToSyncMapping.containsKey(remoteTask.getRemoteId())) { + SyncMapping mapping = remoteIdToSyncMapping.get(remoteTask.getRemoteId()); + remoteChangeMap.put(mapping.getTask(), remoteTask); + } + } + + // grab tasks without a sync mapping and create them remotely + Set newlyCreatedTasks = new HashSet(localTasks); + newlyCreatedTasks.removeAll(mappedTasks); + for(TaskIdentifier taskId : newlyCreatedTasks) { + String remoteId = helper.createTask(); + stats.remoteCreatedTasks++; + SyncMapping mapping = new SyncMapping(taskId, getId(), remoteId); + Synchronizer.getSyncController().saveSyncMapping(mapping); + + // add it to data structures + localChanges.add(mapping); + } + + // find deleted tasks and remove them from the list + Set deletedTasks = new HashSet(mappedTasks); + deletedTasks.removeAll(localTasks); + for(TaskIdentifier taskId : deletedTasks) { + stats.remoteDeletedTasks++; + SyncMapping mapping = localIdToSyncMapping.get(taskId); + Synchronizer.getSyncController().deleteSyncMapping(mapping); + helper.deleteTask(mapping); + + // remove it from data structures + localChanges.remove(mapping); + remoteIdToSyncMapping.remove(mapping); + remoteChangeMap.remove(taskId); + } + + // for each updated local task + for(SyncMapping mapping : localChanges) { + TaskProxy localTask = new TaskProxy(getId(), mapping.getRemoteId(), false); + TaskModelForSync task = Synchronizer.getTaskController().fetchTaskForSync( + mapping.getTask()); + localTask.readFromTaskModel(task); + + // if there is a conflict, merge + TaskProxy remoteConflict = null; + if(remoteChangeMap.containsKey(mapping.getTask())) { + remoteConflict = remoteChangeMap.get(mapping.getTask()); + localTask.mergeWithOther(remoteConflict); + stats.mergedTasks++; + } + + helper.pushTask(localTask, mapping); + + // re-fetch remote task + if(remoteConflict != null) { + TaskProxy newTask = helper.refetchTask(remoteConflict); + remoteTasks.remove(remoteConflict); + remoteTasks.add(newTask); + } + stats.remoteUpdatedTasks++; + } + stats.remoteUpdatedTasks -= stats.remoteCreatedTasks; + + // load remote information + for(TaskProxy remoteTask : remoteTasks) { + SyncMapping mapping = null; + TaskModelForSync task = null; + + // if it's new, create a new task model + if(!remoteIdToSyncMapping.containsKey(remoteTask.getRemoteId())) { + // if it's new & deleted, forget about it + if(remoteTask.isDeleted()) { + continue; + } + + task = new TaskModelForSync(); + stats.localCreatedTasks++; + } else { + mapping = remoteIdToSyncMapping.get(remoteTask.getRemoteId()); + if(remoteTask.isDeleted()) { + Synchronizer.getTaskController().deleteTask(mapping.getTask()); + Synchronizer.getSyncController().deleteSyncMapping(mapping); + stats.localDeletedTasks++; + continue; + } + + task = Synchronizer.getTaskController().fetchTaskForSync( + mapping.getTask()); + } + + // save the data + remoteTask.writeToTaskModel(task); + Synchronizer.getTaskController().saveTask(task); + stats.localUpdatedTasks++; + + if(mapping == null) { + mapping = new SyncMapping(task.getTaskIdentifier(), remoteTask); + Synchronizer.getSyncController().saveSyncMapping(mapping); + } + } + stats.localUpdatedTasks -= stats.localCreatedTasks; + + Synchronizer.getSyncController().clearUpdatedTaskList(getId()); + stats.showDialog(activity); + } + + // --- helper classes + + private class SyncStats { + int localCreatedTasks = 0; + int localUpdatedTasks = 0; + int localDeletedTasks = 0; + + int mergedTasks = 0; + + int remoteCreatedTasks = 0; + int remoteUpdatedTasks = 0; + int remoteDeletedTasks = 0; + + /** Display a dialog with statistics */ + public void showDialog(Context context) { + if(equals(new SyncStats())) // i.e. no change + return; + + StringBuilder sb = new StringBuilder(); + sb.append(getName()).append(" Sync Results:"); // TODO i18n + sb.append("\n\nLocal ---"); + if(localCreatedTasks > 0) + sb.append("\nCreated: " + localCreatedTasks); + if(localUpdatedTasks > 0) + sb.append("\nUpdated: " + localUpdatedTasks); + if(localDeletedTasks > 0) + sb.append("\nDeleted: " + localDeletedTasks); + + if(mergedTasks > 0) + sb.append("\n\nMerged: " + localCreatedTasks); + + sb.append("\n\nRemote ---"); + if(remoteCreatedTasks > 0) + sb.append("\nCreated: " + remoteCreatedTasks); + if(remoteUpdatedTasks > 0) + sb.append("\nUpdated: " + remoteUpdatedTasks); + if(remoteDeletedTasks > 0) + sb.append("\nDeleted: " + remoteDeletedTasks); - /** Called when synchronization with this service is turned off */ - void synchronizationDisabled(Activity activity); + DialogUtilities.okDialog(context, sb.toString(), null); + } + } } diff --git a/src/com/timsu/astrid/sync/Synchronizer.java b/src/com/timsu/astrid/sync/Synchronizer.java index c5b87db3e..2747bb5fb 100644 --- a/src/com/timsu/astrid/sync/Synchronizer.java +++ b/src/com/timsu/astrid/sync/Synchronizer.java @@ -4,20 +4,42 @@ import java.util.HashMap; import java.util.Map; import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.content.res.Resources; -import android.util.Log; -import com.timsu.astrid.R; -import com.timsu.astrid.utilities.DialogUtilities; +import com.timsu.astrid.data.sync.SyncDataController; +import com.timsu.astrid.data.task.TaskController; import com.timsu.astrid.utilities.Preferences; public class Synchronizer { - /* Synchronization Service ID's */ + // Synchronization Service ID's private static final int SYNC_ID_RTM = 1; + // --- public interface + + /** Synchronize all activated sync services */ + public static void synchronize(final Activity activity) { + // kick off a new thread + new Thread(new Runnable() { + @Override + public void run() { + // RTM sync + if(Preferences.shouldSyncRTM(activity)) { + openControllers(activity); + services.get(SYNC_ID_RTM).synchronize(activity); + } + + closeControllers(); + } + }, "sync").start(); + } + + /** Clears tokens if services are disabled */ + public static void synchronizerStatusUpdated(Activity activity) { + // do nothing + } + + // --- package helpers + /** Service map */ private static Map services = new HashMap(); @@ -25,38 +47,39 @@ public class Synchronizer { services.put(SYNC_ID_RTM, new RTMSyncService(SYNC_ID_RTM)); } - // --- public interface - - /** Synchronize all activated sync services */ - public static void synchronize(Activity activity) { - // RTM sync - if(Preferences.shouldSyncRTM(activity)) { - services.get(SYNC_ID_RTM).synchronize(activity); - } + static SyncDataController getSyncController() { + return syncController; } - /** Clears tokens if services are disabled */ - public static void synchronizerStatusUpdated(Activity activity) { - if(!Preferences.shouldSyncRTM(activity)) { - services.get(SYNC_ID_RTM).synchronizationDisabled(activity); - } + static TaskController getTaskController() { + return taskController; } - // --- package utilities + // --- controller stuff + private static SyncDataController syncController = null; + private static TaskController taskController = null; - /** Utility class for showing synchronization errors */ - static void showError(Context context, Throwable e) { - Log.e("astrid", "Synchronization Error", e); + private static void openControllers(Activity activity) { + if(syncController == null) { + syncController = new SyncDataController(activity); + syncController.open(); + } - Resources r = context.getResources(); - DialogUtilities.okDialog(context, - r.getString(R.string.sync_error) + " " + - e.getLocalizedMessage(), new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - // do nothing? - } - }); + if(taskController == null) { + taskController = new TaskController(activity); + taskController.open(); + } } + private static void closeControllers() { + if(syncController != null) { + syncController.close(); + syncController = null; + } + + if(taskController != null) { + taskController.close(); + taskController = null; + } + } } diff --git a/src/com/timsu/astrid/sync/TaskProxy.java b/src/com/timsu/astrid/sync/TaskProxy.java new file mode 100644 index 000000000..b66ab1bc3 --- /dev/null +++ b/src/com/timsu/astrid/sync/TaskProxy.java @@ -0,0 +1,168 @@ +package com.timsu.astrid.sync; + +import java.util.Date; + +import com.timsu.astrid.data.enums.Importance; +import com.timsu.astrid.data.enums.RepeatInterval; +import com.timsu.astrid.data.task.TaskModelForSync; +import com.timsu.astrid.data.task.AbstractTaskModel.RepeatInfo; + +/** Representation of a task on a remote server. Your synchronization + * service should instantiate these, filling out every field (use null + * where the field does not exist). + * + * @author timsu + * + */ +public class TaskProxy { + + TaskProxy(int syncServiceId, String syncTaskId, boolean isDeleted) { + this.syncServiceId = syncServiceId; + this.syncTaskId = syncTaskId; + this.isDeleted = isDeleted; + } + + // --- fill these out + + String name = null; + String notes = null; + + Importance importance = null; + Integer progressPercentage = null; + + Date creationDate = null; + Date completionDate = null; + + Date definiteDueDate = null; + Date preferredDueDate = null; + Date hiddenUntil = null; + + String[] tags = null; + + Integer estimatedSeconds = null; + Integer elapsedSeconds = null; + Integer repeatEveryNSeconds = null; + + // --- internal state + + /** id of the synchronization service */ + private int syncServiceId; + + /** id of this particular remote task */ + private String syncTaskId; + + /** was the task deleted on the remote server */ + private boolean isDeleted = false; + + public int getSyncServiceId() { + return syncServiceId; + } + + public String getRemoteId() { + return syncTaskId; + } + + public boolean isDeleted() { + return isDeleted; + } + + // --- helper methods + + /** Merge with another TaskProxy. Fields in this taskProxy will be overwritten! */ + public void mergeWithOther(TaskProxy other) { + if(other == null) + return; + + if(other.name != null) + name = other.name; + if(other.notes != null) + notes = other.notes; + if(other.importance != null) + importance = other.importance; + if(other.progressPercentage != null) + progressPercentage = other.progressPercentage; + if(other.creationDate != null) + creationDate = other.creationDate; + if(other.completionDate != null) + completionDate = other.completionDate; + if(other.definiteDueDate != null) + definiteDueDate = other.definiteDueDate; + if(other.preferredDueDate != null) + preferredDueDate = other.preferredDueDate; + if(other.hiddenUntil != null) + hiddenUntil = other.hiddenUntil; + if(other.tags != null) + tags = other.tags; + if(other.estimatedSeconds != null) + estimatedSeconds = other.estimatedSeconds; + if(other.elapsedSeconds != null) + elapsedSeconds = other.elapsedSeconds; + if(other.repeatEveryNSeconds != null) + repeatEveryNSeconds = other.repeatEveryNSeconds; + } + + /** Read from the given task model */ + public void readFromTaskModel(TaskModelForSync task) { + name = task.getName(); + notes = task.getNotes(); + importance = task.getImportance(); + progressPercentage = task.getProgressPercentage(); + creationDate = task.getCreationDate(); + completionDate = task.getCompletionDate(); + definiteDueDate = task.getDefiniteDueDate(); + preferredDueDate = task.getPreferredDueDate(); + hiddenUntil = task.getHiddenUntil(); + estimatedSeconds = task.getEstimatedSeconds(); + elapsedSeconds = task.getElapsedSeconds(); + RepeatInfo repeatInfo = task.getRepeat(); + if(repeatInfo != null) { + repeatEveryNSeconds = (int)(repeatInfo.shiftDate(new Date(0)).getTime()/1000); + } + } + + /** Write to the given task model */ + public void writeToTaskModel(TaskModelForSync task) { + if(name != null) + task.setName(name); + if(notes != null) + task.setNotes(notes); + if(importance != null) + task.setImportance(importance); + if(progressPercentage != null) + task.setProgressPercentage(progressPercentage); + if(creationDate != null) + task.setCreationDate(creationDate); + if(completionDate != null) + task.setCompletionDate(completionDate); + if(definiteDueDate != null) + task.setDefiniteDueDate(definiteDueDate); + if(preferredDueDate != null) + task.setPreferredDueDate(preferredDueDate); + if(hiddenUntil != null) + task.setHiddenUntil(hiddenUntil); + + // TODO tags + + if(estimatedSeconds != null) + task.setEstimatedSeconds(estimatedSeconds); + if(elapsedSeconds != null) + task.setElapsedSeconds(elapsedSeconds); + + // this is inaccurate. =/ + if(repeatEveryNSeconds != null) { + RepeatInterval repeatInterval; + int repeatValue; + if(repeatEveryNSeconds < 7 * 24 * 3600) { + repeatInterval = RepeatInterval.DAYS; + repeatValue = repeatEveryNSeconds / (24 * 3600); + } else if(repeatEveryNSeconds < 30 * 24 * 3600) { + repeatInterval = RepeatInterval.WEEKS; + repeatValue = repeatEveryNSeconds / (7 * 24 * 3600); + } else { + repeatInterval = RepeatInterval.MONTHS; + repeatValue = repeatEveryNSeconds / (30 * 24 * 3600); + } + task.setRepeat(new RepeatInfo(repeatInterval, repeatValue)); + } + } +} diff --git a/src/com/timsu/astrid/utilities/Preferences.java b/src/com/timsu/astrid/utilities/Preferences.java index fe67515e1..4ef60a5e3 100644 --- a/src/com/timsu/astrid/utilities/Preferences.java +++ b/src/com/timsu/astrid/utilities/Preferences.java @@ -1,5 +1,7 @@ package com.timsu.astrid.utilities; +import java.util.Date; + import android.content.Context; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; @@ -15,6 +17,7 @@ public class Preferences { private static final String P_CURRENT_VERSION = "cv"; private static final String P_SHOW_REPEAT_HELP = "repeathelp"; private static final String P_SYNC_RTM_TOKEN = "rtmtoken"; + private static final String P_SYNC_RTM_LAST_SYNC = "rtmlastsync"; // default values private static final boolean DEFAULT_PERSISTENCE_MODE = true; @@ -132,6 +135,14 @@ public class Preferences { editor.commit(); } + /** RTM Last Successful Sync Date, or null */ + public static Date getSyncRTMLastSync(Context context) { + Long value = getPrefs(context).getLong(P_SYNC_RTM_LAST_SYNC, 0); + if(value == 0) + return null; + return new Date(value); + } + /** Should sync with RTM? */ public static boolean shouldSyncRTM(Context context) { Resources r = context.getResources();