From 0c819c842bb809047421deb2e4c69c5c1a8cb0a8 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 14 Oct 2015 18:19:59 +0200 Subject: [PATCH] Basic implementation of calendar sync. with common SyncManager --- .gitignore | 4 +- .gitmodules | 3 + app/build.gradle | 2 + .../davdroid/resource/DavResourceFinder.java | 2 +- .../davdroid/resource/LocalAddressBook.java | 11 +- .../davdroid/resource/LocalCalendar.java | 134 ++ .../davdroid/resource/LocalCollection.java | 27 + .../davdroid/resource/LocalContact.java | 12 +- .../bitfire/davdroid/resource/LocalEvent.java | 119 ++ .../davdroid/resource/LocalResource.java | 26 + .../bitfire/davdroid/resource/ServerInfo.java | 13 +- .../syncadapter/CalendarSyncManager.java | 213 +++ .../CalendarsSyncAdapterService.java | 18 +- .../ContactsSyncAdapterService.java | 6 +- .../syncadapter/ContactsSyncManager.java | 207 +-- .../davdroid/syncadapter/SyncManager.java | 255 ++- .../ui/setup/AccountDetailsFragment.java | 16 +- .../org/dmfs/provider/tasks/TaskContract.java | 1519 ----------------- .../org/dmfs/provider/tasks/UriFactory.java | 56 - dav4android | 2 +- ical4android | 1 + settings.gradle | 2 + vcard4android | 2 +- 23 files changed, 841 insertions(+), 1809 deletions(-) create mode 100644 app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java create mode 100644 app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java create mode 100644 app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.java create mode 100644 app/src/main/java/at/bitfire/davdroid/resource/LocalResource.java create mode 100644 app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java delete mode 100644 app/src/main/java/org/dmfs/provider/tasks/TaskContract.java delete mode 100644 app/src/main/java/org/dmfs/provider/tasks/UriFactory.java create mode 160000 ical4android diff --git a/.gitignore b/.gitignore index 40788539..e2e1ee9c 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,8 @@ build/ # Ignore Gradle GUI config gradle-app.setting - ### external libs ### .svn + +# Javadoc +javadoc/ diff --git a/.gitmodules b/.gitmodules index 5f4c0aa6..e6d6dae2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "MemorizingTrustManager"] path = MemorizingTrustManager url = https://github.com/ge0rg/MemorizingTrustManager +[submodule "ical4android"] + path = ical4android + url = git@gitlab.com:bitfireAT/ical4android.git diff --git a/app/build.gradle b/app/build.gradle index 9132d7c8..fa6a60b1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -50,11 +50,13 @@ configurations.all { } dependencies { + compile 'com.google.guava:guava:18.0' compile 'dnsjava:dnsjava:2.1.7' provided 'org.projectlombok:lombok:1.16.6' compile('org.slf4j:slf4j-android:1.7.12') compile project(':dav4android') + compile project(':ical4android') compile project(':vcard4android') compile project(':MemorizingTrustManager') diff --git a/app/src/main/java/at/bitfire/davdroid/resource/DavResourceFinder.java b/app/src/main/java/at/bitfire/davdroid/resource/DavResourceFinder.java index d0537b4a..9845f197 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/DavResourceFinder.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/DavResourceFinder.java @@ -144,7 +144,7 @@ public class DavResourceFinder { member.location.toString(), displayName != null ? displayName.displayName : null, description != null ? description.description : null, - color != null ? DavUtils.CalDAVtoARGBColor(color.color) : null + color != null ? color.color : null ); CalendarTimezone tz = (CalendarTimezone)member.properties.get(CalendarTimezone.NAME); diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java index 0d80de83..e0956c2b 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java @@ -24,7 +24,7 @@ import lombok.Cleanup; import lombok.Synchronized; -public class LocalAddressBook extends AndroidAddressBook { +public class LocalAddressBook extends AndroidAddressBook implements LocalCollection { protected static final String SYNC_STATE_CTAG = "ctag"; @@ -39,14 +39,15 @@ public class LocalAddressBook extends AndroidAddressBook { /** * Returns an array of local contacts, excluding those which have been modified locally (and not uploaded yet). */ + @Override public LocalContact[] getAll() throws ContactsStorageException { - LocalContact contacts[] = (LocalContact[])queryContacts(null, null); - return contacts; + return (LocalContact[])queryContacts(null, null); } /** * Returns an array of local contacts which have been deleted locally. (DELETED != 0). */ + @Override public LocalContact[] getDeleted() throws ContactsStorageException { return (LocalContact[])queryContacts(ContactsContract.RawContacts.DELETED + "!=0", null); } @@ -54,6 +55,7 @@ public class LocalAddressBook extends AndroidAddressBook { /** * Returns an array of local contacts which have been changed locally (DIRTY != 0). */ + @Override public LocalContact[] getDirty() throws ContactsStorageException { return (LocalContact[])queryContacts(ContactsContract.RawContacts.DIRTY + "!=0", null); } @@ -61,6 +63,7 @@ public class LocalAddressBook extends AndroidAddressBook { /** * Returns an array of local contacts which don't have a file name yet. */ + @Override public LocalContact[] getWithoutFileName() throws ContactsStorageException { return (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null); } @@ -77,6 +80,7 @@ public class LocalAddressBook extends AndroidAddressBook { syncState.clear(); } + @Override public String getCTag() throws ContactsStorageException { synchronized (syncState) { readSyncState(); @@ -84,6 +88,7 @@ public class LocalAddressBook extends AndroidAddressBook { } } + @Override public void setCTag(String cTag) throws ContactsStorageException { synchronized (syncState) { readSyncState(); diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java new file mode 100644 index 00000000..3401d9d9 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java @@ -0,0 +1,134 @@ +/* + * Copyright © 2013 – 2015 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 at.bitfire.davdroid.resource; + +import android.accounts.Account; +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.CalendarContract; +import android.provider.CalendarContract.Calendars; +import android.provider.CalendarContract.Events; +import android.provider.CalendarContract.Reminders; + +import com.google.common.base.Joiner; + +import at.bitfire.ical4android.AndroidCalendar; +import at.bitfire.ical4android.AndroidCalendarFactory; +import at.bitfire.ical4android.CalendarStorageException; +import at.bitfire.vcard4android.ContactsStorageException; +import lombok.Cleanup; + +public class LocalCalendar extends AndroidCalendar implements LocalCollection { + + public static final int defaultColor = 0xFFC3EA6E; // "DAVdroid green" + + public static final String COLUMN_CTAG = Calendars.CAL_SYNC1; + + static String[] BASE_INFO_COLUMNS = new String[] { + Events._ID, + LocalEvent.COLUMN_FILENAME, + LocalEvent.COLUMN_ETAG + }; + + @Override + protected String[] eventBaseInfoColumns() { + return BASE_INFO_COLUMNS; + } + + + protected LocalCalendar(Account account, ContentProviderClient provider, long id) { + super(account, provider, LocalEvent.Factory.INSTANCE, id); + } + + public static Uri create(Account account, ContentResolver resolver, ServerInfo.ResourceInfo info) throws CalendarStorageException { + @Cleanup("release") ContentProviderClient provider = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY); + if (provider == null) + throw new CalendarStorageException("Couldn't acquire ContentProviderClient for " + CalendarContract.AUTHORITY); + + ContentValues values = new ContentValues(); + values.put(Calendars.NAME, info.getURL()); + values.put(Calendars.CALENDAR_DISPLAY_NAME, info.getTitle()); + values.put(Calendars.CALENDAR_COLOR, info.color != null ? info.color : defaultColor); + values.put(Calendars.CALENDAR_ACCESS_LEVEL, info.readOnly ? Calendars.CAL_ACCESS_READ : Calendars.CAL_ACCESS_OWNER); + values.put(Calendars.OWNER_ACCOUNT, account.name); + values.put(Calendars.SYNC_EVENTS, 1); + if (info.timezone != null) { + // TODO parse VTIMEZONE + // values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(info.timezone)); + } + values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT); + values.put(Calendars.ALLOWED_AVAILABILITY, Joiner.on(",").join(Reminders.AVAILABILITY_TENTATIVE, Reminders.AVAILABILITY_FREE, Reminders.AVAILABILITY_BUSY)); + values.put(Calendars.ALLOWED_ATTENDEE_TYPES, Joiner.on(",").join(CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE)); + return create(account, provider, values); + } + + + @Override + public LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException { + return (LocalEvent[])queryEvents(null, null); + } + + @Override + public LocalEvent[] getDeleted() throws CalendarStorageException { + return (LocalEvent[])queryEvents(Events.DELETED + "!=0", null); + } + + @Override + public LocalEvent[] getWithoutFileName() throws CalendarStorageException { + return (LocalEvent[])queryEvents(LocalEvent.COLUMN_FILENAME + " IS NULL", null); + } + + @Override + public LocalResource[] getDirty() throws CalendarStorageException { + return (LocalEvent[])queryEvents(Events.DIRTY + "!=0", null); + } + + + @Override + public String getCTag() throws CalendarStorageException { + try { + @Cleanup Cursor cursor = provider.query(calendarSyncURI(), new String[] { COLUMN_CTAG }, null, null, null); + if (cursor != null && cursor.moveToNext()) + return cursor.getString(0); + } catch (RemoteException e) { + throw new CalendarStorageException("Couldn't read local (last known) CTag", e); + } + return null; + } + + @Override + public void setCTag(String cTag) throws CalendarStorageException, ContactsStorageException { + try { + ContentValues values = new ContentValues(1); + values.put(COLUMN_CTAG, cTag); + provider.update(calendarSyncURI(), values, null, null); + } catch (RemoteException e) { + throw new CalendarStorageException("Couldn't write local (last known) CTag", e); + } + } + + + public static class Factory implements AndroidCalendarFactory { + public static final Factory INSTANCE = new Factory(); + + @Override + public AndroidCalendar newInstance(Account account, ContentProviderClient provider, long id) { + return new LocalCalendar(account, provider, id); + } + + @Override + public AndroidCalendar[] newArray(int size) { + return new LocalCalendar[size]; + } + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java new file mode 100644 index 00000000..37c7de40 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2013 – 2015 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 at.bitfire.davdroid.resource; + +import android.provider.ContactsContract; + +import at.bitfire.ical4android.CalendarStorageException; +import at.bitfire.vcard4android.ContactsStorageException; + +public interface LocalCollection { + + LocalResource[] getDeleted() throws CalendarStorageException, ContactsStorageException; + LocalResource[] getWithoutFileName() throws CalendarStorageException, ContactsStorageException; + LocalResource[] getDirty() throws CalendarStorageException, ContactsStorageException; + + LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException; + + String getCTag() throws CalendarStorageException, ContactsStorageException; + void setCTag(String cTag) throws CalendarStorageException, ContactsStorageException; + +} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java index d8262fee..437f3484 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java @@ -20,7 +20,7 @@ import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.ContactsStorageException; import ezvcard.Ezvcard; -public class LocalContact extends AndroidContact { +public class LocalContact extends AndroidContact implements LocalResource { static { Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION; } @@ -39,6 +39,8 @@ public class LocalContact extends AndroidContact { values.put(ContactsContract.RawContacts.DIRTY, 0); values.put(COLUMN_ETAG, eTag); addressBook.provider.update(rawContactSyncURI(), values, null, null); + + this.eTag = eTag; } catch (RemoteException e) { throw new ContactsStorageException("Couldn't clear dirty flag", e); } @@ -46,10 +48,14 @@ public class LocalContact extends AndroidContact { public void updateFileNameAndUID(String uid) throws ContactsStorageException { try { - ContentValues values = new ContentValues(1); - values.put(COLUMN_FILENAME, uid + ".vcf"); + String newFileName = uid + ".vcf"; + + ContentValues values = new ContentValues(2); + values.put(COLUMN_FILENAME, newFileName); values.put(COLUMN_UID, uid); addressBook.provider.update(rawContactSyncURI(), values, null, null); + + fileName = newFileName; } catch (RemoteException e) { throw new ContactsStorageException("Couldn't update UID", e); } diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.java new file mode 100644 index 00000000..7f75cb8e --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.java @@ -0,0 +1,119 @@ +/* + * Copyright © 2013 – 2015 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 at.bitfire.davdroid.resource; + +import android.content.ContentProviderOperation; +import android.content.ContentValues; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.CalendarContract; + +import at.bitfire.ical4android.AndroidCalendar; +import at.bitfire.ical4android.AndroidEvent; +import at.bitfire.ical4android.AndroidEventFactory; +import at.bitfire.ical4android.CalendarStorageException; +import at.bitfire.ical4android.Event; +import lombok.Getter; +import lombok.Setter; + +public class LocalEvent extends AndroidEvent implements LocalResource { + + static final String COLUMN_FILENAME = CalendarContract.Events.SYNC_DATA1, + COLUMN_ETAG = CalendarContract.Events.SYNC_DATA2, + COLUMN_UID = CalendarContract.Events.UID_2445; + + @Getter protected String fileName; + @Getter @Setter protected String eTag; + + public LocalEvent(AndroidCalendar calendar, Event event, String fileName, String eTag) { + super(calendar, event); + this.fileName = fileName; + this.eTag = eTag; + } + + protected LocalEvent(AndroidCalendar calendar, long id, ContentValues baseInfo) { + super(calendar, id, baseInfo); + fileName = baseInfo.getAsString(COLUMN_FILENAME); + eTag = baseInfo.getAsString(COLUMN_ETAG); + } + + + /* process LocalEvent-specific fields */ + + @Override + protected void populateEvent(ContentValues values) { + super.populateEvent(values); + fileName = values.getAsString(COLUMN_FILENAME); + eTag = values.getAsString(COLUMN_ETAG); + event.uid = values.getAsString(COLUMN_UID); + } + + @Override + protected void buildEvent(Event recurrence, ContentProviderOperation.Builder builder) { + super.buildEvent(recurrence, builder); + builder .withValue(COLUMN_FILENAME, fileName) + .withValue(COLUMN_ETAG, eTag) + .withValue(COLUMN_UID, event.uid); + } + + + /* custom queries */ + + public void updateFileNameAndUID(String uid) throws CalendarStorageException { + try { + String newFileName = uid + ".ics"; + + ContentValues values = new ContentValues(2); + values.put(COLUMN_FILENAME, newFileName); + values.put(COLUMN_UID, uid); + calendar.provider.update(eventSyncURI(), values, null, null); + + fileName = newFileName; + if (event != null) + event.uid = uid; + + } catch (RemoteException e) { + throw new CalendarStorageException("Couldn't update UID", e); + } + } + + @Override + public void clearDirty(String eTag) throws CalendarStorageException { + try { + ContentValues values = new ContentValues(2); + values.put(CalendarContract.Events.DIRTY, 0); + values.put(COLUMN_ETAG, eTag); + calendar.provider.update(eventSyncURI(), values, null, null); + + this.eTag = eTag; + } catch (RemoteException e) { + throw new CalendarStorageException("Couldn't update UID", e); + } + } + + + static class Factory implements AndroidEventFactory { + static final Factory INSTANCE = new Factory(); + + @Override + public AndroidEvent newInstance(AndroidCalendar calendar, long id, ContentValues baseInfo) { + return new LocalEvent(calendar, id, baseInfo); + } + + @Override + public AndroidEvent newInstance(AndroidCalendar calendar, Event event) { + return new LocalEvent(calendar, event, null, null); + } + + @Override + public AndroidEvent[] newArray(int size) { + return new LocalEvent[size]; + } + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.java new file mode 100644 index 00000000..bb18503c --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2013 – 2015 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 at.bitfire.davdroid.resource; + +import at.bitfire.ical4android.CalendarStorageException; +import at.bitfire.vcard4android.ContactsStorageException; + +public interface LocalResource { + + Long getId(); + + String getFileName(); + String getETag(); + + int delete() throws CalendarStorageException, ContactsStorageException; + + void updateFileNameAndUID(String uuid) throws CalendarStorageException, ContactsStorageException; + void clearDirty(String eTag) throws CalendarStorageException, ContactsStorageException; + +} diff --git a/app/src/main/java/at/bitfire/davdroid/resource/ServerInfo.java b/app/src/main/java/at/bitfire/davdroid/resource/ServerInfo.java index 667ce9ff..d90a74f8 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/ServerInfo.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/ServerInfo.java @@ -7,6 +7,8 @@ */ package at.bitfire.davdroid.resource; +import com.squareup.okhttp.HttpUrl; + import java.io.Serializable; import java.net.MalformedURLException; import java.net.URI; @@ -57,6 +59,7 @@ public class ServerInfo implements Serializable { description; final Integer color; + /** full VTIMEZONE definition (not the TZ ID) */ String timezone; @@ -79,13 +82,9 @@ public class ServerInfo implements Serializable { public String getTitle() { if (title == null) { - try { - java.net.URL url = new java.net.URL(URL); - return url.getPath(); - } catch (MalformedURLException e) { - return URL; - } - } else + HttpUrl url = HttpUrl.parse(URL); + return url != null ? url.toString() : "–"; + } else return title; } } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java new file mode 100644 index 00000000..edc65656 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java @@ -0,0 +1,213 @@ +/* + * Copyright © 2013 – 2015 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 at.bitfire.davdroid.syncadapter; + +import android.accounts.Account; +import android.content.ContentProviderClient; +import android.content.ContentValues; +import android.content.Context; +import android.content.SyncResult; +import android.os.Bundle; +import android.provider.CalendarContract.Calendars; +import android.text.TextUtils; + +import com.google.common.base.Charsets; +import com.google.common.base.Joiner; +import com.squareup.okhttp.HttpUrl; +import com.squareup.okhttp.MediaType; +import com.squareup.okhttp.RequestBody; +import com.squareup.okhttp.ResponseBody; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; + +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.AddressData; +import at.bitfire.dav4android.property.CalendarColor; +import at.bitfire.dav4android.property.CalendarData; +import at.bitfire.dav4android.property.DisplayName; +import at.bitfire.dav4android.property.GetCTag; +import at.bitfire.dav4android.property.GetContentType; +import at.bitfire.dav4android.property.GetETag; +import at.bitfire.davdroid.ArrayUtils; +import at.bitfire.davdroid.Constants; +import at.bitfire.davdroid.resource.LocalCalendar; +import at.bitfire.davdroid.resource.LocalContact; +import at.bitfire.davdroid.resource.LocalEvent; +import at.bitfire.davdroid.resource.LocalResource; +import at.bitfire.ical4android.AndroidHostInfo; +import at.bitfire.ical4android.CalendarStorageException; +import at.bitfire.ical4android.Event; +import at.bitfire.ical4android.InvalidCalendarException; +import at.bitfire.vcard4android.Contact; +import at.bitfire.vcard4android.ContactsStorageException; +import lombok.Cleanup; + +public class CalendarSyncManager extends SyncManager { + + protected static final int + MAX_MULTIGET = 30, + NOTIFICATION_ID = 2; + + protected AndroidHostInfo hostInfo; + + + public CalendarSyncManager(Context context, Account account, Bundle extras, ContentProviderClient provider, SyncResult result, LocalCalendar calendar) { + super(NOTIFICATION_ID, context, account, extras, provider, result); + localCollection = calendar; + } + + + @Override + protected void prepare() { + Thread.currentThread().setContextClassLoader(context.getClassLoader()); + + hostInfo = new AndroidHostInfo(context.getContentResolver()); + + collectionURL = HttpUrl.parse(localCalendar().getName()); + davCollection = new DavCalendar(httpClient, collectionURL); + } + + @Override + protected void queryCapabilities() throws DavException, IOException, HttpException, CalendarStorageException { + davCollection.propfind(0, DisplayName.NAME, CalendarColor.NAME, GetCTag.NAME); + + // update name and color + DisplayName pDisplayName = (DisplayName)davCollection.properties.get(DisplayName.NAME); + String displayName = (pDisplayName != null && !TextUtils.isEmpty(pDisplayName.displayName)) ? + pDisplayName.displayName : collectionURL.toString(); + + CalendarColor pColor = (CalendarColor)davCollection.properties.get(CalendarColor.NAME); + int color = (pColor != null && pColor.color != null) ? pColor.color : LocalCalendar.defaultColor; + + ContentValues values = new ContentValues(2); + Constants.log.info("Setting new calendar name \"" + displayName + "\" and color 0x" + Integer.toHexString(color)); + values.put(Calendars.CALENDAR_DISPLAY_NAME, displayName); + values.put(Calendars.CALENDAR_COLOR, color); + localCalendar().update(values); + } + + @Override + protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException { + LocalEvent local = (LocalEvent)resource; + return RequestBody.create( + DavCalendar.MIME_ICALENDAR, + local.getEvent().toStream().toByteArray() + ); + } + + @Override + protected void listRemote() throws IOException, HttpException, DavException { + // fetch list of remote VEVENTs and build hash table to index file name + davCalendar().calendarQuery("VEVENT"); + remoteResources = new HashMap<>(davCollection.members.size()); + for (DavResource vCard : davCollection.members) { + String fileName = vCard.fileName(); + Constants.log.debug("Found remote VEVENT: " + fileName); + remoteResources.put(fileName, vCard); + } + } + + @Override + protected void downloadRemote() throws IOException, HttpException, DavException, CalendarStorageException { + Constants.log.info("Downloading " + toDownload.size() + " events (" + MAX_MULTIGET + " at once)"); + + // download new/updated iCalendars from server + for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) { + Constants.log.info("Downloading " + Joiner.on(" + ").join(bunch)); + + if (bunch.length == 1) { + // only one contact, use GET + DavResource remote = bunch[0]; + + ResponseBody body = remote.get("text/calendar"); + String eTag = ((GetETag)remote.properties.get(GetETag.NAME)).eTag; + + @Cleanup InputStream stream = body.byteStream(); + processVEvent(remote.fileName(), eTag, stream, body.contentType().charset(Charsets.UTF_8)); + + } else { + // multiple contacts, use multi-get + List urls = new LinkedList<>(); + for (DavResource remote : bunch) + urls.add(remote.location); + davCalendar().multiget(urls.toArray(new HttpUrl[urls.size()])); + + // process multiget results + for (DavResource remote : davCollection.members) { + String eTag; + GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME); + if (getETag != null) + eTag = getETag.eTag; + else + throw new DavException("Received multi-get response without ETag"); + + Charset charset = Charsets.UTF_8; + GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME); + if (getContentType != null && getContentType.type != null) { + MediaType type = MediaType.parse(getContentType.type); + if (type != null) + charset = type.charset(Charsets.UTF_8); + } + + CalendarData calendarData = (CalendarData)remote.properties.get(CalendarData.NAME); + if (calendarData == null || calendarData.iCalendar == null) + throw new DavException("Received multi-get response without address data"); + + @Cleanup InputStream stream = new ByteArrayInputStream(calendarData.iCalendar.getBytes()); + processVEvent(remote.fileName(), eTag, stream, charset); + } + } + } + } + + + // helpers + + private LocalCalendar localCalendar() { return ((LocalCalendar)localCollection); } + private DavCalendar davCalendar() { return (DavCalendar)davCollection; } + + private void processVEvent(String fileName, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException { + Event[] events; + try { + events = Event.fromStream(stream, charset, hostInfo); + } catch (InvalidCalendarException e) { + Constants.log.error("Received invalid iCalendar, ignoring"); + return; + } + + if (events.length == 1) { + Event newData = events[0]; + + // delete local event, if it exists + LocalEvent localEvent = (LocalEvent)localResources.get(fileName); + if (localEvent != null) { + Constants.log.info("Updating " + fileName + " in local calendar"); + localEvent.setETag(eTag); + localEvent.update(newData); + syncResult.stats.numUpdates++; + } else { + Constants.log.info("Adding " + fileName + " to local calendar"); + localEvent = new LocalEvent(localCalendar(), newData, fileName, eTag); + localEvent.add(); + syncResult.stats.numInserts++; + } + } else + Constants.log.error("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring " + fileName); + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.java index baaf3d52..32dd8779 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.java @@ -17,6 +17,11 @@ import android.content.SyncResult; import android.os.Bundle; import android.os.IBinder; +import at.bitfire.davdroid.Constants; +import at.bitfire.davdroid.resource.LocalCalendar; +import at.bitfire.davdroid.resource.LocalContact; +import at.bitfire.ical4android.CalendarStorageException; + public class CalendarsSyncAdapterService extends Service { private static SyncAdapter syncAdapter; @@ -38,14 +43,25 @@ public class CalendarsSyncAdapterService extends Service { private static class SyncAdapter extends AbstractThreadedSyncAdapter { - public SyncAdapter(Context context) { super(context, false); } @Override public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { + Constants.log.info("Starting calendar sync (" + authority + ")"); + + try { + for (LocalCalendar calendar : (LocalCalendar[])LocalCalendar.findAll(account, provider, LocalCalendar.Factory.INSTANCE)) { + Constants.log.info("Synchronizing calendar #" + calendar.getId() + ", URL: " + calendar.getName()); + CalendarSyncManager syncManager = new CalendarSyncManager(getContext(), account, extras, provider, syncResult, calendar); + syncManager.performSync(); + } + } catch (CalendarStorageException e) { + Constants.log.error("Couldn't get list of local calendars", e); + } + Constants.log.info("Calendar sync complete"); } } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java index a82904a5..c4774e9d 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java @@ -22,8 +22,6 @@ import at.bitfire.davdroid.Constants; public class ContactsSyncAdapterService extends Service { private static ContactsSyncAdapter syncAdapter; - - @Override public void onCreate() { if (syncAdapter == null) @@ -48,12 +46,12 @@ public class ContactsSyncAdapterService extends Service { @Override public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { - Constants.log.info("Starting contacts sync (" + authority + ")"); + Constants.log.info("Starting address book sync (" + authority + ")"); ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, extras, provider, syncResult); syncManager.performSync(); - Constants.log.info("Sync complete for authority " + authority); + Constants.log.info("Address book sync complete"); } } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java index 02c3e29a..2a827978 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java @@ -10,7 +10,6 @@ package at.bitfire.davdroid.syncadapter; import android.accounts.Account; import android.content.ContentProviderClient; -import android.content.ContentResolver; import android.content.Context; import android.content.SyncResult; import android.os.Bundle; @@ -29,18 +28,15 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; import java.util.HashMap; -import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.UUID; import at.bitfire.dav4android.DavAddressBook; import at.bitfire.dav4android.DavResource; import at.bitfire.dav4android.exception.DavException; import at.bitfire.dav4android.exception.HttpException; -import at.bitfire.dav4android.exception.PreconditionFailedException; import at.bitfire.dav4android.property.AddressData; import at.bitfire.dav4android.property.GetCTag; import at.bitfire.dav4android.property.GetContentType; @@ -51,6 +47,7 @@ import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.HttpClient; import at.bitfire.davdroid.resource.LocalAddressBook; import at.bitfire.davdroid.resource.LocalContact; +import at.bitfire.davdroid.resource.LocalResource; import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.ContactsStorageException; import ezvcard.VCardVersion; @@ -63,17 +60,8 @@ public class ContactsSyncManager extends SyncManager { MAX_MULTIGET = 10, NOTIFICATION_ID = 1; - protected HttpUrl addressBookURL; - protected DavAddressBook davCollection; protected boolean hasVCard4; - protected LocalAddressBook addressBook; - String currentCTag; - - Map localContacts; - Map remoteContacts; - Set toDownload; - public ContactsSyncManager(Context context, Account account, Bundle extras, ContentProviderClient provider, SyncResult result) { super(NOTIFICATION_ID, context, account, extras, provider, result); @@ -82,11 +70,11 @@ public class ContactsSyncManager extends SyncManager { @Override protected void prepare() { - addressBookURL = HttpUrl.parse(settings.getAddressBookURL()); - davCollection = new DavAddressBook(httpClient, addressBookURL); + collectionURL = HttpUrl.parse(settings.getAddressBookURL()); + davCollection = new DavAddressBook(httpClient, collectionURL); // prepare local address book - addressBook = new LocalAddressBook(account, provider); + localCollection = new LocalAddressBook(account, provider); } @Override @@ -103,159 +91,23 @@ public class ContactsSyncManager extends SyncManager { } @Override - protected void processLocallyDeleted() throws ContactsStorageException { - // Remove locally deleted contacts from server (if they have a name, i.e. if they were uploaded before), - // but only if they don't have changed on the server. Then finally remove them from the local address book. - LocalContact[] localList = addressBook.getDeleted(); - for (LocalContact local : localList) { - final String fileName = local.getFileName(); - if (!TextUtils.isEmpty(fileName)) { - Constants.log.info(fileName + " has been deleted locally -> deleting from server"); - try { - new DavResource(httpClient, addressBookURL.newBuilder().addPathSegment(fileName).build()) - .delete(local.eTag); - } catch (IOException | HttpException e) { - Constants.log.warn("Couldn't delete " + fileName + " from server"); - } - } else - Constants.log.info("Removing local contact #" + local.getId() + " which has been deleted locally and was never uploaded"); - local.delete(); - syncResult.stats.numDeletes++; - } - } - - @Override - protected void processLocallyCreated() throws ContactsStorageException { - // assign file names and UIDs to new contacts so that we can use the file name as an index - for (LocalContact local : addressBook.getWithoutFileName()) { - String uuid = UUID.randomUUID().toString(); - Constants.log.info("Found local contact #" + local.getId() + " without file name; assigning name UID/name " + uuid + "[.vcf]"); - local.updateFileNameAndUID(uuid); - } - } - - @Override - protected void uploadDirty() throws ContactsStorageException, IOException, HttpException { - // upload dirty contacts - for (LocalContact local : addressBook.getDirty()) { - final String fileName = local.getFileName(); - - DavResource remote = new DavResource(httpClient, addressBookURL.newBuilder().addPathSegment(fileName).build()); - - RequestBody vCard = RequestBody.create( - hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8, - local.getContact().toStream(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0).toByteArray() - ); - - try { - if (local.eTag == null) { - Constants.log.info("Uploading new contact " + fileName); - remote.put(vCard, null, true); - // TODO handle 30x - } else { - Constants.log.info("Uploading locally modified contact " + fileName); - remote.put(vCard, local.eTag, false); - // TODO handle 30x - } - - } catch (PreconditionFailedException e) { - Constants.log.info("Contact has been modified on the server before upload, ignoring", e); - } - - String eTag = null; - GetETag newETag = (GetETag) remote.properties.get(GetETag.NAME); - if (newETag != null) { - eTag = newETag.eTag; - Constants.log.debug("Received new ETag=" + eTag + " after uploading"); - } else - Constants.log.debug("Didn't receive new ETag after uploading, setting to null"); - - local.clearDirty(eTag); - } - } - - @Override - protected boolean checkSyncState() throws ContactsStorageException { - // check CTag (ignore on manual sync) - currentCTag = null; - GetCTag getCTag = (GetCTag) davCollection.properties.get(GetCTag.NAME); - if (getCTag != null) - currentCTag = getCTag.cTag; - - String localCTag = null; - if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) - Constants.log.info("Manual sync, ignoring CTag"); - else - localCTag = addressBook.getCTag(); - - if (currentCTag != null && currentCTag.equals(localCTag)) { - Constants.log.info("Remote address book didn't change (CTag=" + currentCTag + "), no need to list VCards"); - return false; - } else - return true; + protected RequestBody prepareUpload(LocalResource resource) throws IOException, ContactsStorageException { + LocalContact local = (LocalContact)resource; + return RequestBody.create( + hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8, + local.getContact().toStream(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0).toByteArray() + ); } @Override - protected void listLocal() throws ContactsStorageException { - // fetch list of local contacts and build hash table to index file name - LocalContact[] localList = addressBook.getAll(); - localContacts = new HashMap<>(localList.length); - for (LocalContact contact : localList) { - Constants.log.debug("Found local contact: " + contact.getFileName()); - localContacts.put(contact.getFileName(), contact); - } - } - - @Override - protected void listRemote() throws IOException, HttpException, DavException, ContactsStorageException { + protected void listRemote() throws IOException, HttpException, DavException { // fetch list of remote VCards and build hash table to index file name - Constants.log.info("Listing remote VCards"); - davCollection.queryMemberETags(); - remoteContacts = new HashMap<>(davCollection.members.size()); + davAddressBook().addressbookQuery(); + remoteResources = new HashMap<>(davCollection.members.size()); for (DavResource vCard : davCollection.members) { String fileName = vCard.fileName(); Constants.log.debug("Found remote VCard: " + fileName); - remoteContacts.put(fileName, vCard); - } - } - - @Override - protected void compareEntries() throws IOException, HttpException, DavException, ContactsStorageException { - /* check which contacts - 1. are not present anymore remotely -> delete immediately on local side - 2. updated remotely -> add to downloadNames - 3. added remotely -> add to downloadNames - */ - toDownload = new HashSet<>(); - for (String localName : localContacts.keySet()) { - DavResource remote = remoteContacts.get(localName); - if (remote == null) { - Constants.log.info(localName + " is not on server anymore, deleting"); - localContacts.get(localName).delete(); - syncResult.stats.numDeletes++; - } else { - // contact is still on server, check whether it has been updated remotely - GetETag getETag = (GetETag) remote.properties.get(GetETag.NAME); - if (getETag == null || getETag.eTag == null) - throw new DavException("Server didn't provide ETag"); - String localETag = localContacts.get(localName).eTag, - remoteETag = getETag.eTag; - if (remoteETag.equals(localETag)) - syncResult.stats.numSkippedEntries++; - else { - Constants.log.info(localName + " has been changed on server (current ETag=" + remoteETag + ", last known ETag=" + localETag + ")"); - toDownload.add(remote); - } - - // remote entry has been seen, remove from list - remoteContacts.remove(localName); - } - } - - // add all unseen (= remotely added) remote contacts - if (!remoteContacts.isEmpty()) { - Constants.log.info("New VCards have been found on the server: " + TextUtils.join(", ", remoteContacts.keySet())); - toDownload.addAll(remoteContacts.values()); + remoteResources.put(fileName, vCard); } } @@ -264,7 +116,7 @@ public class ContactsSyncManager extends SyncManager { Constants.log.info("Downloading " + toDownload.size() + " contacts (" + MAX_MULTIGET + " at once)"); // prepare downloader which may be used to download external resource like contact photos - Contact.Downloader downloader = new ResourceDownloader(httpClient, addressBookURL); + Contact.Downloader downloader = new ResourceDownloader(httpClient, collectionURL); // download new/updated VCards from server for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) { @@ -278,14 +130,14 @@ public class ContactsSyncManager extends SyncManager { String eTag = ((GetETag) remote.properties.get(GetETag.NAME)).eTag; @Cleanup InputStream stream = body.byteStream(); - processVCard(syncResult, addressBook, localContacts, remote.fileName(), eTag, stream, body.contentType().charset(Charsets.UTF_8), downloader); + processVCard(remote.fileName(), eTag, stream, body.contentType().charset(Charsets.UTF_8), downloader); } else { // multiple contacts, use multi-get List urls = new LinkedList<>(); for (DavResource remote : bunch) urls.add(remote.location); - davCollection.multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4); + davAddressBook().multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4); // process multiget results for (DavResource remote : davCollection.members) { @@ -309,28 +161,25 @@ public class ContactsSyncManager extends SyncManager { throw new DavException("Received multi-get response without address data"); @Cleanup InputStream stream = new ByteArrayInputStream(addressData.vCard.getBytes()); - processVCard(syncResult, addressBook, localContacts, remote.fileName(), eTag, stream, charset, downloader); + processVCard(remote.fileName(), eTag, stream, charset, downloader); } } } } - @Override - protected void saveSyncState() throws ContactsStorageException { - /* Save sync state (CTag). It doesn't matter if it has changed during the sync process - (for instance, because another client has uploaded changes), because this will simply - cause all remote entries to be listed at the next sync. */ - Constants.log.info("Saving sync state: CTag=" + currentCTag); - addressBook.setCTag(currentCTag); - } - private void processVCard(SyncResult syncResult, LocalAddressBook addressBook, MaplocalContacts, String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException { + // helpers + + private DavAddressBook davAddressBook() { return (DavAddressBook)davCollection; } + private LocalAddressBook localAddressBook() { return (LocalAddressBook)localCollection; } + + private void processVCard(String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException { Contact contacts[] = Contact.fromStream(stream, charset, downloader); if (contacts.length == 1) { Contact newData = contacts[0]; // delete local contact, if it exists - LocalContact localContact = localContacts.get(fileName); + LocalContact localContact = (LocalContact)localResources.get(fileName); if (localContact != null) { Constants.log.info("Updating " + fileName + " in local address book"); localContact.eTag = eTag; @@ -338,7 +187,7 @@ public class ContactsSyncManager extends SyncManager { syncResult.stats.numUpdates++; } else { Constants.log.info("Adding " + fileName + " to local address book"); - localContact = new LocalContact(addressBook, newData, fileName, eTag); + localContact = new LocalContact(localAddressBook(), newData, fileName, eTag); localContact.add(); syncResult.stats.numInserts++; } @@ -347,8 +196,10 @@ public class ContactsSyncManager extends SyncManager { } + // downloader helper class + @RequiredArgsConstructor - static class ResourceDownloader implements Contact.Downloader { + private static class ResourceDownloader implements Contact.Downloader { final HttpClient httpClient; final HttpUrl baseUrl; diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java index 22abccbe..c991fca8 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java @@ -12,20 +12,37 @@ import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.ContentProviderClient; +import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.SyncResult; import android.os.Build; import android.os.Bundle; +import android.text.TextUtils; + +import com.squareup.okhttp.HttpUrl; +import com.squareup.okhttp.RequestBody; import java.io.IOException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import at.bitfire.dav4android.DavResource; import at.bitfire.dav4android.exception.DavException; import at.bitfire.dav4android.exception.HttpException; +import at.bitfire.dav4android.exception.PreconditionFailedException; +import at.bitfire.dav4android.property.GetCTag; +import at.bitfire.dav4android.property.GetETag; import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.HttpClient; import at.bitfire.davdroid.R; +import at.bitfire.davdroid.resource.LocalCollection; +import at.bitfire.davdroid.resource.LocalResource; import at.bitfire.davdroid.ui.DebugInfoActivity; +import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.vcard4android.ContactsStorageException; abstract public class SyncManager { @@ -38,21 +55,40 @@ abstract public class SyncManager { SYNC_PHASE_CHECK_SYNC_STATE = 5, SYNC_PHASE_LIST_LOCAL = 6, SYNC_PHASE_LIST_REMOTE = 7, - SYNC_PHASE_COMPARE_ENTRIES = 8, + SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8, SYNC_PHASE_DOWNLOAD_REMOTE = 9, SYNC_PHASE_SAVE_SYNC_STATE = 10; - final NotificationManager notificationManager; - final int notificationId; + protected final NotificationManager notificationManager; + protected final int notificationId; + + protected final Context context; + protected final Account account; + protected final Bundle extras; + protected final ContentProviderClient provider; + protected final SyncResult syncResult; + + protected final AccountSettings settings; + protected LocalCollection localCollection; + + protected final HttpClient httpClient; + protected HttpUrl collectionURL; + protected DavResource davCollection; + + + /** remote CTag at the time of {@link #listRemote()} */ + protected String remoteCTag = null; + + /** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */ + protected Map localResources; + + /** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */ + protected Map remoteResources; + + /** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */ + protected Set toDownload; - final Context context; - final Account account; - final Bundle extras; - final ContentProviderClient provider; - final SyncResult syncResult; - final AccountSettings settings; - final HttpClient httpClient; public SyncManager(int notificationId, Context context, Account account, Bundle extras, ContentProviderClient provider, SyncResult syncResult) { this.context = context; @@ -73,35 +109,46 @@ abstract public class SyncManager { public void performSync() { int syncPhase = SYNC_PHASE_PREPARE; try { + Constants.log.info("Preparing synchronization"); prepare(); syncPhase = SYNC_PHASE_QUERY_CAPABILITIES; + Constants.log.info("Querying capabilities"); queryCapabilities(); syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED; + Constants.log.info("Processing locally deleted entries"); processLocallyDeleted(); syncPhase = SYNC_PHASE_PREPARE_LOCALLY_CREATED; + Constants.log.info("Processing locally created entries"); processLocallyCreated(); syncPhase = SYNC_PHASE_UPLOAD_DIRTY; + Constants.log.info("Uploading dirty entries"); uploadDirty(); syncPhase = SYNC_PHASE_CHECK_SYNC_STATE; + Constants.log.info("Checking sync state"); if (checkSyncState()) { syncPhase = SYNC_PHASE_LIST_LOCAL; + Constants.log.info("Listing local entries"); listLocal(); syncPhase = SYNC_PHASE_LIST_REMOTE; + Constants.log.info("Listing remote entries"); listRemote(); - syncPhase = SYNC_PHASE_COMPARE_ENTRIES; - compareEntries(); + syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE; + Constants.log.info("Comparing local/remote entries"); + compareLocalRemote(); syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE; + Constants.log.info("Downloading remote entries"); downloadRemote(); syncPhase = SYNC_PHASE_SAVE_SYNC_STATE; + Constants.log.info("Saving sync state"); saveSyncState(); } else Constants.log.info("Remote collection didn't change, skipping remote sync"); @@ -110,8 +157,8 @@ abstract public class SyncManager { Constants.log.error("I/O exception during sync, trying again later", e); syncResult.stats.numIoExceptions++; - } catch(HttpException e) { - Constants.log.error("HTTP Exception during sync", e); + } catch(HttpException|DavException e) { + Constants.log.error("HTTP/DAV Exception during sync", e); syncResult.stats.numParseExceptions++; Intent detailsIntent = new Intent(context, DebugInfoActivity.class); @@ -138,40 +185,188 @@ abstract public class SyncManager { } notificationManager.notify(account.name, notificationId, notification); - } catch(DavException e) { - // TODO - } catch(ContactsStorageException e) { + } catch(CalendarStorageException|ContactsStorageException e) { + Constants.log.error("Couldn't access local storage", e); syncResult.databaseError = true; } - } abstract protected void prepare(); - abstract protected void queryCapabilities() throws IOException, HttpException, DavException, ContactsStorageException; + abstract protected void queryCapabilities() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException; + + protected void processLocallyDeleted() throws CalendarStorageException, ContactsStorageException { + // Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before), + // but only if they don't have changed on the server. Then finally remove them from the local address book. + LocalResource[] localList = localCollection.getDeleted(); + for (LocalResource local : localList) { + final String fileName = local.getFileName(); + if (!TextUtils.isEmpty(fileName)) { + Constants.log.info(fileName + " has been deleted locally -> deleting from server"); + try { + new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build()) + .delete(local.getETag()); + } catch (IOException | HttpException e) { + Constants.log.warn("Couldn't delete " + fileName + " from server"); + } + } else + Constants.log.info("Removing local record #" + local.getId() + " which has been deleted locally and was never uploaded"); + local.delete(); + syncResult.stats.numDeletes++; + } + } + + protected void processLocallyCreated() throws CalendarStorageException, ContactsStorageException { + // assign file names and UIDs to new contacts so that we can use the file name as an index + for (LocalResource local : localCollection.getWithoutFileName()) { + String uuid = UUID.randomUUID().toString(); + Constants.log.info("Found local record #" + local.getId() + " without file name; assigning file name/UID based on " + uuid); + local.updateFileNameAndUID(uuid); + } + } + + abstract protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException, ContactsStorageException; - abstract protected void processLocallyDeleted() throws IOException, HttpException, DavException, ContactsStorageException; + protected void uploadDirty() throws IOException, HttpException, CalendarStorageException, ContactsStorageException { + // upload dirty contacts + for (LocalResource local : localCollection.getDirty()) { + final String fileName = local.getFileName(); - abstract protected void processLocallyCreated() throws IOException, HttpException, DavException, ContactsStorageException; + DavResource remote = new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build()); - abstract protected void uploadDirty() throws IOException, HttpException, DavException, ContactsStorageException; + // generate entity to upload (VCard, iCal, whatever) + RequestBody body = prepareUpload(local); + + try { + + if (local.getETag() == null) { + Constants.log.info("Uploading new record " + fileName); + remote.put(body, null, true); + // TODO handle 30x + } else { + Constants.log.info("Uploading locally modified record " + fileName); + remote.put(body, local.getETag(), false); + // TODO handle 30x + } + + } catch (PreconditionFailedException e) { + Constants.log.info("Resource has been modified on the server before upload, ignoring", e); + } + + String eTag = null; + GetETag newETag = (GetETag) remote.properties.get(GetETag.NAME); + if (newETag != null) { + eTag = newETag.eTag; + Constants.log.debug("Received new ETag=" + eTag + " after uploading"); + } else + Constants.log.debug("Didn't receive new ETag after uploading, setting to null"); + + local.clearDirty(eTag); + } + } /** * Checks the current sync state (e.g. CTag) and whether synchronization from remote is required. - * @return true if the remote collection has changed, i.e. synchronization from remote is required - * false if the remote collection hasn't changed + * @return
    + *
  • true if the remote collection has changed, i.e. synchronization from remote is required
  • + *
  • false if the remote collection hasn't changed
  • + *
