mirror of https://github.com/tasks/tasks
Add CalDAV sync adapter
parent
bb0f99f35a
commit
7eb76be346
@ -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
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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<Task> 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<CaldavTask> 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<CaldavTask> 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<at.bitfire.ical4android.Task> 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);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
jcenter()
|
||||
google()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.0.1'
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
Subproject commit e00c8fa36990a1a6184fb6c8a7ab6229b3117d42
|
@ -0,0 +1 @@
|
||||
Subproject commit 4c1ff076c12b577e7d8daaf35bcc6f09b80c5554
|
@ -1 +1 @@
|
||||
include 'app'
|
||||
include 'app', 'ical4android', 'dav4android'
|
||||
|
Loading…
Reference in New Issue