diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..480e0a05c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "ical4android"] + path = ical4android + url = git@gitlab.com:tasks.org/ical4android.git +[submodule "dav4android"] + path = dav4android + url = git@gitlab.com:tasks.org/dav4android.git diff --git a/app/build.gradle b/app/build.gradle index 506da9ca5..94b978d00 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,17 +4,6 @@ task wrapper(type: Wrapper) { gradleVersion = '4.1' } -buildscript { - repositories { - jcenter() - google() - } - - dependencies { - classpath 'com.android.tools.build:gradle:3.0.1' - } -} - repositories { jcenter() google() @@ -107,6 +96,9 @@ final ROOM_VERSION = '1.0.0' final TESTING_SUPPORT_VERSION = '1.0.0' dependencies { + compile project(':ical4android') + compile project(':dav4android') + annotationProcessor "com.google.dagger:dagger-compiler:${DAGGER_VERSION}" compile "com.google.dagger:dagger:${DAGGER_VERSION}" @@ -126,6 +118,7 @@ dependencies { debugCompile 'com.squareup.leakcanary:leakcanary-android:1.5.4' debugCompile 'com.android.support:multidex:1.0.2' + compile 'com.squareup.okhttp3:okhttp:3.9.1' compile 'com.google.code.gson:gson:2.8.2' compile 'com.github.rey5137:material:1.2.4' compile 'com.nononsenseapps:filepicker:4.1.0' diff --git a/app/proguard.pro b/app/proguard.pro index 0c99e65cf..855217a94 100644 --- a/app/proguard.pro +++ b/app/proguard.pro @@ -27,3 +27,19 @@ -dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue -dontwarn com.google.errorprone.annotations.concurrent.LazyInit -dontwarn com.google.errorprone.annotations.ForOverride + +# okhttp +-dontwarn okio.** +-dontwarn javax.annotation.Nullable +-dontwarn javax.annotation.ParametersAreNonnullByDefault + +# https://gitlab.com/bitfireAT/davdroid/blob/9fc3921b3293e19bd7be7bfc3f24d799ed2446bc/app/proguard-rules.txt +-dontwarn aQute.** +-dontwarn groovy.** # Groovy-based ContentBuilder not used +-dontwarn javax.cache.** # no JCache support in Android +-dontwarn net.fortuna.ical4j.model.** +-dontwarn org.codehaus.groovy.** +-dontwarn org.apache.log4j.** # ignore warnings from log4j dependency +-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime) +-keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing) +-keep class at.bitfire.** { *; } # all DAVdroid code is required diff --git a/app/src/amazon/java/org/tasks/injection/ApplicationComponent.java b/app/src/amazon/java/org/tasks/injection/ApplicationComponent.java index 543da3e4f..2965a5e3e 100644 --- a/app/src/amazon/java/org/tasks/injection/ApplicationComponent.java +++ b/app/src/amazon/java/org/tasks/injection/ApplicationComponent.java @@ -20,4 +20,6 @@ public interface ApplicationComponent { BroadcastComponent plus(BroadcastModule module); IntentServiceComponent plus(IntentServiceModule module); + + SyncAdapterComponent plus(SyncAdapterModule syncAdapterModule); } diff --git a/app/src/amazon/java/org/tasks/injection/SyncAdapterComponent.java b/app/src/amazon/java/org/tasks/injection/SyncAdapterComponent.java new file mode 100644 index 000000000..af3ca02da --- /dev/null +++ b/app/src/amazon/java/org/tasks/injection/SyncAdapterComponent.java @@ -0,0 +1,10 @@ +package org.tasks.injection; + +import org.tasks.caldav.CalDAVSyncAdapter; + +import dagger.Subcomponent; + +@Subcomponent(modules = SyncAdapterModule.class) +public interface SyncAdapterComponent { + void inject(CalDAVSyncAdapter calDAVSyncAdapter); +} diff --git a/app/src/generic/java/org/tasks/injection/ApplicationComponent.java b/app/src/generic/java/org/tasks/injection/ApplicationComponent.java index 543da3e4f..2965a5e3e 100644 --- a/app/src/generic/java/org/tasks/injection/ApplicationComponent.java +++ b/app/src/generic/java/org/tasks/injection/ApplicationComponent.java @@ -20,4 +20,6 @@ public interface ApplicationComponent { BroadcastComponent plus(BroadcastModule module); IntentServiceComponent plus(IntentServiceModule module); + + SyncAdapterComponent plus(SyncAdapterModule syncAdapterModule); } diff --git a/app/src/generic/java/org/tasks/injection/SyncAdapterComponent.java b/app/src/generic/java/org/tasks/injection/SyncAdapterComponent.java new file mode 100644 index 000000000..af3ca02da --- /dev/null +++ b/app/src/generic/java/org/tasks/injection/SyncAdapterComponent.java @@ -0,0 +1,10 @@ +package org.tasks.injection; + +import org.tasks.caldav.CalDAVSyncAdapter; + +import dagger.Subcomponent; + +@Subcomponent(modules = SyncAdapterModule.class) +public interface SyncAdapterComponent { + void inject(CalDAVSyncAdapter calDAVSyncAdapter); +} diff --git a/app/src/googleplay/java/com/todoroo/astrid/gtasks/GtasksPreferences.java b/app/src/googleplay/java/com/todoroo/astrid/gtasks/GtasksPreferences.java index e1281efc5..aecb15ab8 100644 --- a/app/src/googleplay/java/com/todoroo/astrid/gtasks/GtasksPreferences.java +++ b/app/src/googleplay/java/com/todoroo/astrid/gtasks/GtasksPreferences.java @@ -24,6 +24,7 @@ import org.tasks.injection.ActivityComponent; import org.tasks.injection.InjectingPreferenceActivity; import org.tasks.preferences.ActivityPermissionRequestor; import org.tasks.preferences.PermissionRequestor; +import org.tasks.sync.SyncAdapters; import javax.inject.Inject; @@ -39,6 +40,7 @@ public class GtasksPreferences extends InjectingPreferenceActivity { @Inject GtaskSyncAdapterHelper gtaskSyncAdapterHelper; @Inject PlayServicesAvailability playServicesAvailability; @Inject DialogBuilder dialogBuilder; + @Inject SyncAdapters syncAdapters; @Inject GoogleTaskDao googleTaskDao; @Override @@ -94,12 +96,12 @@ public class GtasksPreferences extends InjectingPreferenceActivity { } @Override - protected void onPostResume() { - super.onPostResume(); + protected void onResume() { + super.onResume(); CheckBoxPreference backgroundSync = (CheckBoxPreference) findPreference(getString(R.string.gtask_background_sync)); backgroundSync.setChecked(gtaskSyncAdapterHelper.isSyncEnabled()); - if (gtaskSyncAdapterHelper.isMasterSyncEnabled()) { + if (syncAdapters.isMasterSyncEnabled()) { backgroundSync.setSummary(null); } else { backgroundSync.setSummary(R.string.master_sync_warning); diff --git a/app/src/googleplay/java/com/todoroo/astrid/gtasks/sync/GtasksSyncService.java b/app/src/googleplay/java/com/todoroo/astrid/gtasks/sync/GtasksSyncService.java index ee28dee5d..98b0cdc84 100644 --- a/app/src/googleplay/java/com/todoroo/astrid/gtasks/sync/GtasksSyncService.java +++ b/app/src/googleplay/java/com/todoroo/astrid/gtasks/sync/GtasksSyncService.java @@ -17,9 +17,9 @@ import com.todoroo.astrid.gtasks.api.GtasksInvoker; import com.todoroo.astrid.gtasks.api.MoveRequest; import org.tasks.analytics.Tracker; +import org.tasks.gtasks.GtaskSyncAdapterHelper; import org.tasks.data.GoogleTask; import org.tasks.data.GoogleTaskDao; -import org.tasks.gtasks.GtaskSyncAdapterHelper; import org.tasks.injection.ApplicationScope; import java.io.IOException; diff --git a/app/src/googleplay/java/org/tasks/gtasks/GoogleTaskSyncAdapter.java b/app/src/googleplay/java/org/tasks/gtasks/GoogleTaskSyncAdapter.java index e6c7d715c..bd2bc560f 100644 --- a/app/src/googleplay/java/org/tasks/gtasks/GoogleTaskSyncAdapter.java +++ b/app/src/googleplay/java/org/tasks/gtasks/GoogleTaskSyncAdapter.java @@ -191,7 +191,7 @@ public class GoogleTaskSyncAdapter extends InjectingAbstractThreadedSyncAdapter } private void pushLocalChanges() throws UserRecoverableAuthIOException { - List tasks = taskDao.getTasksToPush(); + List tasks = taskDao.getGoogleTasksToPush(); for (Task task : tasks) { try { pushTask(task, gtasksInvoker); diff --git a/app/src/googleplay/java/org/tasks/gtasks/GtaskSyncAdapterHelper.java b/app/src/googleplay/java/org/tasks/gtasks/GtaskSyncAdapterHelper.java index f96985e6b..de3e6f272 100644 --- a/app/src/googleplay/java/org/tasks/gtasks/GtaskSyncAdapterHelper.java +++ b/app/src/googleplay/java/org/tasks/gtasks/GtaskSyncAdapterHelper.java @@ -78,10 +78,6 @@ public class GtaskSyncAdapterHelper { getAccount() != null; } - public boolean isMasterSyncEnabled() { - return ContentResolver.getMasterSyncAutomatically(); - } - public void enableSynchronization(boolean enabled) { Account account = getAccount(); if (account != null) { diff --git a/app/src/googleplay/java/org/tasks/injection/SyncAdapterComponent.java b/app/src/googleplay/java/org/tasks/injection/SyncAdapterComponent.java index ad2775991..6336bf2e7 100644 --- a/app/src/googleplay/java/org/tasks/injection/SyncAdapterComponent.java +++ b/app/src/googleplay/java/org/tasks/injection/SyncAdapterComponent.java @@ -1,5 +1,6 @@ package org.tasks.injection; +import org.tasks.caldav.CalDAVSyncAdapter; import org.tasks.gtasks.GoogleTaskSyncAdapter; import dagger.Subcomponent; @@ -7,4 +8,6 @@ import dagger.Subcomponent; @Subcomponent(modules = SyncAdapterModule.class) public interface SyncAdapterComponent { void inject(GoogleTaskSyncAdapter googleTaskSyncAdapter); + + void inject(CalDAVSyncAdapter calDAVSyncAdapter); } diff --git a/app/src/googleplay/java/org/tasks/receivers/GoogleTaskPusher.java b/app/src/googleplay/java/org/tasks/receivers/GoogleTaskPusher.java index 04e6e5962..417219d04 100644 --- a/app/src/googleplay/java/org/tasks/receivers/GoogleTaskPusher.java +++ b/app/src/googleplay/java/org/tasks/receivers/GoogleTaskPusher.java @@ -4,21 +4,21 @@ import com.google.common.base.Strings; import com.todoroo.astrid.data.SyncFlags; import com.todoroo.astrid.data.Task; -import org.tasks.gtasks.GtaskSyncAdapterHelper; +import org.tasks.sync.SyncAdapters; import javax.inject.Inject; public class GoogleTaskPusher { - private final GtaskSyncAdapterHelper gtaskSyncAdapterHelper; + private final SyncAdapters syncAdapters; @Inject - public GoogleTaskPusher(GtaskSyncAdapterHelper gtaskSyncAdapterHelper) { - this.gtaskSyncAdapterHelper = gtaskSyncAdapterHelper; + public GoogleTaskPusher(SyncAdapters syncAdapters) { + this.syncAdapters = syncAdapters; } void push(Task task, Task original) { - if(!gtaskSyncAdapterHelper.isEnabled()) { + if(!syncAdapters.isGoogleTaskSyncEnabled()) { return; } @@ -35,7 +35,7 @@ public class GoogleTaskPusher { !task.getCompletionDate().equals(original.getCompletionDate()) || !task.getDeletionDate().equals(original.getDeletionDate()) || task.checkAndClearTransitory(SyncFlags.FORCE_SYNC)) { - gtaskSyncAdapterHelper.requestSynchronization(); + syncAdapters.requestSynchronization(); } } } diff --git a/app/src/googleplay/java/org/tasks/receivers/PushReceiver.java b/app/src/googleplay/java/org/tasks/receivers/PushReceiver.java index 952ff117b..c1a05e7a5 100644 --- a/app/src/googleplay/java/org/tasks/receivers/PushReceiver.java +++ b/app/src/googleplay/java/org/tasks/receivers/PushReceiver.java @@ -7,13 +7,16 @@ import javax.inject.Inject; public class PushReceiver { private final GoogleTaskPusher googleTaskPusher; + private final CalDAVPushReceiver calDAVPushReceiver; @Inject - public PushReceiver(GoogleTaskPusher googleTaskPusher) { + public PushReceiver(GoogleTaskPusher googleTaskPusher, CalDAVPushReceiver calDAVPushReceiver) { this.googleTaskPusher = googleTaskPusher; + this.calDAVPushReceiver = calDAVPushReceiver; } public void push(Task task, Task original) { googleTaskPusher.push(task, original); + calDAVPushReceiver.push(task, original); } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6385bec43..615f63cda 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -523,6 +523,18 @@ + + + + + + + diff --git a/app/src/main/java/com/todoroo/astrid/core/DefaultsPreferences.java b/app/src/main/java/com/todoroo/astrid/core/DefaultsPreferences.java index 79b6b0756..ed8b710a3 100644 --- a/app/src/main/java/com/todoroo/astrid/core/DefaultsPreferences.java +++ b/app/src/main/java/com/todoroo/astrid/core/DefaultsPreferences.java @@ -44,8 +44,8 @@ public class DefaultsPreferences extends InjectingPreferenceActivity implements @Inject CalendarProvider calendarProvider; @Inject ActivityPermissionRequestor permissionRequester; @Inject Tracker tracker; - @Inject SyncAdapters syncAdapters; @Inject DefaultFilterProvider defaultFilterProvider; + @Inject SyncAdapters syncAdapters; private Preference defaultCalendarPref; diff --git a/app/src/main/java/com/todoroo/astrid/dao/TaskDao.java b/app/src/main/java/com/todoroo/astrid/dao/TaskDao.java index d9f74688b..f4516330b 100644 --- a/app/src/main/java/com/todoroo/astrid/dao/TaskDao.java +++ b/app/src/main/java/com/todoroo/astrid/dao/TaskDao.java @@ -97,7 +97,13 @@ public abstract class TaskDao { "LEFT JOIN google_tasks ON tasks._id = google_tasks.task " + "WHERE tasks.modified > google_tasks.last_sync " + "OR google_tasks.remote_id = ''") - public abstract List getTasksToPush(); + public abstract List getGoogleTasksToPush(); + + @android.arch.persistence.room.Query("SELECT tasks.* FROM tasks " + + "LEFT JOIN caldav_tasks ON tasks._id = caldav_tasks.task " + + "WHERE caldav_tasks.account = :uid " + + "AND tasks.modified > caldav_tasks.last_sync") + public abstract List getCaldavTasksToPush(String uid); @android.arch.persistence.room.Query("SELECT * FROM TASKS " + "WHERE completed = 0 AND deleted = 0 AND (notificationFlags > 0 OR notifications > 0)") diff --git a/app/src/main/java/com/todoroo/astrid/data/Task.java b/app/src/main/java/com/todoroo/astrid/data/Task.java index 8b0ad1498..9ffbb0a6f 100644 --- a/app/src/main/java/com/todoroo/astrid/data/Task.java +++ b/app/src/main/java/com/todoroo/astrid/data/Task.java @@ -623,6 +623,10 @@ public class Task implements Parcelable { setDueDate(newDueDate); } + public boolean isRecurring() { + return !Strings.isNullOrEmpty(recurrence); + } + public String getRecurrence() { return recurrence; } @@ -739,6 +743,10 @@ public class Task implements Parcelable { modified = modificationDate; } + public Long getModificationDate() { + return modified; + } + public Integer getReminderFlags() { return notificationFlags; } 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 7d58346cd..36415476f 100644 --- a/app/src/main/java/com/todoroo/astrid/service/TaskDeleter.java +++ b/app/src/main/java/com/todoroo/astrid/service/TaskDeleter.java @@ -6,6 +6,7 @@ import com.todoroo.astrid.data.Task; import org.tasks.calendars.CalendarEventProvider; import org.tasks.data.AlarmDao; +import org.tasks.data.CaldavDao; import org.tasks.data.GoogleTaskDao; import org.tasks.data.LocationDao; import org.tasks.data.TagDao; @@ -28,17 +29,19 @@ public class TaskDeleter { private final LocationDao locationDao; private final TagDao tagDao; private final GoogleTaskDao googleTaskDao; + private final CaldavDao caldavDao; @Inject public TaskDeleter(TaskDao taskDao, CalendarEventProvider calendarEventProvider, AlarmDao alarmDao, LocationDao locationDao, TagDao tagDao, - GoogleTaskDao googleTaskDao) { + GoogleTaskDao googleTaskDao, CaldavDao caldavDao) { this.taskDao = taskDao; this.calendarEventProvider = calendarEventProvider; this.alarmDao = alarmDao; this.locationDao = locationDao; this.tagDao = tagDao; this.googleTaskDao = googleTaskDao; + this.caldavDao = caldavDao; } public int purgeDeleted() { @@ -51,6 +54,7 @@ public class TaskDeleter { locationDao.deleteByTaskId(id); tagDao.deleteByTaskId(id); googleTaskDao.deleteByTaskId(id); + caldavDao.deleteById(id); } return deleted.size(); } diff --git a/app/src/main/java/org/tasks/caldav/CalDAVSyncAdapter.java b/app/src/main/java/org/tasks/caldav/CalDAVSyncAdapter.java new file mode 100644 index 000000000..ea650ed6b --- /dev/null +++ b/app/src/main/java/org/tasks/caldav/CalDAVSyncAdapter.java @@ -0,0 +1,285 @@ +package org.tasks.caldav; + +import android.accounts.Account; +import android.content.ContentProviderClient; +import android.content.Context; +import android.content.SyncResult; +import android.os.Bundle; + +import com.google.common.base.Strings; +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 org.apache.commons.codec.Charsets; +import org.tasks.AccountManager; +import org.tasks.LocalBroadcastManager; +import org.tasks.data.CaldavAccount; +import org.tasks.data.CaldavDao; +import org.tasks.data.CaldavTask; +import org.tasks.injection.InjectingAbstractThreadedSyncAdapter; +import org.tasks.injection.SyncAdapterComponent; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.List; + +import javax.inject.Inject; + +import at.bitfire.dav4android.BasicDigestAuthHandler; +import at.bitfire.dav4android.DavCalendar; +import at.bitfire.dav4android.DavResource; +import at.bitfire.dav4android.exception.DavException; +import at.bitfire.dav4android.exception.HttpException; +import at.bitfire.dav4android.property.GetCTag; +import at.bitfire.dav4android.property.GetETag; +import at.bitfire.ical4android.CalendarStorageException; +import at.bitfire.ical4android.InvalidCalendarException; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; +import okhttp3.ResponseBody; +import timber.log.Timber; + +import static com.google.common.base.Strings.isNullOrEmpty; +import static org.tasks.time.DateTimeUtils.currentTimeMillis; + +public class CalDAVSyncAdapter extends InjectingAbstractThreadedSyncAdapter { + + @Inject AccountManager accountManager; + @Inject CaldavDao caldavDao; + @Inject CaldavAccountManager caldavAccountManager; + @Inject TaskDao taskDao; + @Inject LocalBroadcastManager localBroadcastManager; + @Inject TaskCreator taskCreator; + + CalDAVSyncAdapter(Context context, boolean autoInitialize) { + super(context, autoInitialize); + } + + @Override + public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { + String accountName = account.name; + String uuid = caldavAccountManager.getUuid(account); + Timber.d("onPerformSync: %s [%s]", accountName, uuid); + + if (Strings.isNullOrEmpty(uuid)) { + caldavAccountManager.removeAccount(account); + return; + } + + // required for dav4android (ServiceLoader) + Thread.currentThread().setContextClassLoader(getContext().getClassLoader()); + + CaldavAccount caldavAccount = caldavDao.getAccount(uuid); + if (caldavAccount == null) { + caldavAccount = new CaldavAccount(accountName, uuid); + caldavAccount.setId(caldavDao.insert(caldavAccount)); + localBroadcastManager.broadcastRefreshList(); + } + org.tasks.caldav.Account localAccount = caldavAccountManager.getAccount(caldavAccount.getUuid()); + if (isNullOrEmpty(localAccount.getPassword())) { + syncResult.stats.numAuthExceptions++; + return; + } + syncResult.stats.numAuthExceptions = 0; + BasicDigestAuthHandler basicDigestAuthHandler = new BasicDigestAuthHandler(null, caldavAccount.getUsername(), localAccount.getPassword()); + OkHttpClient httpClient = new OkHttpClient().newBuilder() + .addNetworkInterceptor(basicDigestAuthHandler) + .authenticator(basicDigestAuthHandler) + .cookieJar(new MemoryCookieStore()) + .followRedirects(false) + .followSslRedirects(false) + .build(); + URI uri = URI.create(caldavAccount.getUrl()); + HttpUrl httpUrl = HttpUrl.get(uri); + DavCalendar davCalendar = new DavCalendar(httpClient, httpUrl); + try { + pushLocalChanges(caldavAccount, httpClient, httpUrl); + + davCalendar.propfind(0, GetCTag.NAME); + + String remoteCtag = davCalendar.getProperties().get(GetCTag.class).getCTag(); + String localCtag = caldavAccount.getCtag(); + + if (localCtag != null && localCtag.equals(remoteCtag)) { + Timber.d("%s up to date", caldavAccount.getName()); + return; + } + + // fetch etags + + // check for deleted tasks + + // multiget updated tasks + + davCalendar.calendarQuery("VTODO", null, null); + + // fetch and apply remote changes + for (DavResource vCard : davCalendar.getMembers()) { + ResponseBody responseBody = vCard.get("text/calendar"); + GetETag eTag = (GetETag) vCard.getProperties().get(GetETag.NAME); + if (eTag == null || isNullOrEmpty(eTag.getETag())) { + throw new DavException("Received CalDAV GET response without ETag for " + vCard.getLocation()); + } + MediaType contentType = responseBody.contentType(); + Charset charset = contentType == null ? Charsets.UTF_8 : contentType.charset(Charsets.UTF_8); + InputStream stream = responseBody.byteStream(); + try { + processVTodo(vCard.fileName(), caldavAccount, eTag.getETag(), stream, charset); + } finally { + if (stream != null) { + stream.close(); + } + } + } + + caldavAccount.setCtag(remoteCtag); + caldavDao.update(caldavAccount); + } catch (IOException | HttpException | DavException | CalendarStorageException e) { + Timber.e(e.getMessage(), e); + } + + localBroadcastManager.broadcastRefresh(); + } + + private void pushLocalChanges(CaldavAccount caldavAccount, OkHttpClient httpClient, HttpUrl httpUrl) { + List tasks = taskDao.getCaldavTasksToPush(caldavAccount.getUuid()); + for (com.todoroo.astrid.data.Task task : tasks) { + try { + pushTask(task, caldavAccount, httpClient, httpUrl); + } catch (IOException e) { + Timber.e(e, e.getMessage()); + } + } + } + + private boolean deleteRemoteResource(OkHttpClient httpClient, HttpUrl httpUrl, CaldavTask caldavTask) { + try { + if (!Strings.isNullOrEmpty(caldavTask.getRemoteId())) { + DavResource remote = new DavResource(httpClient, httpUrl.newBuilder().addPathSegment(caldavTask.getRemoteId() + ".ics").build()); + remote.delete(null); + } + } catch (HttpException e) { + if (e.getStatus() != 404) { + Timber.e(e, e.getMessage()); + return false; + } + } catch (IOException e) { + Timber.e(e.getMessage(), e); + return false; + } + caldavDao.delete(caldavTask); + return true; + } + + private void pushTask(Task task, CaldavAccount caldavAccount, OkHttpClient httpClient, HttpUrl httpUrl) throws IOException { + Timber.d("pushing %s", task); + List deleted = getDeleted(task.getId(), caldavAccount); + if (!deleted.isEmpty()) { + for (CaldavTask entry : deleted) { + deleteRemoteResource(httpClient, httpUrl, entry); + } + return; + } + + CaldavTask caldavMetadata = caldavDao.getTask(task.getId()); + + if (caldavMetadata == null) { + return; + } + + if (task.isDeleted()) { + if (deleteRemoteResource(httpClient, httpUrl, caldavMetadata)) { + caldavDao.delete(caldavMetadata); + } + return; + } + + at.bitfire.ical4android.Task remoteModel = TaskConverter.toCaldav(task); + + if (Strings.isNullOrEmpty(caldavMetadata.getRemoteId())) { + String caldavUid = UUIDHelper.newUUID(); + caldavMetadata.setRemoteId(caldavUid); + remoteModel.setUid(caldavUid); + } else { + remoteModel.setUid(caldavMetadata.getRemoteId()); + } + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + remoteModel.write(os); + RequestBody requestBody = RequestBody.create( + DavCalendar.MIME_ICALENDAR, + os.toByteArray()); + try { + DavResource remote = new DavResource(httpClient, httpUrl.newBuilder().addPathSegment(caldavMetadata.getRemoteId() + ".ics").build()); + remote.put(requestBody, null, false); + } catch (HttpException e) { + Timber.e(e.getMessage(), e); + return; + } + + long modified = currentTimeMillis(); + task.setModificationDate(modified); + task.putTransitory(SyncFlags.GTASKS_SUPPRESS_SYNC, true); + taskDao.save(task); + caldavMetadata.setLastSync(modified); + caldavDao.update(caldavMetadata); + } + + private List getDeleted(long taskId, CaldavAccount caldavAccount) { + return caldavDao.getDeleted(taskId, caldavAccount.getUuid()); + } + + private void processVTodo(String fileName, CaldavAccount caldavAccount, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException { + List tasks; + try { + InputStreamReader inputStreamReader = new InputStreamReader(stream, charset); + tasks = at.bitfire.ical4android.Task.fromReader(inputStreamReader); + inputStreamReader.close(); + } catch (InvalidCalendarException e) { + Timber.e(e.getMessage(), e); + return; + } + + if (tasks.size() == 1) { + at.bitfire.ical4android.Task remote = tasks.get(0); + Task task; + CaldavTask caldavTask = caldavDao.getTask(caldavAccount.getUuid(), remote.getUid()); + if (caldavTask == null) { + task = taskCreator.createWithValues(null, ""); + taskDao.createNew(task); + caldavTask = new CaldavTask(task.getId(), caldavAccount.getUuid()); + caldavTask.setRemoteId(remote.getUid()); + Timber.d("NEW %s", remote); + } else { + task = taskDao.fetch(caldavTask.getTask()); + Timber.d("UPDATE %s", remote); + } + TaskConverter.apply(task, remote); + task.putTransitory(SyncFlags.GTASKS_SUPPRESS_SYNC, true); + taskDao.save(task); + caldavTask.setLastSync(DateUtilities.now() + 1000L); + if (caldavTask.getId() == Task.NO_ID) { + caldavDao.insert(caldavTask); + } else { + caldavDao.update(caldavTask); + } + } else { + Timber.e("Received VCALENDAR with %s VTODOs; ignoring %s", tasks.size(), fileName); + } + } + + @Override + protected void inject(SyncAdapterComponent component) { + component.inject(this); + } +} diff --git a/app/src/main/java/org/tasks/caldav/CalDAVSyncService.java b/app/src/main/java/org/tasks/caldav/CalDAVSyncService.java new file mode 100644 index 000000000..ab606abfb --- /dev/null +++ b/app/src/main/java/org/tasks/caldav/CalDAVSyncService.java @@ -0,0 +1,34 @@ +package org.tasks.caldav; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import timber.log.Timber; + +public class CalDAVSyncService extends Service { + private static final Object lock = new Object(); + private static CalDAVSyncAdapter syncAdapter = null; + + @Override + public void onCreate() { + super.onCreate(); + Timber.d("Service created"); + synchronized (lock) { + if (syncAdapter == null) { + syncAdapter = new CalDAVSyncAdapter(getApplicationContext(), true); + } + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + Timber.d("Service destroyed"); + } + + @Override + public IBinder onBind(Intent intent) { + return syncAdapter.getSyncAdapterBinder(); + } +} diff --git a/app/src/main/java/org/tasks/caldav/MemoryCookieStore.java b/app/src/main/java/org/tasks/caldav/MemoryCookieStore.java new file mode 100644 index 000000000..e9a79ff06 --- /dev/null +++ b/app/src/main/java/org/tasks/caldav/MemoryCookieStore.java @@ -0,0 +1,69 @@ +/* + * Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package org.tasks.caldav; + +import org.apache.commons.collections4.MapIterator; +import org.apache.commons.collections4.keyvalue.MultiKey; +import org.apache.commons.collections4.map.HashedMap; +import org.apache.commons.collections4.map.MultiKeyMap; + +import java.util.LinkedList; +import java.util.List; + +import okhttp3.Cookie; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; + +/** + * Primitive cookie store that stores cookies in a (volatile) hash map. + * Will be sufficient for session cookies. + */ +public class MemoryCookieStore implements CookieJar { + + /** + * Stored cookies. The multi-key consists of three parts: name, domain, and path. + * This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model] + * Not thread-safe! + */ + protected final MultiKeyMap storage = MultiKeyMap.multiKeyMap(new HashedMap, Cookie>()); + + @Override + public void saveFromResponse(HttpUrl url, List cookies) { + synchronized(storage) { + for (Cookie cookie : cookies) + storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie); + } + } + + @Override + public List loadForRequest(HttpUrl url) { + List cookies = new LinkedList<>(); + + synchronized(storage) { + MapIterator, Cookie> iter = storage.mapIterator(); + while (iter.hasNext()) { + iter.next(); + Cookie cookie = iter.getValue(); + + // remove expired cookies + if (cookie.expiresAt() <= System.currentTimeMillis()) { + iter.remove(); + continue; + } + + // add applicable cookies + if (cookie.matches(url)) + cookies.add(cookie); + } + } + + return cookies; + } + +} diff --git a/app/src/main/java/org/tasks/caldav/TaskConverter.java b/app/src/main/java/org/tasks/caldav/TaskConverter.java new file mode 100644 index 000000000..5c24aaff7 --- /dev/null +++ b/app/src/main/java/org/tasks/caldav/TaskConverter.java @@ -0,0 +1,113 @@ +package org.tasks.caldav; + +import com.todoroo.astrid.data.Task; + +import net.fortuna.ical4j.model.Date; +import net.fortuna.ical4j.model.DateTime; +import net.fortuna.ical4j.model.property.Completed; +import net.fortuna.ical4j.model.property.Due; +import net.fortuna.ical4j.model.property.RRule; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Locale; + +import timber.log.Timber; + +import static com.todoroo.astrid.data.Task.DUE_DATE; +import static com.todoroo.astrid.data.Task.URGENCY_SPECIFIC_DAY; +import static com.todoroo.astrid.data.Task.URGENCY_SPECIFIC_DAY_TIME; +import static org.tasks.date.DateTimeUtils.newDateTime; + +public class TaskConverter { + + private static DateFormat DUE_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd", Locale.US); + + public static void apply(Task local, at.bitfire.ical4android.Task remote) { + if (remote.getCompletedAt() != null) { + local.setCompletionDate(remote.getCompletedAt().getDate().getTime()); + } + local.setTitle(remote.getSummary()); + local.setNotes(remote.getDescription()); + local.setImportance(fromRemote(remote.getPriority())); + RRule repeatRule = remote.getRRule(); + if (repeatRule != null) { + local.setRecurrence("RRULE:" + repeatRule.getValue() + (local.repeatAfterCompletion() ? ";FROM=COMPLETION" : "")); + } + Due due = remote.getDue(); + if (due != null) { + Date dueDate = due.getDate(); + if (dueDate instanceof DateTime) { + local.setDueDate(Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, dueDate.getTime())); + } else { + try { + local.setDueDate(Task.createDueDate(URGENCY_SPECIFIC_DAY, DUE_DATE_FORMAT.parse(due.getValue()).getTime())); + } catch (ParseException e) { + Timber.e(e, e.getMessage()); + } + } + } + } + + static int fromRemote(int remotePriority) { + switch (remotePriority) { + case 0: + return Task.IMPORTANCE_NONE; + case 1: + return Task.IMPORTANCE_DO_OR_DIE; + case 2: + return Task.IMPORTANCE_MUST_DO; + default: + return Task.IMPORTANCE_SHOULD_DO; + } + } + + static int toRemote(int tasksPriority) { + switch (tasksPriority) { + case Task.IMPORTANCE_DO_OR_DIE: + return 1; + case Task.IMPORTANCE_MUST_DO: + return 2; + case Task.IMPORTANCE_SHOULD_DO: + return 3; + default: + return 0; + } + } + + public static at.bitfire.ical4android.Task toCaldav(Task task) { + at.bitfire.ical4android.Task remote = new at.bitfire.ical4android.Task(); + remote.setSummary(task.getTitle()); + remote.setDescription(task.getNotes()); + if (task.hasDueDate()) { + if (task.hasDueTime()) { + remote.setDue(new Due(new DateTime(task.getDueDate()))); + } else { + try { + remote.setDue(new Due(newDateTime(task.getDueDate()).toString("yyyyMMdd"))); + } catch (ParseException e) { + Timber.e(e, e.getMessage()); + } + } + remote.setDue(new Due(task.hasDueTime() + ? new DateTime(task.getDueDate()) + : new Date(new org.tasks.time.DateTime(task.getDueDate()).toUTC().getMillis()))); + } + if (task.isCompleted()) { + remote.setCompletedAt(new Completed(new DateTime(task.getCompletionDate()))); + } + if (task.isRecurring()) { + try { + String rrule = task + .getRecurrenceWithoutFrom() + .replace("RRULE:", ""); + remote.setRRule(new RRule(rrule)); + } catch (ParseException e) { + Timber.e(e, e.getMessage()); + } + } + remote.setPriority(toRemote(task.getImportance())); + return remote; + } +} diff --git a/app/src/main/java/org/tasks/data/CaldavDao.java b/app/src/main/java/org/tasks/data/CaldavDao.java index 462d62d4b..88c3a8927 100644 --- a/app/src/main/java/org/tasks/data/CaldavDao.java +++ b/app/src/main/java/org/tasks/data/CaldavDao.java @@ -41,8 +41,8 @@ public interface CaldavDao { @Query("SELECT * FROM caldav_tasks WHERE task = :taskId AND deleted = 0 LIMIT 1") CaldavTask getTask(long taskId); - @Query("SELECT * FROM caldav_tasks WHERE remote_id = :remoteId LIMIT 1") - CaldavTask getTask(String remoteId); + @Query("SELECT * FROM caldav_tasks WHERE account = :account AND remote_id = :remoteId LIMIT 1") + CaldavTask getTask(String account, String remoteId); @Query("DELETE FROM caldav_tasks WHERE task = :taskId") void deleteById(long taskId); diff --git a/app/src/main/java/org/tasks/data/CaldavTask.java b/app/src/main/java/org/tasks/data/CaldavTask.java index 2fc58248c..cb36f9ce0 100644 --- a/app/src/main/java/org/tasks/data/CaldavTask.java +++ b/app/src/main/java/org/tasks/data/CaldavTask.java @@ -102,4 +102,17 @@ public class CaldavTask { public void setAccount(String account) { this.account = account; } + + @Override + public String toString() { + return "CaldavTask{" + + "id=" + id + + ", task=" + task + + ", account='" + account + '\'' + + ", remoteId='" + remoteId + '\'' + + ", etag='" + etag + '\'' + + ", lastSync=" + lastSync + + ", deleted=" + deleted + + '}'; + } } diff --git a/app/src/googleplay/java/org/tasks/injection/InjectingAbstractThreadedSyncAdapter.java b/app/src/main/java/org/tasks/injection/InjectingAbstractThreadedSyncAdapter.java similarity index 100% rename from app/src/googleplay/java/org/tasks/injection/InjectingAbstractThreadedSyncAdapter.java rename to app/src/main/java/org/tasks/injection/InjectingAbstractThreadedSyncAdapter.java diff --git a/app/src/googleplay/java/org/tasks/injection/SyncAdapterModule.java b/app/src/main/java/org/tasks/injection/SyncAdapterModule.java similarity index 100% rename from app/src/googleplay/java/org/tasks/injection/SyncAdapterModule.java rename to app/src/main/java/org/tasks/injection/SyncAdapterModule.java diff --git a/app/src/main/java/org/tasks/preferences/Preferences.java b/app/src/main/java/org/tasks/preferences/Preferences.java index d248aeefe..036943362 100644 --- a/app/src/main/java/org/tasks/preferences/Preferences.java +++ b/app/src/main/java/org/tasks/preferences/Preferences.java @@ -151,6 +151,10 @@ public class Preferences { return getStringValue(R.string.gcal_p_default); } + public String getDefaultRemoteList() { + return getStringValue(R.string.p_default_remote_list); + } + public int getFirstDayOfWeek() { int firstDayOfWeek = getIntegerFromString(R.string.p_start_of_week, 0); return firstDayOfWeek < 1 || firstDayOfWeek > 7 ? 0 : firstDayOfWeek; diff --git a/app/src/main/java/org/tasks/receivers/CalDAVPushReceiver.java b/app/src/main/java/org/tasks/receivers/CalDAVPushReceiver.java new file mode 100644 index 000000000..1eb5cb08a --- /dev/null +++ b/app/src/main/java/org/tasks/receivers/CalDAVPushReceiver.java @@ -0,0 +1,25 @@ +package org.tasks.receivers; + +import com.todoroo.astrid.data.SyncFlags; +import com.todoroo.astrid.data.Task; + +import org.tasks.caldav.CaldavAccountManager; + +import javax.inject.Inject; + +public class CalDAVPushReceiver { + + private final CaldavAccountManager caldavAccountManager; + + @Inject + public CalDAVPushReceiver(CaldavAccountManager caldavAccountManager) { + this.caldavAccountManager = caldavAccountManager; + } + + public void push(Task task, Task original) { + if(task.checkTransitory(SyncFlags.GTASKS_SUPPRESS_SYNC)) { + return; + } + caldavAccountManager.requestSynchronization(); + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..fd44952a5 --- /dev/null +++ b/build.gradle @@ -0,0 +1,10 @@ +buildscript { + repositories { + jcenter() + google() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.0.1' + } +} \ No newline at end of file diff --git a/dav4android b/dav4android new file mode 160000 index 000000000..e00c8fa36 --- /dev/null +++ b/dav4android @@ -0,0 +1 @@ +Subproject commit e00c8fa36990a1a6184fb6c8a7ab6229b3117d42 diff --git a/ical4android b/ical4android new file mode 160000 index 000000000..4c1ff076c --- /dev/null +++ b/ical4android @@ -0,0 +1 @@ +Subproject commit 4c1ff076c12b577e7d8daaf35bcc6f09b80c5554 diff --git a/settings.gradle b/settings.gradle index 4c90568ab..f5f47dbc9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -include 'app' +include 'app', 'ical4android', 'dav4android'