*/ - abstract protected boolean checkSyncState() throws IOException, HttpException, DavException, ContactsStorageException; + protected boolean checkSyncState() throws CalendarStorageException, ContactsStorageException { + // check CTag (ignore on manual sync) + GetCTag getCTag = (GetCTag)davCollection.properties.get(GetCTag.NAME); + if (getCTag != null) + remoteCTag = getCTag.cTag; + + String localCTag = null; + if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) + Constants.log.info("Manual sync, ignoring CTag"); + else + localCTag = localCollection.getCTag(); + + if (remoteCTag != null && remoteCTag.equals(localCTag)) { + Constants.log.info("Remote collection didn't change (CTag=" + remoteCTag + "), no need to query children"); + return false; + } else + return true; + } - abstract protected void listLocal() throws IOException, HttpException, DavException, ContactsStorageException; + /** + * Lists all local resources which should be taken into account for synchronization into {@link #localResources}. + */ + protected void listLocal() throws CalendarStorageException, ContactsStorageException { + // fetch list of local contacts and build hash table to index file name + LocalResource[] localList = localCollection.getAll(); + localResources = new HashMap<>(localList.length); + for (LocalResource resource : localList) { + Constants.log.debug("Found local resource: " + resource.getFileName()); + localResources.put(resource.getFileName(), resource); + } + } - abstract protected void listRemote() throws IOException, HttpException, DavException, ContactsStorageException; + /** + * Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}. + */ + abstract protected void listRemote() throws IOException, HttpException, DavException; - abstract protected void compareEntries() throws IOException, HttpException, DavException, ContactsStorageException; + /** + * Compares {@link #localResources} and {@link #remoteResources} by file name and ETag: + *
    + *
  • Local resources which are not available in the remote collection (anymore) will be removed.
  • + *
  • Resources whose remote ETag has changed will be added into {@link #toDownload}
  • + *
+ */ + protected void compareLocalRemote() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException { + /* check which contacts + 1. are not present anymore remotely -> delete immediately on local side + 2. updated remotely -> add to downloadNames + 3. added remotely -> add to downloadNames + */ + toDownload = new HashSet<>(); + for (String localName : localResources.keySet()) { + DavResource remote = remoteResources.get(localName); + if (remote == null) { + Constants.log.info(localName + " is not on server anymore, deleting"); + localResources.get(localName).delete(); + syncResult.stats.numDeletes++; + } else { + // contact is still on server, check whether it has been updated remotely + GetETag getETag = (GetETag) remote.properties.get(GetETag.NAME); + if (getETag == null || getETag.eTag == null) + throw new DavException("Server didn't provide ETag"); + String localETag = localResources.get(localName).getETag(), + remoteETag = getETag.eTag; + if (remoteETag.equals(localETag)) + syncResult.stats.numSkippedEntries++; + else { + Constants.log.info(localName + " has been changed on server (current ETag=" + remoteETag + ", last known ETag=" + localETag + ")"); + toDownload.add(remote); + } + + // remote entry has been seen, remove from list + remoteResources.remove(localName); + } + } - abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException; + // add all unseen (= remotely added) remote contacts + if (!remoteResources.isEmpty()) { + Constants.log.info("New VCards have been found on the server: " + TextUtils.join(", ", remoteResources.keySet())); + toDownload.addAll(remoteResources.values()); + } + } - abstract protected void saveSyncState() throws IOException, HttpException, DavException, ContactsStorageException; + /** + * Downloads the remote resources in {@link #toDownload} and stores them locally. + */ + abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException, CalendarStorageException; + + protected void saveSyncState() throws CalendarStorageException, ContactsStorageException { + /* Save sync state (CTag). It doesn't matter if it has changed during the sync process + (for instance, because another client has uploaded changes), because this will simply + cause all remote entries to be listed at the next sync. */ + Constants.log.info("Saving CTag=" + remoteCTag); + localCollection.setCTag(remoteCTag); + } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.java b/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.java index b068db4c..44e09cbd 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.java @@ -14,6 +14,7 @@ import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.ContentValues; import android.os.Bundle; +import android.provider.CalendarContract; import android.provider.ContactsContract; import android.text.Editable; import android.text.TextWatcher; @@ -28,13 +29,16 @@ import android.widget.EditText; import android.widget.TextView; import android.widget.Toast; +import java.util.Calendar; import java.util.List; import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.R; import at.bitfire.davdroid.resource.LocalAddressBook; +import at.bitfire.davdroid.resource.LocalCalendar; import at.bitfire.davdroid.resource.ServerInfo; import at.bitfire.davdroid.syncadapter.AccountSettings; +import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.vcard4android.ContactsStorageException; import lombok.Cleanup; @@ -105,14 +109,18 @@ public class AccountDetailsFragment extends Fragment implements TextWatcher { } }); - /*addSync(account, CalendarContract.AUTHORITY, serverInfo.getCalendars(), new AddSyncCallback() { + addSync(account, CalendarContract.AUTHORITY, serverInfo.getCalendars(), new AddSyncCallback() { @Override - public void createLocalCollection(Account account, ServerInfo.ResourceInfo calendar) throws LocalStorageException { - LocalCalendar.create(account, getActivity().getContentResolver(), calendar); + public void createLocalCollection(Account account, ServerInfo.ResourceInfo calendar) { + try { + LocalCalendar.create(account, getActivity().getContentResolver(), calendar); + } catch(CalendarStorageException e) { + Constants.log.error("Couldn't create local calendar", e); + } } }); - addSync(account, LocalTaskList.TASKS_AUTHORITY, serverInfo.getTaskLists(), new AddSyncCallback() { + /*addSync(account, LocalTaskList.TASKS_AUTHORITY, serverInfo.getTaskLists(), new AddSyncCallback() { @Override public void createLocalCollection(Account account, ServerInfo.ResourceInfo todoList) throws LocalStorageException { LocalTaskList.create(account, getActivity().getContentResolver(), todoList); diff --git a/app/src/main/java/org/dmfs/provider/tasks/TaskContract.java b/app/src/main/java/org/dmfs/provider/tasks/TaskContract.java deleted file mode 100644 index 08d4b3e4..00000000 --- a/app/src/main/java/org/dmfs/provider/tasks/TaskContract.java +++ /dev/null @@ -1,1519 +0,0 @@ -/* - * Copyright (C) 2012 Marten Gajda - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package org.dmfs.provider.tasks; - -import java.util.HashMap; -import java.util.Map; - -import android.content.ContentResolver; -import android.net.Uri; - - -/** - * Task contract. This class defines the interface to the task provider. - *

- * TODO: Add missing javadoc. - *

- *

- * TODO: Specify extended properties - *

- *

- * TODO: Add CONTENT_URI for the attachment store. - *

- *

- * TODO: Also, we could use some refactoring... - *

- * - * @author Marten Gajda - * @author Tobias Reinsch - */ -public final class TaskContract -{ - - private static Map sUriFactories = new HashMap(4); - - /** - * URI parameter to signal that the caller is a sync adapter. - */ - public static final String CALLER_IS_SYNCADAPTER = "caller_is_syncadapter"; - - /** - * URI parameter to signal the request of the extended properties of a task. - */ - public static final String LOAD_PROPERTIES = "load_properties"; - - /** - * URI parameter to submit the account name of the account we operate on. - */ - public static final String ACCOUNT_NAME = "account_name"; - - /** - * URI parameter to submit the account type of the account we operate on. - */ - public static final String ACCOUNT_TYPE = "account_type"; - - /** - * Account type for local, unsynced task lists. - */ - public static final String LOCAL_ACCOUNT = "LOCAL"; - - - /** - * Private constructor to prevent instantiation. - */ - private TaskContract() - { - } - - - /** - * Get the base content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * @return A {@link Uri}. - */ - public final static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(); - } - - /** - * A set of columns for synchronization purposes. These columns exist in {@link Tasks} and in {@link TaskLists} but have different meanings. Only sync - * adapters are allowed to change these values. - * - * @author Marten Gajda - */ - public interface CommonSyncColumns - { - - /** - * A unique Sync ID as set by the sync adapter. - *

- * Value: String - *

- */ - public static final String _SYNC_ID = "_sync_id"; - - /** - * Sync version as set by the sync adapter. - *

- * Value: String - *

- */ - public static final String SYNC_VERSION = "sync_version"; - - /** - * Indicates that a task or a task list has been changed. - *

- * Value: Integer - *

- */ - public static final String _DIRTY = "_dirty"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - public static final String SYNC1 = "sync1"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - public static final String SYNC2 = "sync2"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - public static final String SYNC3 = "sync3"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - public static final String SYNC4 = "sync4"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - public static final String SYNC5 = "sync5"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - public static final String SYNC6 = "sync6"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - public static final String SYNC7 = "sync7"; - - /** - * A general purpose column for the sync adapter. - *

- * Value: String - *

- */ - public static final String SYNC8 = "sync8"; - - } - - /** - * Additional sync columns for task lists. - * - * @author Marten Gajda - */ - public interface TaskListSyncColumns - { - - /** - * The name of the account this list belongs to. This field is write-once. - *

- * Value: String - *

- */ - public static final String ACCOUNT_NAME = "account_name"; - - /** - * The type of the account this list belongs to. This field is write-once. - *

- * Value: String - *

- */ - public static final String ACCOUNT_TYPE = "account_type"; - } - - /** - * Additional sync columns for tasks. - * - * @author Marten Gajda - */ - public interface TaskSyncColumns - { - /** - * The UID of a task. This is field can be changed by a sync adapter only. - *

- * Value: String - *

- */ - public static final String _UID = "_uid"; - - /** - * Deleted flag of a task. This is set to 1 by the content provider when a task app deletes a task. The sync adapter has to remove the task - * again to finish the removal. This value is read-only. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String _DELETED = "_deleted"; - } - - /** - * Data columns of task lists. - * - * @author Marten Gajda - */ - public interface TaskListColumns - { - - /** - * List ID. - *

- * Value: Long - *

- *

- * read-only - *

- */ - public static final String _ID = "_id"; - - /** - * The name of the task list. - *

- * Value: String - *

- */ - public static final String LIST_NAME = "list_name"; - - /** - * The color of this list as integer (0xaarrggbb). Only the sync adapter can change this. - *

- * Value: Integer - *

- */ - public static final String LIST_COLOR = "list_color"; - - /** - * The access level a user has on this list. This value is not used yet, sync adapters should set it to 0. - *

- * Value: Integer - *

- */ - public static final String ACCESS_LEVEL = "list_access_level"; - - /** - * Indicates that a task list is set to be visible. - *

- * Value: Integer (0 or 1) - *

- */ - public static final String VISIBLE = "visible"; - - /** - * Indicates that a task list is set to be synced. - *

- * Value: Integer (0 or 1) - *

- */ - public static final String SYNC_ENABLED = "sync_enabled"; - - /** - * The email address of the list owner. - *

- * Value: String - *

- */ - public static final String OWNER = "list_owner"; - - } - - /** - * The task list table holds one entry for each task list. - * - * @author Marten Gajda - */ - public static final class TaskLists implements TaskListColumns, TaskListSyncColumns, CommonSyncColumns - { - static final String CONTENT_URI_PATH = "tasklists"; - - /** - * The default sort order. - */ - public static final String DEFAULT_SORT_ORDER = ACCOUNT_NAME + ", " + LIST_NAME; - - /** - * An array of columns only a sync adapter is allowed to change. - */ - public static final String[] SYNC_ADAPTER_COLUMNS = new String[] { ACCESS_LEVEL, _DIRTY, OWNER, SYNC1, SYNC2, SYNC3, SYNC4, SYNC5, SYNC6, SYNC7, SYNC8, - _SYNC_ID, SYNC_VERSION, }; - - - /** - * Get the task list content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * @return A {@link Uri}. - */ - public final static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - /** - * Task data columns. Defines all the values a task can have at most once. - * - * @author Marten Gajda - */ - public interface TaskColumns - { - - /** - * The row id of a task. This value is read-only - *

- * Value: Integer - *

- */ - public static final String _ID = "_id"; - - /** - * The id of the list this task belongs to. This value is write-once and must not be null. - *

- * Value: Integer - *

- */ - public static final String LIST_ID = "list_id"; - - /** - * The title of the task. - *

- * Value: String - *

- */ - public static final String TITLE = "title"; - - /** - * The location of the task. - *

- * Value: String - *

- */ - public static final String LOCATION = "location"; - - /** - * A geographic location related to the task. The should be a string in the format "longitude,latitude". - *

- * Value: String - *

- */ - public static final String GEO = "geo"; - - /** - * The description of a task. - *

- * Value: String - *

- */ - public static final String DESCRIPTION = "description"; - - /** - * An URL for this task. Must be a valid URL if not null- - *

- * Value: String - *

- */ - public static final String URL = "url"; - - /** - * The email address of the organizer if any, {@code null} otherwise. - *

- * Value: String - *

- */ - public static final String ORGANIZER = "organizer"; - - /** - * The priority of a task. This is an Integer between zero and 9. Zero means there is no priority set. 1 is the highest priority and 9 the lowest. - *

- * Value: Integer - *

- */ - public static final String PRIORITY = "priority"; - - /** - * The default value of {@link #PRIORITY}. - */ - public static final int PRIORITY_DEFAULT = 0; - - /** - * The classification of a task. This value must be either null or one of {@link #CLASSIFICATION_PUBLIC}, {@link #CLASSIFICATION_PRIVATE}, - * {@link #CLASSIFICATION_CONFIDENTIAL}. - *

- * Value: Integer - *

- */ - public static final String CLASSIFICATION = "class"; - - /** - * Classification value for public tasks. - */ - public static final int CLASSIFICATION_PUBLIC = 0; - - /** - * Classification value for private tasks. - */ - public static final int CLASSIFICATION_PRIVATE = 1; - - /** - * Classification value for confidential tasks. - */ - public static final int CLASSIFICATION_CONFIDENTIAL = 2; - - /** - * Default value of {@link #CLASSIFICATION}. - */ - public static final Integer CLASSIFICATION_DEFAULT = null; - - /** - * Date of completion of this task in milliseconds since the epoch or {@code null} if this task has not been completed yet. - *

- * Value: Long - *

- */ - public static final String COMPLETED = "completed"; - - /** - * Indicates that the date of completion is an all-day date. - *

- * Value: Integer - *

- */ - public static final String COMPLETED_IS_ALLDAY = "completed_is_allday"; - - /** - * A number between 0 and 100 that indicates the progress of the task or null. - *

- * Value: Integer (0-100) - *

- */ - public static final String PERCENT_COMPLETE = "percent_complete"; - - /** - * The status of this task. One of {@link #STATUS_NEEDS_ACTION},{@link #STATUS_IN_PROCESS}, {@link #STATUS_COMPLETED}, {@link #STATUS_CANCELLED}. - *

- * Value: Integer - *

- */ - public static final String STATUS = "status"; - - /** - * A specific status indicating that nothing has been done yet. - */ - public static final int STATUS_NEEDS_ACTION = 0; - - /** - * A specific status indicating that some work has been done. - */ - public static final int STATUS_IN_PROCESS = 1; - - /** - * A specific status indicating that the task is completed. - */ - public static final int STATUS_COMPLETED = 2; - - /** - * A specific status indicating that the task has been cancelled. - */ - public static final int STATUS_CANCELLED = 3; - - /** - * The default status is "needs action". - */ - public static final int STATUS_DEFAULT = STATUS_NEEDS_ACTION; - - /** - * A flag that indicates a task is new (i.e. not work has been done yet). This flag is read-only. Its value is 1 when - * {@link #STATUS} equals {@link #STATUS_NEEDS_ACTION} and 0 otherwise. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String IS_NEW = "is_new"; - - /** - * A flag that indicates a task is closed (no more work has to be done). This flag is read-only. Its value is 1 when - * {@link #STATUS} equals {@link #STATUS_COMPLETED} or {@link #STATUS_CANCELLED} and 0 otherwise. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String IS_CLOSED = "is_closed"; - - /** - * An individual color for this task in the format 0xaarrggbb or {@code null} to use {@link TaskListColumns#LIST_COLOR} instead. - *

- * Value: Integer - *

- */ - public static final String TASK_COLOR = "task_color"; - - /** - * When this task starts in milliseconds since the epoch. - *

- * Value: Long - *

- */ - public static final String DTSTART = "dtstart"; - - /** - * Boolean: flag that indicates that this is an all-day task. - */ - public static final String IS_ALLDAY = "is_allday"; - - /** - * When this task has been created in milliseconds since the epoch. - *

- * Value: Long - *

- */ - public static final String CREATED = "created"; - - /** - * When this task had been modified the last time in milliseconds since the epoch. - *

- * Value: Long - *

- */ - public static final String LAST_MODIFIED = "last_modified"; - - /** - * String: An Olson Id of the time zone of this task. If this value is null, it's automatically replaced by the local time zone. - */ - public static final String TZ = "tz"; - - /** - * When this task is due in milliseconds since the epoch. Only one of {@link #DUE} or {@link #DURATION} must be supplied (or none of both if the task - * has no due date). - *

- * Value: Long - *

- */ - public static final String DUE = "due"; - - /** - * The duration of this task. Only one of {@link #DUE} or {@link #DURATION} must be supplied (or none of both if the task has no due date). Setting a - * {@link #DURATION} is not allowed when {@link #DTSTART} is null. The Value must be a duration string as in RFC 5545 Section 3.3.6. - *

- * Value: String - *

- */ - public static final String DURATION = "duration"; - - /** - * A comma separated list of time Strings in RFC 5545 format (see RFC 5545 Section 3.3.4 - * and RFC 5545 Section 3.3.5) that contains dates of instances of e recurring task. - * All-day tasks must use the DATE format specified in section 3.3.4 of RFC 5545. - * - * This value must be {@code null} for exception instances. - *

- * Value: String - *

- */ - public static final String RDATE = "rdate"; - - /** - * A comma separated list of time Strings in RFC 5545 format (see RFC 5545 Section 3.3.4 - * and RFC 5545 Section 3.3.5) that contains dates of exceptions of a recurring task. - * All-day tasks must use the DATE format specified in section 3.3.4 of RFC 5545. - * - * This value must be {@code null} for exception instances. - *

- * Value: String - *

- */ - public static final String EXDATE = "exdate"; - - /** - * A recurrence rule as specified in RFC 5545 Section 3.3.10. - * - * This value must be {@code null} for exception instances. - *

- * Value: String - *

- */ - public static final String RRULE = "rrule"; - - /** - * The _sync_id of the original event if this is an exception, null otherwise. Only one of {@link #ORIGINAL_INSTANCE_SYNC_ID} or - * {@link #ORIGINAL_INSTANCE_ID} must be set if this task is an exception. The other one will be updated by the content provider. - *

- * Value: String - *

- */ - public static final String ORIGINAL_INSTANCE_SYNC_ID = "original_instance_sync_id"; - - /** - * The row id of the original event if this is an exception, null otherwise. Only one of {@link #ORIGINAL_INSTANCE_SYNC_ID} or - * {@link #ORIGINAL_INSTANCE_ID} must be set if this task is an exception. The other one will be updated by the content provider. - *

- * Value: Long - *

- */ - public static final String ORIGINAL_INSTANCE_ID = "original_instance_id"; - - /** - * The time in milliseconds since the Epoch of the original instance that is overridden by this instance or null if this task is not an - * exception. - *

- * Value: Long - *

- */ - public static final String ORIGINAL_INSTANCE_TIME = "original_instance_time"; - - /** - * A flag indicating that the original instance was an all-day task. - *

- * Value: Integer - *

- */ - public static final String ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"; - - /** - * The row id of the parent task. null if the task has no parent task. - *

- * Value: Long - *

- */ - public static final String PARENT_ID = "parent_id"; - - /** - * The sorting of this task under it's parent task. - *

- * Value: String - *

- */ - public static final String SORTING = "sorting"; - - /** - * Indicates how many alarms a task has. 0 means the task has no alarms. - *

- * Value: Integer - *

- * - */ - public static final String HAS_ALARMS = "has_alarms"; - } - - /** - * Columns that are valid in a search query. - * - * @author Marten Gajda - */ - public interface TaskSearchColumns - { - /** - * The score of a task in a search result. It's an indicator for the relevance of the task. Value is in (0, 1.0] where 0 would be "no relevance" at all - * (though the result doesn't contain such tasks). - *

- * Value: Float - *

- */ - public final static String SCORE = "score"; - } - - /** - * The task table stores the data of all tasks. - * - * @author Marten Gajda - */ - public static final class Tasks implements TaskColumns, CommonSyncColumns, TaskSyncColumns, TaskSearchColumns - { - /** - * The name of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String ACCOUNT_NAME = TaskLists.ACCOUNT_NAME; - - /** - * The type of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String ACCOUNT_TYPE = TaskLists.ACCOUNT_TYPE; - - /** - * The name of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value - * here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String LIST_NAME = TaskLists.LIST_NAME; - /** - * The color of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value - * here. To change the color of an individual task use {@code TASK_COLOR} instead. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String LIST_COLOR = TaskLists.LIST_COLOR; - - /** - * The owner of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String LIST_OWNER = TaskLists.OWNER; - - /** - * The access level of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String LIST_ACCESS_LEVEL = TaskLists.ACCESS_LEVEL; - - /** - * The visibility of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String VISIBLE = "visible"; - - static final String CONTENT_URI_PATH = "tasks"; - - static final String SEARCH_URI_PATH = "tasks_search"; - - static final String SEARCH_QUERY_PARAMETER = "q"; - - public static final String DEFAULT_SORT_ORDER = DUE; - - public static final String[] SYNC_ADAPTER_COLUMNS = new String[] { _DIRTY, SYNC1, SYNC2, SYNC3, SYNC4, SYNC5, SYNC6, SYNC7, SYNC8, _SYNC_ID, - SYNC_VERSION, }; - - - /** - * Get the tasks content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * @return A {@link Uri}. - */ - public final static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - - public final static Uri getSearchUri(String authority, String query) - { - Uri.Builder builder = getUriFactory(authority).getUri(SEARCH_URI_PATH).buildUpon(); - builder.appendQueryParameter(SEARCH_QUERY_PARAMETER, Uri.encode(query)); - return builder.build(); - } - } - - /** - * Columns of a task instance. - * - * @author Yannic Ahrens - * @author Marten Gajda - */ - public interface InstanceColumns - { - /** - * _ID of task this instance belongs to. - *

- * Value: Long - *

- */ - public static final String TASK_ID = "task_id"; - - /** - * The start date of an instance in milliseconds since the epoch or null if the instance has no start date. At present this is read only. - *

- * Value: Long - *

- */ - public static final String INSTANCE_START = "instance_start"; - - /** - * The due date of an instance in milliseconds since the epoch or null if the instance has no due date. At present this is read only. - *

- * Value: Long - *

- */ - public static final String INSTANCE_DUE = "instance_due"; - - /** - * This column should be used in an order clause to sort instances by due date. It contains a slightly modified start date that takes allday tasks into - * account. - *

- * Value: Long - *

- *

- * read-only - *

- */ - public static final String INSTANCE_START_SORTING = "instance_start_sorting"; - - /** - * This column should be used in an order clause to sort instances by due date. It contains a slightly modified due date that takes allday tasks into - * account. - *

- * Value: Long - *

- *

- * read-only - *

- */ - public static final String INSTANCE_DUE_SORTING = "instance_due_sorting"; - - /** - * The duration of an instance in milliseconds or null if the instance has only one of start or due date or none of both. At present this - * is read only. - *

- * Value: Long - *

- */ - public static final String INSTANCE_DURATION = "instance_duration"; - - } - - /** - * Instances of a task. At present this table is read only. Currently it contains exactly one entry per task (and task exception), so it's merely a copy of - * {@link Tasks}. - *

- * TODO: Insert all instances of recurring the tasks. - *

- *

- * TODO: In later releases it's planned to provide a convenient interface to add, change or delete task instances via this URI. - *

- * - * @author Yannic Ahrens - */ - public static final class Instances implements TaskColumns, InstanceColumns - { - - /** - * The name of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String ACCOUNT_NAME = TaskLists.ACCOUNT_NAME; - - /** - * The type of the account the task belongs to. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String ACCOUNT_TYPE = TaskLists.ACCOUNT_TYPE; - - /** - * The name of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value - * here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String LIST_NAME = TaskLists.LIST_NAME; - /** - * The color of the list this task belongs to as integer (0xaarrggbb). This is auto-derived from the list the task belongs to. Do not write this value - * here. To change the color of an individual task use {@code TASK_COLOR} instead. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String LIST_COLOR = TaskLists.LIST_COLOR; - - /** - * The owner of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: String - *

- *

- * read-only - *

- */ - public static final String LIST_OWNER = TaskLists.OWNER; - - /** - * The access level of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String LIST_ACCESS_LEVEL = TaskLists.ACCESS_LEVEL; - - /** - * The visibility of the list this task belongs. This is auto-derived from the list the task belongs to. Do not write this value here. - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public static final String VISIBLE = "visible"; - - static final String CONTENT_URI_PATH = "instances"; - - public static final String DEFAULT_SORT_ORDER = INSTANCE_DUE_SORTING; - - - /** - * Get the instances content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * @return A {@link Uri}. - */ - public final static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - /** - * Available values in Categories. - * - * Categories are per account. It's up to the front-end to ensure consistency of category colors across accounts. - * - * @author Marten Gajda - */ - public interface CategoriesColumns - { - - public static final String _ID = "_id"; - - public static final String ACCOUNT_NAME = "account_name"; - - public static final String ACCOUNT_TYPE = "account_type"; - - public static final String NAME = "name"; - - public static final String COLOR = "color"; - } - - public static final class Categories implements CategoriesColumns - { - - static final String CONTENT_URI_PATH = "categories"; - - public static final String DEFAULT_SORT_ORDER = NAME; - - - /** - * Get the categories content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * @return A {@link Uri}. - */ - public final static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - public interface AlarmsColumns - { - public static final String ALARM_ID = "alarm_id"; - - public static final String LAST_TRIGGER = "last_trigger"; - - public static final String NEXT_TRIGGER = "next_trigger"; - } - - public static final class Alarms implements AlarmsColumns - { - - static final String CONTENT_URI_PATH = "alarms"; - - - /** - * Get the alarms content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * @return A {@link Uri}. - */ - public final static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - public interface PropertySyncColumns - { - public static final String SYNC1 = "prop_sync1"; - - public static final String SYNC2 = "prop_sync2"; - - public static final String SYNC3 = "prop_sync3"; - - public static final String SYNC4 = "prop_sync4"; - - public static final String SYNC5 = "prop_sync5"; - - public static final String SYNC6 = "prop_sync6"; - - public static final String SYNC7 = "prop_sync7"; - - public static final String SYNC8 = "prop_sync8"; - } - - public interface PropertyColumns - { - - public static final String PROPERTY_ID = "property_id"; - - public static final String TASK_ID = "task_id"; - - public static final String MIMETYPE = "mimetype"; - - public static final String VERSION = "prop_version"; - - public static final String DATA0 = "data0"; - - public static final String DATA1 = "data1"; - - public static final String DATA2 = "data2"; - - public static final String DATA3 = "data3"; - - public static final String DATA4 = "data4"; - - public static final String DATA5 = "data5"; - - public static final String DATA6 = "data6"; - - public static final String DATA7 = "data7"; - - public static final String DATA8 = "data8"; - - public static final String DATA9 = "data9"; - - public static final String DATA10 = "data10"; - - public static final String DATA11 = "data11"; - - public static final String DATA12 = "data12"; - - public static final String DATA13 = "data13"; - - public static final String DATA14 = "data14"; - - public static final String DATA15 = "data15"; - } - - public static final class Properties implements PropertySyncColumns, PropertyColumns - { - - static final String CONTENT_URI_PATH = "properties"; - - public static final String DEFAULT_SORT_ORDER = DATA0; - - - /** - * Get the properties content {@link Uri} using the given authority. - * - * @param authority - * The authority. - * @return A {@link Uri}. - */ - public final static Uri getContentUri(String authority) - { - return getUriFactory(authority).getUri(CONTENT_URI_PATH); - } - - } - - public interface Property - { - /** - * Attached documents. - *

- * Note: Attachments are write-once. To change an attachment you'll have to remove and re-add it. - *

- * - * @author Marten Gajda - */ - public static interface Attachment extends PropertyColumns - { - /** - * The mime-type of this property. - */ - public final static String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/attachment"; - - /** - * URL of the attachment. This is the link that points to the attached resource. - *

- * Value: String - *

- */ - public final static String URL = DATA1; - - /** - * The display name of the attachment, if any. - *

- * Value: String - *

- */ - public final static String DISPLAY_NAME = DATA2; - - /** - * Content-type of the attachment. - *

- * Value: String - *

- */ - public final static String FORMAT = DATA3; - - /** - * File size of the attachment or -1 if unknown. - *

- * Value: Long - *

- */ - public final static String SIZE = DATA4; - - /** - * A content {@link Uri} that can be used to retrieve the attachment. Sync adapters can set this field if they know how to download the attachment - * without going through the browser. - *

- * Value: String - *

- */ - public final static String CONTENT_URI = DATA5; - - } - - public static interface Attendee extends PropertyColumns - { - /** - * The mime-type of this property. - */ - public final static String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/attendee"; - - /** - * Name of the contact, if known. - *

- * Value: String - *

- */ - public final static String NAME = DATA0; - - /** - * Email address of the contact. - *

- * Value: String - *

- */ - public final static String EMAIL = DATA1; - - public final static String ROLE = DATA2; - - public final static String STATUS = DATA3; - - public final static String RSVP = DATA4; - } - - /** - * Categories are immutable. For creation is either the category id or name necessary - * - */ - public static interface Category extends PropertyColumns - { - /** - * The mime-type of this property. - */ - public final static String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/category"; - - /** - * Row id of the category. - *

- * Value: Long - *

- */ - public final static String CATEGORY_ID = DATA0; - - /** - * The name of the category - *

- * Value: String - *

- */ - public final static String CATEGORY_NAME = DATA1; - - /** - * The decimal coded color of the category - *

- * Value: Integer - *

- *

- * read-only - *

- */ - public final static String CATEGORY_COLOR = DATA2; - } - - public static interface Comment extends PropertyColumns - { - /** - * The mime-type of this property. - */ - public final static String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/comment"; - - /** - * Comment text. - *

- * Value: String - *

- */ - public final static String COMMENT = DATA0; - - /** - * Language code of the comment as defined in RFC5646 or null. - *

- * Value: String - *

- */ - public final static String LANGUAGE = DATA1; - } - - public static interface Contact extends PropertyColumns - { - /** - * The mime-type of this property. - */ - public final static String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/contact"; - - public final static String NAME = DATA0; - - public final static String LANGUAGE = DATA1; - } - - /** - * Relations of a task. - *

- * When writing a relation, exactly one of {@link #RELATED_ID}, {@link #RELATED_UID} or {@link #RELATED_URI} must be given. {@link #RELATED_CONTENT_URI} - * will be populated automatically if possible. - *

- */ - public static interface Relation extends PropertyColumns - { - /** - * The mime-type of this property. - */ - public final static String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/relation"; - - /** - * The row id of the related task. May be -1 if the property doesn't refer to a task in this database or if it doesn't refer to a task - * at all. - *

- * Value: long - *

- */ - public final static String RELATED_ID = DATA1; - - /** - * The relation type. This must be the ordinal value of a {@link RelType}. - *

- * Value: int - *

- */ - public final static String RELATED_TYPE = DATA2; - - /** - * The UID of the related object. - *

- * Value: String - *

- */ - public final static String RELATED_UID = DATA3; - - /** - * The URI of a related object. - *

- * Value: String (URI) - *

- */ - public final static String RELATED_URI = DATA4; - - /** - * The URI of a related object in another Android content provider. If the object is a task in this database, this is null. If the - * related object is an event or note this field may contain the content URI to the object. - *

- * Value: String (URI) - *

- *

- * This field is read-only. - *

- */ - public final static String RELATED_CONTENT_URI = DATA5; - - /** - * An optional gap value for temporal relationships. - *

- * Value: duration string - *

- */ - public final static String GAP = DATA6; - - /** - * Valid values for the {@link Relation#RELATED_TYPE} field. Note that the field actually takes the ordinal value of these. - */ - public enum RelType - { - /** - * The related object is the parent of the object owning this relation. - */ - PARENT, - - /** - * The related object is the child of the object owning this relation. - */ - CHILD, - - /** - * The related object is a sibling of the object owning this relation. - */ - SIBLING, - - DEPENDS_ON, - - REFID, - - STRUCTURED_CATEGORY, - - FINISHTOSTART, - - FINISHTOFINISH, - - STARTTOFINISH, - - STARTTOSTART; - } - } - - public static interface Alarm extends PropertyColumns - { - - public static final int ALARM_TYPE_NOTHING = 0; - - public static final int ALARM_TYPE_MESSAGE = 1; - - public static final int ALARM_TYPE_EMAIL = 2; - - public static final int ALARM_TYPE_SMS = 3; - - public static final int ALARM_TYPE_SOUND = 4; - - public static final int ALARM_REFERENCE_DUE_DATE = 1; - - public static final int ALARM_REFERENCE_START_DATE = 2; - - /** - * The mime-type of this property. - */ - public final static String CONTENT_ITEM_TYPE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/alarm"; - - /** - * Number of minutes from the reference date when the alarm goes off. If the value is < 0 the alarm will go off after the reference date. - *

- * Value: Integer - *

- */ - public final static String MINUTES_BEFORE = DATA0; - - /** - * The reference date for the alarm. Either {@link ALARM_REFERENCE_DUE_DATE} or {@link ALARM_REFERENCE_START_DATE}. - *

- * Value: Integer - *

- */ - public final static String REFERENCE = DATA1; - - /** - * A message that appears with the alarm. - *

- * Value: String - *

- */ - public final static String MESSAGE = DATA2; - - /** - * The type of the alarm. Use the provided alarm types {@link ALARM_TYPE_MESSAGE}, {@link ALARM_TYPE_SOUND}, {@link ALARM_TYPE_NOTHING}, - * {@link ALARM_TYPE_EMAIL} and {@link ALARM_TYPE_SMS}. - *

- * Value: Integer - *

- */ - public final static String ALARM_TYPE = DATA3; - } - - } - - - private static synchronized UriFactory getUriFactory(String authority) - { - UriFactory uriFactory = sUriFactories.get(authority); - if (uriFactory == null) - { - uriFactory = new UriFactory(authority); - uriFactory.addUri(TaskLists.CONTENT_URI_PATH); - uriFactory.addUri(Tasks.CONTENT_URI_PATH); - uriFactory.addUri(Tasks.SEARCH_URI_PATH); - uriFactory.addUri(Instances.CONTENT_URI_PATH); - uriFactory.addUri(Categories.CONTENT_URI_PATH); - uriFactory.addUri(Alarms.CONTENT_URI_PATH); - uriFactory.addUri(Properties.CONTENT_URI_PATH); - sUriFactories.put(authority, uriFactory); - - } - return uriFactory; - } -} diff --git a/app/src/main/java/org/dmfs/provider/tasks/UriFactory.java b/app/src/main/java/org/dmfs/provider/tasks/UriFactory.java deleted file mode 100644 index 2e330b6a..00000000 --- a/app/src/main/java/org/dmfs/provider/tasks/UriFactory.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2014 Marten Gajda - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package org.dmfs.provider.tasks; - -import java.util.HashMap; -import java.util.Map; - -import android.net.Uri; - - -public class UriFactory -{ - public final String authority; - - private final Map mUriMap = new HashMap(16); - - - UriFactory(String authority) - { - this.authority = authority; - mUriMap.put((String) null, Uri.parse("content://" + authority)); - } - - - void addUri(String path) - { - mUriMap.put(path, Uri.parse("content://" + authority + "/" + path)); - } - - - public Uri getUri() - { - return mUriMap.get(null); - } - - - public Uri getUri(String path) - { - return mUriMap.get(path); - } -} diff --git a/dav4android b/dav4android index e6c3ee6d..7530deb4 160000 --- a/dav4android +++ b/dav4android @@ -1 +1 @@ -Subproject commit e6c3ee6da90a94d3c77675b8fdd9be7e2d5f83e3 +Subproject commit 7530deb497c7c0ee78a583e7371ef9bfc4458a2e diff --git a/ical4android b/ical4android new file mode 160000 index 00000000..4e1131ae --- /dev/null +++ b/ical4android @@ -0,0 +1 @@ +Subproject commit 4e1131ae4607b4220e2d37632fd54a987b633849 diff --git a/settings.gradle b/settings.gradle index 2931f4d9..617c30b4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,5 +8,7 @@ include ':app' include ':dav4android' +include ':ical4android' include ':vcard4android' + include ':MemorizingTrustManager' diff --git a/vcard4android b/vcard4android index 384de9ec..53c1695d 160000 --- a/vcard4android +++ b/vcard4android @@ -1 +1 @@ -Subproject commit 384de9ec6eab1ac36d875330599b2858ce6ba888 +Subproject commit 53c1695db02cc371369e05cb02a0f1e537ac9eec