diff --git a/app/src/main/java/com/todoroo/astrid/service/TaskDeleter.java b/app/src/main/java/com/todoroo/astrid/service/TaskDeleter.java index b7eebf695..3447b819d 100644 --- a/app/src/main/java/com/todoroo/astrid/service/TaskDeleter.java +++ b/app/src/main/java/com/todoroo/astrid/service/TaskDeleter.java @@ -71,7 +71,11 @@ public class TaskDeleter { } public void delete(Task task) { - delete(ImmutableList.of(task.getId())); + delete(task.getId()); + } + + public void delete(Long task) { + delete(ImmutableList.of(task)); } public void delete(List tasks) { diff --git a/app/src/main/java/org/tasks/caldav/CaldavConverter.java b/app/src/main/java/org/tasks/caldav/CaldavConverter.java index f231bdcb9..3a4eee4e2 100644 --- a/app/src/main/java/org/tasks/caldav/CaldavConverter.java +++ b/app/src/main/java/org/tasks/caldav/CaldavConverter.java @@ -104,7 +104,7 @@ public class CaldavConverter { return remotePriority > 5 ? Math.min(9, remotePriority) : 9; } - static at.bitfire.ical4android.Task toCaldav(CaldavTask caldavTask, Task task) { + public static at.bitfire.ical4android.Task toCaldav(CaldavTask caldavTask, Task task) { at.bitfire.ical4android.Task remote = null; try { if (!Strings.isNullOrEmpty(caldavTask.getVtodo())) { diff --git a/app/src/main/java/org/tasks/data/CaldavDao.java b/app/src/main/java/org/tasks/data/CaldavDao.java index 720e096f4..1044430a3 100644 --- a/app/src/main/java/org/tasks/data/CaldavDao.java +++ b/app/src/main/java/org/tasks/data/CaldavDao.java @@ -84,6 +84,9 @@ public abstract class CaldavDao { @Query("SELECT * FROM caldav_tasks WHERE cd_calendar = :calendar AND cd_object = :object LIMIT 1") public abstract CaldavTask getTask(String calendar, String object); + @Query("SELECT * FROM caldav_tasks WHERE cd_calendar = :calendar AND cd_remote_id = :remoteId") + public abstract CaldavTask getTaskByRemoteId(String calendar, String remoteId); + @Query("SELECT * FROM caldav_tasks WHERE cd_task = :taskId") public abstract List getTasks(long taskId); @@ -96,6 +99,13 @@ public abstract class CaldavDao { + "WHERE cd_deleted = 0 AND cd_vtodo IS NOT NULL AND cd_vtodo != ''") public abstract List getTasks(); + @Query( + "SELECT task.*, caldav_task.* FROM tasks AS task " + + "INNER JOIN caldav_tasks AS caldav_task ON _id = cd_task " + + "WHERE cd_calendar = :calendar " + + "AND modified > cd_last_sync") + public abstract List getCaldavTasksToPush(String calendar); + @Query("SELECT * FROM caldav_lists ORDER BY cdl_name COLLATE NOCASE") public abstract List getCalendars(); diff --git a/app/src/main/java/org/tasks/data/CaldavTaskContainer.java b/app/src/main/java/org/tasks/data/CaldavTaskContainer.java index 344d81d0a..f580a0e99 100644 --- a/app/src/main/java/org/tasks/data/CaldavTaskContainer.java +++ b/app/src/main/java/org/tasks/data/CaldavTaskContainer.java @@ -1,13 +1,30 @@ package org.tasks.data; import androidx.room.Embedded; +import com.google.common.base.Strings; import com.todoroo.astrid.data.Task; public class CaldavTaskContainer { @Embedded public Task task; @Embedded public CaldavTask caldavTask; + public Task getTask() { + return task; + } + public CaldavTask getCaldavTask() { return caldavTask; } + + public String getRemoteId() { + return caldavTask.getRemoteId(); + } + + public boolean isDeleted() { + return task.isDeleted(); + } + + public boolean isNew() { + return Strings.isNullOrEmpty(caldavTask.getVtodo()); + } } diff --git a/app/src/main/java/org/tasks/etesync/EteSyncClient.java b/app/src/main/java/org/tasks/etesync/EteSyncClient.java index 5c47957a3..c9e9c7184 100644 --- a/app/src/main/java/org/tasks/etesync/EteSyncClient.java +++ b/app/src/main/java/org/tasks/etesync/EteSyncClient.java @@ -1,5 +1,7 @@ package org.tasks.etesync; +import static com.google.common.collect.Lists.transform; + import android.content.Context; import androidx.annotation.Nullable; import androidx.core.util.Pair; @@ -8,19 +10,24 @@ import at.bitfire.cert4android.CustomCertManager.CustomHostnameVerifier; import com.etesync.journalmanager.Crypto; import com.etesync.journalmanager.Crypto.CryptoManager; import com.etesync.journalmanager.Exceptions; +import com.etesync.journalmanager.Exceptions.HttpException; import com.etesync.journalmanager.Exceptions.IntegrityException; import com.etesync.journalmanager.Exceptions.VersionTooNewException; import com.etesync.journalmanager.JournalAuthenticator; +import com.etesync.journalmanager.JournalEntryManager; +import com.etesync.journalmanager.JournalEntryManager.Entry; import com.etesync.journalmanager.JournalManager; import com.etesync.journalmanager.JournalManager.Journal; import com.etesync.journalmanager.UserInfoManager; import com.etesync.journalmanager.UserInfoManager.UserInfo; import com.etesync.journalmanager.model.CollectionInfo; +import com.etesync.journalmanager.model.SyncEntry; import com.etesync.journalmanager.util.TokenAuthenticator; import java.io.IOException; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import javax.inject.Inject; @@ -32,6 +39,7 @@ import okhttp3.OkHttpClient.Builder; import okhttp3.internal.tls.OkHostnameVerifier; import org.tasks.DebugNetworkInterceptor; import org.tasks.caldav.MemoryCookieStore; +import org.tasks.data.CaldavAccount; import org.tasks.injection.ForApplication; import org.tasks.preferences.Preferences; import org.tasks.security.Encryption; @@ -39,6 +47,8 @@ import timber.log.Timber; public class EteSyncClient { + private static final int MAX_FETCH = 50; + private final Encryption encryption; private final Preferences preferences; private final DebugNetworkInterceptor interceptor; @@ -110,6 +120,15 @@ public class EteSyncClient { journalManager = new JournalManager(httpClient, httpUrl); } + public EteSyncClient forAccount(CaldavAccount account) + throws NoSuchAlgorithmException, KeyManagementException { + return forUrl( + account.getUrl(), + account.getUsername(), + account.getEncryptionPassword(encryption), + account.getPassword(encryption)); + } + public EteSyncClient forUrl(String url, String username, String encryptionPassword, String token) throws KeyManagementException, NoSuchAlgorithmException { return new EteSyncClient( @@ -136,7 +155,7 @@ public class EteSyncClient { return Pair.create(token, key); } - public CryptoManager getCrypto(Journal journal) + CryptoManager getCrypto(Journal journal) throws VersionTooNewException, IntegrityException { return new CryptoManager(journal.getVersion(), encryptionPassword, journal.getUid()); } @@ -166,6 +185,19 @@ public class EteSyncClient { return result; } + List> getSyncEntries(Journal journal, @Nullable String ctag) + throws IntegrityException, Exceptions.HttpException, VersionTooNewException { + JournalEntryManager journalEntryManager = + new JournalEntryManager(httpClient, httpUrl, journal.getUid()); + CryptoManager crypto = getCrypto(journal); + List journalEntries = journalEntryManager.list(crypto, ctag, MAX_FETCH); + return transform(journalEntries, e -> Pair.create(e, SyncEntry.fromJournalEntry(crypto, e))); + } + + void pushEntries(Journal journal, List entries, String ctag) throws HttpException { + new JournalEntryManager(httpClient, httpUrl, journal.getUid()).create(entries, ctag); + } + public EteSyncClient setForeground() { foreground = true; return this; diff --git a/app/src/main/java/org/tasks/etesync/EteSynchronizer.java b/app/src/main/java/org/tasks/etesync/EteSynchronizer.java new file mode 100644 index 000000000..a446a3f06 --- /dev/null +++ b/app/src/main/java/org/tasks/etesync/EteSynchronizer.java @@ -0,0 +1,306 @@ +package org.tasks.etesync; + +import static com.google.common.base.Strings.isNullOrEmpty; +import static com.google.common.collect.FluentIterable.from; +import static com.google.common.collect.Lists.newArrayList; +import static com.google.common.collect.Lists.transform; +import static com.google.common.collect.Maps.newHashMap; +import static com.google.common.collect.Sets.newHashSet; +import static java.util.Collections.emptySet; +import static org.tasks.caldav.CaldavUtils.getParent; + +import android.content.Context; +import androidx.core.util.Pair; +import at.bitfire.ical4android.ICalendar; +import com.etesync.journalmanager.Crypto.CryptoManager; +import com.etesync.journalmanager.Exceptions; +import com.etesync.journalmanager.Exceptions.HttpException; +import com.etesync.journalmanager.Exceptions.IntegrityException; +import com.etesync.journalmanager.Exceptions.VersionTooNewException; +import com.etesync.journalmanager.JournalEntryManager; +import com.etesync.journalmanager.JournalEntryManager.Entry; +import com.etesync.journalmanager.JournalManager.Journal; +import com.etesync.journalmanager.model.CollectionInfo; +import com.etesync.journalmanager.model.SyncEntry; +import com.etesync.journalmanager.model.SyncEntry.Actions; +import com.google.common.base.Strings; +import com.google.common.collect.Iterables; +import com.todoroo.andlib.utility.DateUtilities; +import com.todoroo.astrid.dao.TaskDao; +import com.todoroo.astrid.data.SyncFlags; +import com.todoroo.astrid.data.Task; +import com.todoroo.astrid.helper.UUIDHelper; +import com.todoroo.astrid.service.TaskCreator; +import com.todoroo.astrid.service.TaskDeleter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.inject.Inject; +import net.fortuna.ical4j.model.property.ProdId; +import org.tasks.BuildConfig; +import org.tasks.LocalBroadcastManager; +import org.tasks.R; +import org.tasks.billing.Inventory; +import org.tasks.caldav.CaldavConverter; +import org.tasks.caldav.CaldavUtils; +import org.tasks.data.CaldavAccount; +import org.tasks.data.CaldavCalendar; +import org.tasks.data.CaldavDao; +import org.tasks.data.CaldavTask; +import org.tasks.data.CaldavTaskContainer; +import org.tasks.data.TagDao; +import org.tasks.data.TagData; +import org.tasks.data.TagDataDao; +import org.tasks.injection.ForApplication; +import timber.log.Timber; + +public class EteSynchronizer { + + static { + ICalendar.Companion.setProdId( + new ProdId("+//IDN tasks.org//android-" + BuildConfig.VERSION_CODE + "//EN")); + } + + private final CaldavDao caldavDao; + private final TaskDao taskDao; + private final TagDataDao tagDataDao; + private final TagDao tagDao; + private final LocalBroadcastManager localBroadcastManager; + private final TaskCreator taskCreator; + private final TaskDeleter taskDeleter; + private final Inventory inventory; + private final EteSyncClient client; + private final Context context; + + @Inject + public EteSynchronizer( + @ForApplication Context context, + CaldavDao caldavDao, + TaskDao taskDao, + TagDataDao tagDataDao, + TagDao tagDao, + LocalBroadcastManager localBroadcastManager, + TaskCreator taskCreator, + TaskDeleter taskDeleter, + Inventory inventory, + EteSyncClient client) { + this.context = context; + this.caldavDao = caldavDao; + this.taskDao = taskDao; + this.tagDataDao = tagDataDao; + this.tagDao = tagDao; + this.localBroadcastManager = localBroadcastManager; + this.taskCreator = taskCreator; + this.taskDeleter = taskDeleter; + this.inventory = inventory; + this.client = client; + } + + public void sync(CaldavAccount account) { + if (!inventory.hasPro()) { + setError(account, context.getString(R.string.requires_pro_subscription)); + return; + } + if (isNullOrEmpty(account.getPassword())) { + setError(account, context.getString(R.string.password_required)); + return; + } + if (isNullOrEmpty(account.getEncryptionKey())) { + setError(account, context.getString(R.string.encryption_password_required)); + return; + } + try { + synchronize(account); + } catch (KeyManagementException + | NoSuchAlgorithmException + | HttpException + | IntegrityException + | IOException + | VersionTooNewException e) { + setError(account, e.getMessage()); + } + } + + private void synchronize(CaldavAccount account) + throws KeyManagementException, NoSuchAlgorithmException, Exceptions.HttpException, + IntegrityException, IOException, VersionTooNewException { + EteSyncClient client = this.client.forAccount(account); + Map resources = client.getCalendars(); + + Set uids = newHashSet(Iterables.transform(resources.values(), CollectionInfo::getUid)); + Timber.d("Found uids: %s", uids); + for (CaldavCalendar calendar : + caldavDao.findDeletedCalendars(account.getUuid(), newArrayList(uids))) { + taskDeleter.delete(calendar); + } + + for (Map.Entry entry : resources.entrySet()) { + CollectionInfo collection = entry.getValue(); + String uid = collection.getUid(); + + CaldavCalendar calendar = caldavDao.getCalendarByUrl(account.getUuid(), uid); + if (calendar == null) { + calendar = new CaldavCalendar(); + calendar.setName(collection.getDisplayName()); + calendar.setAccount(account.getUuid()); + calendar.setUrl(collection.getUid()); + calendar.setUuid(UUIDHelper.newUUID()); + caldavDao.insert(calendar); + } else { + // TODO: update db and broadcast list refresh if name changed + } + sync(client, calendar, entry.getKey()); + } + setError(account, ""); + } + + private void setError(CaldavAccount account, String message) { + account.setError(message); + caldavDao.update(account); + localBroadcastManager.broadcastRefreshList(); + if (!Strings.isNullOrEmpty(message)) { + Timber.e(message); + } + } + + private void sync(EteSyncClient client, CaldavCalendar caldavCalendar, Journal journal) + throws IntegrityException, Exceptions.HttpException, IOException, VersionTooNewException { + Timber.d("sync(%s)", caldavCalendar); + + Map localChanges = newHashMap(); + for (CaldavTaskContainer task : caldavDao.getCaldavTasksToPush(caldavCalendar.getUuid())) { + localChanges.put(task.getRemoteId(), task); + } + + List> syncEntries = + client.getSyncEntries(journal, caldavCalendar.getCtag()); + + applyEntries(caldavCalendar, syncEntries, localChanges.keySet()); + + List changes = new ArrayList<>(); + for (CaldavTask task : caldavDao.getDeleted(caldavCalendar.getUuid())) { + changes.add(new SyncEntry(task.getVtodo(), Actions.DELETE)); + } + for (CaldavTaskContainer task : localChanges.values()) { + changes.add(new SyncEntry(getVtodo(task), task.isNew() ? Actions.ADD : Actions.DELETE)); + } + + String remoteCtag = caldavCalendar.getCtag(); + CryptoManager crypto = client.getCrypto(journal); + List> updates = new ArrayList<>(); + JournalEntryManager.Entry previous = + Strings.isNullOrEmpty(remoteCtag) ? null : Entry.getFakeWithUid(remoteCtag); + + for (SyncEntry syncEntry : changes) { + Entry entry = new Entry(); + entry.update(crypto, syncEntry.toJson(), previous); + updates.add(Pair.create(entry, syncEntry)); + previous = entry; + } + + client.pushEntries(journal, from(updates).transform(p -> p.first).toList(), remoteCtag); + + applyEntries(caldavCalendar, updates, emptySet()); + + Timber.d("UPDATE %s", caldavCalendar); + + caldavDao.update(caldavCalendar); + caldavDao.updateParents(caldavCalendar.getUuid()); + localBroadcastManager.broadcastRefresh(); + } + + private void applyEntries( + CaldavCalendar caldavCalendar, List> syncEntries, Set dirty) { + for (Pair entry : syncEntries) { + Entry journalEntry = entry.first; + SyncEntry syncEntry = entry.second; + Actions action = syncEntry.getAction(); + String vtodo = syncEntry.getContent(); + Timber.v("%s: %s", action, vtodo); + at.bitfire.ical4android.Task task = CaldavUtils.fromVtodo(vtodo); + String remoteId = task.getUid(); + CaldavTask caldavTask = caldavDao.getTaskByRemoteId(caldavCalendar.getUuid(), remoteId); + switch (action) { + case ADD: + case CHANGE: + if (dirty.contains(remoteId)) { + caldavTask.setVtodo(vtodo); + caldavDao.update(caldavTask); + } else { + processVTodo(caldavCalendar, caldavTask, task, vtodo); + } + break; + case DELETE: + if (caldavTask != null) { + taskDeleter.delete(caldavTask.getTask()); + } + break; + } + caldavCalendar.setCtag(journalEntry.getUid()); + caldavDao.update(caldavCalendar); + } + } + + private JournalEntryManager.Entry getSyncEntry(Entry previous, CaldavTaskContainer task) { + return null; + } + + private String getVtodo(CaldavTaskContainer container) throws IOException { + Task task = container.getTask(); + CaldavTask caldavTask = container.getCaldavTask(); + + at.bitfire.ical4android.Task remoteModel = CaldavConverter.toCaldav(caldavTask, task); + LinkedList categories = remoteModel.getCategories(); + categories.clear(); + categories.addAll(transform(tagDataDao.getTagDataForTask(task.getId()), TagData::getName)); + if (Strings.isNullOrEmpty(caldavTask.getRemoteId())) { + String caldavUid = UUIDHelper.newUUID(); + caldavTask.setRemoteId(caldavUid); + remoteModel.setUid(caldavUid); + } else { + remoteModel.setUid(caldavTask.getRemoteId()); + } + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + remoteModel.write(os); + return new String(os.toByteArray()); + } + + private void processVTodo( + CaldavCalendar calendar, + CaldavTask caldavTask, + at.bitfire.ical4android.Task remote, + String vtodo) { + Task task; + if (caldavTask == null) { + task = taskCreator.createWithValues(""); + taskDao.createNew(task); + caldavTask = new CaldavTask(task.getId(), calendar.getUuid(), remote.getUid(), null); + } else { + task = taskDao.fetch(caldavTask.getTask()); + } + + CaldavConverter.apply(task, remote); + tagDao.applyTags(task, tagDataDao, CaldavUtils.getTags(tagDataDao, remote.getCategories())); + task.putTransitory(SyncFlags.GTASKS_SUPPRESS_SYNC, true); + task.putTransitory(TaskDao.TRANS_SUPPRESS_REFRESH, true); + taskDao.save(task); + caldavTask.setVtodo(vtodo); + caldavTask.setLastSync(DateUtilities.now() + 1000L); + caldavTask.setRemoteParent(getParent(remote)); + + if (caldavTask.getId() == Task.NO_ID) { + caldavTask.setId(caldavDao.insert(caldavTask)); + Timber.d("NEW %s", caldavTask); + } else { + caldavDao.update(caldavTask); + Timber.d("UPDATE %s", caldavTask); + } + } +} diff --git a/app/src/main/java/org/tasks/jobs/SyncWork.java b/app/src/main/java/org/tasks/jobs/SyncWork.java index 11b135348..1365f6e21 100644 --- a/app/src/main/java/org/tasks/jobs/SyncWork.java +++ b/app/src/main/java/org/tasks/jobs/SyncWork.java @@ -17,6 +17,7 @@ import org.tasks.data.CaldavAccount; import org.tasks.data.CaldavDao; import org.tasks.data.GoogleTaskAccount; import org.tasks.data.GoogleTaskListDao; +import org.tasks.etesync.EteSynchronizer; import org.tasks.gtasks.GoogleTaskSynchronizer; import org.tasks.injection.InjectingWorker; import org.tasks.injection.JobComponent; @@ -28,6 +29,7 @@ public class SyncWork extends InjectingWorker { private static final Object LOCK = new Object(); @Inject CaldavSynchronizer caldavSynchronizer; + @Inject EteSynchronizer eteSynchronizer; @Inject GoogleTaskSynchronizer googleTaskSynchronizer; @Inject LocalBroadcastManager localBroadcastManager; @Inject Preferences preferences; @@ -69,7 +71,14 @@ public class SyncWork extends InjectingWorker { ExecutorService executor = newFixedThreadPool(numThreads); for (CaldavAccount account : caldavDao.getAccounts()) { - executor.execute(() -> caldavSynchronizer.sync(account)); + executor.execute( + () -> { + if (account.isCaldavAccount()) { + caldavSynchronizer.sync(account); + } else if (account.isEteSyncAccount()) { + eteSynchronizer.sync(account); + } + }); } List accounts = googleTaskListDao.getAccounts(); for (int i = 0; i < accounts.size(); i++) {