Commit de7ca7b9 authored by Ricki Hirner's avatar Ricki Hirner

Basic implementation of calendar sync. with common SyncManager

parent fa4f090f
......@@ -85,6 +85,8 @@ build/
# Ignore Gradle GUI config
gradle-app.setting
### external libs ###
.svn
# Javadoc
javadoc/
......@@ -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
......@@ -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')
......
......@@ -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);
......
......@@ -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();
......
/*
* 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];
}
}
}
/*
* 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;
}
......@@ -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);
}
......
/*
* 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];
}
}
}
/*
* 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;
}
......@@ -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;
}
}
......
/*
* 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<HttpUrl> 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