Remove legacy calendar/task/WebDAV code

pull/2/head
Ricki Hirner 9 years ago
parent c2e9b27831
commit d348f54deb

@ -50,27 +50,9 @@ configurations.all {
}
dependencies {
// Apache Commons
compile 'org.apache.commons:commons-lang3:3.4'
compile 'commons-io:commons-io:2.4'
// Lombok for useful @helpers
provided 'org.projectlombok:lombok:1.16.6'
// ical4j for parsing/generating iCalendars
compile('org.mnode.ical4j:ical4j:2.0-beta1') { // update ICAL_PRODID in Constants too!
// we don't need content builders, see https://github.com/ical4j/ical4j/wiki/Groovy
exclude group: 'org.codehaus.groovy', module: 'groovy-all'
}
compile('org.slf4j:slf4j-android:1.7.12') // slf4j is used by ical4j
// dnsjava for querying SRV/TXT records
compile 'dnsjava:dnsjava:2.1.7'
// HttpClient 4.3, Android flavour for WebDAV operations
compile 'org.apache.httpcomponents:httpclient-android:4.3.5.1'
//compile project(':lib:httpclient-android')
// SimpleXML for parsing and generating WebDAV messages
compile('org.simpleframework:simple-xml:2.7.1') {
exclude group: 'stax', module: 'stax-api'
exclude group: 'xpp3', module: 'xpp3'
}
provided 'org.projectlombok:lombok:1.16.6'
compile('org.slf4j:slf4j-android:1.7.12')
compile project(':dav4android')
compile project(':vcard4android')

@ -7,8 +7,6 @@
*/
package at.bitfire.davdroid;
import net.fortuna.ical4j.model.property.ProdId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -19,7 +17,7 @@ public class Constants {
WEB_URL_HELP = "https://davdroid.bitfire.at/configuration?pk_campaign=davdroid-app",
WEB_URL_VIEW_LOGS = "https://github.com/bitfireAT/davdroid/wiki/How-to-view-the-logs";
public static final ProdId ICAL_PRODID = new ProdId("-//bitfire web engineering//DAVdroid " + BuildConfig.VERSION_CODE + " (ical4j 2.0-beta1)//EN");
//public static final ProdId ICAL_PRODID = new ProdId("-//bitfire web engineering//DAVdroid " + BuildConfig.VERSION_CODE + " (ical4j 2.0-beta1)//EN");
public static final Logger log = LoggerFactory.getLogger("davdroid");
}

@ -1,166 +0,0 @@
/*
* 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;
import android.util.Log;
import net.fortuna.ical4j.model.Date;
import net.fortuna.ical4j.model.DateList;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.TimeZone;
import net.fortuna.ical4j.model.TimeZoneRegistry;
import net.fortuna.ical4j.model.TimeZoneRegistryFactory;
import net.fortuna.ical4j.model.parameter.Value;
import net.fortuna.ical4j.model.property.DateListProperty;
import org.apache.commons.lang3.StringUtils;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.LinkedList;
import java.util.List;
import java.util.SimpleTimeZone;
public class DateUtils {
private final static String TAG = "davdroid.DateUtils";
public static final TimeZoneRegistry tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry();
static {
// disable automatic time-zone updates (causes unwanted network traffic)
System.setProperty("net.fortuna.ical4j.timezone.update.enabled", "false");
}
// time zones
public static String findAndroidTimezoneID(String tz) {
String deviceTZ = null;
String availableTZs[] = SimpleTimeZone.getAvailableIDs();
// first, try to find an exact match (case insensitive)
for (String availableTZ : availableTZs)
if (availableTZ.equalsIgnoreCase(tz)) {
deviceTZ = availableTZ;
break;
}
// if that doesn't work, try to find something else that matches
if (deviceTZ == null) {
Log.w(TAG, "Coulnd't find time zone with matching identifiers, trying to guess");
for (String availableTZ : availableTZs)
if (StringUtils.indexOfIgnoreCase(tz, availableTZ) != -1) {
deviceTZ = availableTZ;
break;
}
}
// if that doesn't work, use UTC as fallback
if (deviceTZ == null) {
final String defaultTZ = TimeZone.getDefault().getID();
Log.e(TAG, "Couldn't identify time zone, using system default (" + defaultTZ + ") as fallback");
deviceTZ = defaultTZ;
}
return deviceTZ;
}
// recurrence sets
/**
* Concatenates, if necessary, multiple RDATE/EXDATE lists and converts them to
* a formatted string which Android calendar provider can process.
* Android expects this format: "[TZID;]date1,date2,date3" where date is "yyyymmddThhmmss" (when
* TZID is given) or "yyyymmddThhmmssZ". We don't use the TZID format here because then we're limited
* to one time-zone, while an iCalendar may contain multiple EXDATE/RDATE lines with different time zones.
* @param dates one more more lists of RDATE or EXDATE
* @param allDay indicates whether the event is an all-day event or not
* @return formatted string for Android calendar provider:
* - in case of all-day events, all dates/times are returned as yyyymmddT000000Z
* - in case of timed events, all dates/times are returned as UTC time: yyyymmddThhmmssZ
*/
public static String recurrenceSetsToAndroidString(List<? extends DateListProperty> dates, boolean allDay) throws ParseException {
List<String> strDates = new LinkedList<>();
/* rdate/exdate: DATE DATE_TIME
all-day store as ...T000000Z cut off time and store as ...T000000Z
event with time (ignored) store as ...ThhmmssZ
*/
final DateFormat dateFormatUtcMidnight = new SimpleDateFormat("yyyyMMdd'T'000000'Z'");
for (DateListProperty dateListProp : dates) {
final Value type = dateListProp.getDates().getType();
if (Value.DATE_TIME.equals(type)) { // DATE-TIME values will be stored in UTC format for Android
if (allDay) {
DateList dateList = dateListProp.getDates();
for (Date date : dateList)
strDates.add(dateFormatUtcMidnight.format(date));
} else {
dateListProp.setUtc(true);
strDates.add(dateListProp.getValue());
}
} else if (Value.DATE.equals(type)) // DATE values have to be converted to DATE-TIME <date>T000000Z for Android
for (Date date : dateListProp.getDates())
strDates.add(dateFormatUtcMidnight.format(date));
}
return StringUtils.join(strDates, ",");
}
/**
* Takes a formatted string as provided by the Android calendar provider and returns a DateListProperty
* constructed from these values.
* @param dbStr formatted string from Android calendar provider (RDATE/EXDATE field)
* expected format: "[TZID;]date1,date2,date3" where date is "yyyymmddThhmmss[Z]"
* @param type subclass of DateListProperty, e.g. RDate or ExDate
* @param allDay true: list will contain DATE values; false: list will contain DATE_TIME values
* @return instance of "type" containing the parsed dates/times from the string
*/
public static DateListProperty androidStringToRecurrenceSet(String dbStr, Class<? extends DateListProperty> type, boolean allDay) throws ParseException {
// 1. split string into time zone and actual dates
TimeZone timeZone;
String datesStr;
final int limiter = dbStr.indexOf(';');
if (limiter != -1) { // TZID given
timeZone = DateUtils.tzRegistry.getTimeZone(dbStr.substring(0, limiter));
datesStr = dbStr.substring(limiter + 1);
} else {
timeZone = null;
datesStr = dbStr;
}
// 2. process date string and generate list of DATEs or DATE-TIMEs
DateList dateList;
if (allDay) {
dateList = new DateList(Value.DATE);
for (String s: StringUtils.split(datesStr, ','))
dateList.add(new Date(new DateTime(s)));
} else {
dateList = new DateList(datesStr, Value.DATE_TIME, timeZone);
if (timeZone == null)
dateList.setUtc(true);
}
// 3. generate requested DateListProperty (RDate/ExDate) from list of DATEs or DATE-TIMEs
DateListProperty list;
try {
list = (DateListProperty)type.getDeclaredConstructor(new Class[] { DateList.class } ).newInstance(dateList);
if (dateList.getTimeZone() != null)
list.setTimeZone(dateList.getTimeZone());
} catch (Exception e) {
throw new ParseException("Couldn't create date/time list by reflection", -1);
}
return list;
}
}

@ -1,80 +0,0 @@
/*
* 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;
import android.util.Log;
import java.net.URI;
import java.net.URISyntaxException;
public class URIUtils {
private static final String TAG = "davdroid.URIUtils";
public static String ensureTrailingSlash(String href) {
if (!href.endsWith("/")) {
Log.d(TAG, "Implicitly appending trailing slash to collection " + href);
return href + "/";
} else
return href;
}
public static URI ensureTrailingSlash(URI href) {
if (!href.getPath().endsWith("/")) {
try {
URI newURI = new URI(href.getScheme(), href.getAuthority(), href.getPath() + "/", null, null);
Log.d(TAG, "Appended trailing slash to collection " + href + " -> " + newURI);
href = newURI;
} catch (URISyntaxException e) {
}
}
return href;
}
/**
* Parse a received absolute/relative URL and generate a normalized URI that can be compared.
* @param original URI to be parsed, may be absolute or relative. Encoded characters will be decoded!
* @param mustBePath true if it's known that original is a path (may contain ":") and not an URI, i.e. ":" is not the scheme separator
* @return normalized URI
* @throws URISyntaxException
*/
public static URI parseURI(String original, boolean mustBePath) throws URISyntaxException {
if (mustBePath) {
// may contain ":"
// case 1: "my:file" won't be parsed by URI correctly because it would consider "my" as URI scheme
// case 2: "path/my:file" will be parsed by URI correctly
// case 3: "my:path/file" won't be parsed by URI correctly because it would consider "my" as URI scheme
int idxSlash = original.indexOf('/'),
idxColon = original.indexOf(':');
if (idxColon != -1) {
// colon present
if ((idxSlash != -1) && idxSlash < idxColon) // There's a slash, and it's before the colon → everything OK
;
else // No slash before the colon; we have to put it there
original = "./" + original;
}
}
// escape some common invalid characters servers keep sending unescaped crap like "my calendar.ics" or "{guid}.vcf"
// this is only a hack, because for instance, "[" may be valid in URLs (IPv6 literal in host name)
String repaired = original
.replaceAll(" ", "%20")
.replaceAll("\\{", "%7B")
.replaceAll("\\}", "%7D");
if (!repaired.equals(original))
Log.w(TAG, "Repaired invalid URL: " + original + " -> " + repaired);
URI uri = new URI(repaired);
URI normalized = new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), uri.getQuery(), uri.getFragment());
Log.v(TAG, "Normalized URI " + original + " -> " + normalized.toASCIIString() + " assuming that it was " +
(mustBePath ? "a path name" : "an URI or path name"));
return normalized;
}
}

@ -1,78 +0,0 @@
/*
* 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.util.Log;
import org.apache.http.impl.client.CloseableHttpClient;
import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import java.io.StringWriter;
import java.net.URISyntaxException;
import at.bitfire.davdroid.webdav.DavCalendarQuery;
import at.bitfire.davdroid.webdav.DavCompFilter;
import at.bitfire.davdroid.webdav.DavFilter;
import at.bitfire.davdroid.webdav.DavMultiget;
import at.bitfire.davdroid.webdav.DavProp;
public class CalDavCalendar extends WebDavCollection<Event> {
private final static String TAG = "davdroid.CalDAVCalendar";
public CalDavCalendar(CloseableHttpClient httpClient, String baseURL, String user, String password, boolean preemptiveAuth) throws URISyntaxException {
super(httpClient, baseURL, user, password, preemptiveAuth);
}
@Override
protected String memberAcceptedMimeTypes()
{
return "text/calendar";
}
@Override
protected DavMultiget.Type multiGetType() {
return DavMultiget.Type.CALENDAR;
}
@Override
protected Event newResourceSkeleton(String name, String ETag) {
return new Event(name, ETag);
}
@Override
public String getMemberETagsQuery() {
DavCalendarQuery query = new DavCalendarQuery();
// prop
DavProp prop = new DavProp();
prop.setGetetag(new DavProp.GetETag());
query.setProp(prop);
// filter
DavFilter filter = new DavFilter();
query.setFilter(filter);
DavCompFilter compFilter = new DavCompFilter("VCALENDAR");
filter.setCompFilter(compFilter);
compFilter.setCompFilter(new DavCompFilter("VEVENT"));
Serializer serializer = new Persister();
StringWriter writer = new StringWriter();
try {
serializer.write(query, writer);
} catch (Exception e) {
Log.e(TAG, "Couldn't prepare REPORT query", e);
return null;
}
return writer.toString();
}
}

@ -1,80 +0,0 @@
/*
* 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.util.Log;
import org.apache.http.impl.client.CloseableHttpClient;
import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import java.io.StringWriter;
import java.net.URISyntaxException;
import at.bitfire.davdroid.webdav.DavCalendarQuery;
import at.bitfire.davdroid.webdav.DavCompFilter;
import at.bitfire.davdroid.webdav.DavFilter;
import at.bitfire.davdroid.webdav.DavMultiget;
import at.bitfire.davdroid.webdav.DavProp;
public class CalDavTaskList extends WebDavCollection<Task> {
private final static String TAG = "davdroid.CalDAVTaskList";
public CalDavTaskList(CloseableHttpClient httpClient, String baseURL, String user, String password, boolean preemptiveAuth) throws URISyntaxException {
super(httpClient, baseURL, user, password, preemptiveAuth);
}
@Override
protected String memberAcceptedMimeTypes()
{
return "text/calendar";
}
@Override
protected DavMultiget.Type multiGetType() {
return DavMultiget.Type.CALENDAR;
}
@Override
protected Task newResourceSkeleton(String name, String ETag) {
return new Task(name, ETag);
}
@Override
public String getMemberETagsQuery() {
DavCalendarQuery query = new DavCalendarQuery();
// prop
DavProp prop = new DavProp();
prop.setGetetag(new DavProp.GetETag());
query.setProp(prop);
// filter
DavFilter filter = new DavFilter();
query.setFilter(filter);
DavCompFilter compFilter = new DavCompFilter("VCALENDAR");
filter.setCompFilter(compFilter);
compFilter.setCompFilter(new DavCompFilter("VTODO"));
Serializer serializer = new Persister();
StringWriter writer = new StringWriter();
try {
serializer.write(query, writer);
} catch (Exception e) {
Log.e(TAG, "Couldn't prepare REPORT query", e);
return null;
}
return writer.toString();
}
}

@ -1,353 +0,0 @@
/*
* 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.text.format.Time;
import android.util.Log;
import net.fortuna.ical4j.data.CalendarOutputter;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.ComponentList;
import net.fortuna.ical4j.model.Date;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.Property;
import net.fortuna.ical4j.model.PropertyList;
import net.fortuna.ical4j.model.ValidationException;
import net.fortuna.ical4j.model.component.VAlarm;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.property.Attendee;
import net.fortuna.ical4j.model.property.Clazz;
import net.fortuna.ical4j.model.property.DateProperty;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.DtEnd;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.Duration;
import net.fortuna.ical4j.model.property.ExDate;
import net.fortuna.ical4j.model.property.ExRule;
import net.fortuna.ical4j.model.property.Location;
import net.fortuna.ical4j.model.property.Organizer;
import net.fortuna.ical4j.model.property.RDate;
import net.fortuna.ical4j.model.property.RRule;
import net.fortuna.ical4j.model.property.RecurrenceId;
import net.fortuna.ical4j.model.property.Status;
import net.fortuna.ical4j.model.property.Summary;
import net.fortuna.ical4j.model.property.Transp;
import net.fortuna.ical4j.model.property.Uid;
import net.fortuna.ical4j.model.property.Version;
import net.fortuna.ical4j.util.TimeZones;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.util.Calendar;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.TimeZone;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.DateUtils;
import lombok.Cleanup;
import lombok.Getter;
import lombok.NonNull;
public class Event extends iCalendar {
private final static String TAG = "davdroid.Event";
protected RecurrenceId recurrenceId;
protected String summary, location, description;
protected DtStart dtStart;
protected DtEnd dtEnd;
// lists must not be set to null (because they're iterated using "for"), so only getters are exposed
protected Duration duration;
@Getter private List<RDate> rdates = new LinkedList<>();
protected RRule rrule;
@Getter private List<ExDate> exdates = new LinkedList<>();
protected ExRule exrule;
@Getter private List<Event> exceptions = new LinkedList<>();
protected Boolean forPublic;
protected Status status;
protected boolean opaque;
protected Organizer organizer;
@Getter private List<Attendee> attendees = new LinkedList<>();
@Getter private List<VAlarm> alarms = new LinkedList<>();
public Event(String name, String ETag) {
super(name, ETag);
}
public Event(long localID, String name, String ETag) {
super(localID, name, ETag);
}
@Override
@SuppressWarnings("unchecked")
public void parseEntity(@NonNull InputStream entity, Charset charset, AssetDownloader downloader) throws IOException, InvalidResourceException {
final net.fortuna.ical4j.model.Calendar ical;
try {
if (charset != null) {
@Cleanup InputStreamReader reader = new InputStreamReader(entity, charset);
ical = calendarBuilder.build(reader);
} else
ical = calendarBuilder.build(entity);
if (ical == null)
throw new InvalidResourceException("No iCalendar found");
} catch (ParserException e) {
throw new InvalidResourceException(e);
}
ComponentList events = ical.getComponents(Component.VEVENT);
if (events == null || events.isEmpty())
throw new InvalidResourceException("No VEVENT found");
// find master VEVENT (the one that is not an exception, i.e. the one without RECURRENCE-ID)
VEvent master = null;
for (VEvent event : (Iterable<VEvent>)events)
if (event.getRecurrenceId() == null) {
master = event;
break;
}
if (master == null)
throw new InvalidResourceException("No VEVENT without RECURRENCE-ID found");
// set event data from master VEVENT
fromVEvent(master);
// find and process exceptions
for (VEvent event : (Iterable<VEvent>)events)
if (event.getRecurrenceId() != null) {
Event exception = new Event(name, null);
exception.fromVEvent(event);
exceptions.add(exception);
}
}
protected void fromVEvent(VEvent event) throws InvalidResourceException {
if (event.getUid() != null)
uid = event.getUid().getValue();
else {
Log.w(TAG, "Received VEVENT without UID, generating new one");
generateUID();
}
recurrenceId = event.getRecurrenceId();
if ((dtStart = event.getStartDate()) == null || (dtEnd = event.getEndDate()) == null)
throw new InvalidResourceException("Invalid start time/end time/duration");
validateTimeZone(dtStart);
validateTimeZone(dtEnd);
// all-day events and "events on that day":
// * related UNIX times must be in UTC
// * must have a duration (set to one day if missing)
if (!isDateTime(dtStart) && !dtEnd.getDate().after(dtStart.getDate())) {
Log.i(TAG, "Repairing iCal: DTEND := DTSTART+1");
Calendar c = Calendar.getInstance(TimeZone.getTimeZone(Time.TIMEZONE_UTC));
c.setTime(dtStart.getDate());
c.add(Calendar.DATE, 1);
dtEnd.setDate(new Date(c.getTimeInMillis()));
}
rrule = (RRule)event.getProperty(Property.RRULE);
for (RDate rdate : (List<RDate>)(List<?>)event.getProperties(Property.RDATE))
rdates.add(rdate);
exrule = (ExRule)event.getProperty(Property.EXRULE);
for (ExDate exdate : (List<ExDate>)(List<?>)event.getProperties(Property.EXDATE))
exdates.add(exdate);
if (event.getSummary() != null)
summary = event.getSummary().getValue();
if (event.getLocation() != null)
location = event.getLocation().getValue();
if (event.getDescription() != null)
description = event.getDescription().getValue();
status = event.getStatus();
opaque = event.getTransparency() != Transp.TRANSPARENT;
organizer = event.getOrganizer();
for (Attendee attendee : (List<Attendee>)(List<?>)event.getProperties(Property.ATTENDEE))
attendees.add(attendee);
Clazz classification = event.getClassification();
if (classification != null) {
if (classification == Clazz.PUBLIC)
forPublic = true;
else if (classification == Clazz.CONFIDENTIAL || classification == Clazz.PRIVATE)
forPublic = false;
}
this.alarms = event.getAlarms();
}
@Override
@SuppressWarnings("unchecked")
public ByteArrayOutputStream toEntity() throws IOException {
net.fortuna.ical4j.model.Calendar ical = new net.fortuna.ical4j.model.Calendar();
ical.getProperties().add(Version.VERSION_2_0);
ical.getProperties().add(Constants.ICAL_PRODID);
// "master event" (without exceptions)
ComponentList components = ical.getComponents();
VEvent master = toVEvent(new Uid(uid));
components.add(master);
// remember used time zones
Set<net.fortuna.ical4j.model.TimeZone> usedTimeZones = new HashSet<>();
if (dtStart != null && dtStart.getTimeZone() != null)
usedTimeZones.add(dtStart.getTimeZone());
if (dtEnd != null && dtEnd.getTimeZone() != null)
usedTimeZones.add(dtEnd.getTimeZone());
// recurrence exceptions
for (Event exception : exceptions) {
// create VEVENT for exception
VEvent vException = exception.toVEvent(master.getUid());
components.add(vException);
// remember used time zones
if (exception.dtStart != null && exception.dtStart.getTimeZone() != null)
usedTimeZones.add(exception.dtStart.getTimeZone());
if (exception.dtEnd != null && exception.dtEnd.getTimeZone() != null)
usedTimeZones.add(exception.dtEnd.getTimeZone());
}
// add VTIMEZONE components
for (net.fortuna.ical4j.model.TimeZone timeZone : usedTimeZones)
ical.getComponents().add(timeZone.getVTimeZone());
CalendarOutputter output = new CalendarOutputter(false);
ByteArrayOutputStream os = new ByteArrayOutputStream();
try {
output.output(ical, os);
} catch (ValidationException e) {
Log.e(TAG, "Generated invalid iCalendar");
}
return os;
}
protected VEvent toVEvent(Uid uid) {
VEvent event = new VEvent();
PropertyList props = event.getProperties();
if (uid != null)
props.add(uid);
if (recurrenceId != null)
props.add(recurrenceId);
props.add(dtStart);
if (dtEnd != null)
props.add(dtEnd);
if (duration != null)
props.add(duration);
if (rrule != null)
props.add(rrule);
for (RDate rdate : rdates)
props.add(rdate);
if (exrule != null)
props.add(exrule);
for (ExDate exdate : exdates)
props.add(exdate);
if (summary != null && !summary.isEmpty())
props.add(new Summary(summary));
if (location != null && !location.isEmpty())
props.add(new Location(location));
if (description != null && !description.isEmpty())
props.add(new Description(description));
if (status != null)
props.add(status);
if (!opaque)
props.add(Transp.TRANSPARENT);
if (organizer != null)
props.add(organizer);
props.addAll(attendees);
if (forPublic != null)
event.getProperties().add(forPublic ? Clazz.PUBLIC : Clazz.PRIVATE);
event.getAlarms().addAll(alarms);
return event;
}
// time helpers
/**
* Returns the time-zone ID for a given date-time, or TIMEZONE_UTC for dates (without time).
* TIMEZONE_UTC is also returned for DATE-TIMEs in UTC representation.
* @param date DateProperty (DATE or DATE-TIME) whose time-zone information is used
*/
protected static String getTzId(DateProperty date) {
if (isDateTime(date) && !date.isUtc() && date.getTimeZone() != null)
return date.getTimeZone().getID();
else
return TimeZones.UTC_ID;
}
public boolean isAllDay() {
return !isDateTime(dtStart);
}
public long getDtStartInMillis() {
return dtStart.getDate().getTime();
}
public String getDtStartTzID() {
return getTzId(dtStart);
}
public void setDtStart(long tsStart, String tzID) {
if (tzID == null) { // all-day
dtStart = new DtStart(new Date(tsStart));
} else {
DateTime start = new DateTime(tsStart);
start.setTimeZone(DateUtils.tzRegistry.getTimeZone(tzID));
dtStart = new DtStart(start);
}
}
public long getDtEndInMillis() {
return dtEnd.getDate().getTime();
}
public String getDtEndTzID() {
return getTzId(dtEnd);
}
public void setDtEnd(long tsEnd, String tzID) {
if (tzID == null) { // all-day
dtEnd = new DtEnd(new Date(tsEnd));
} else {
DateTime end = new DateTime(tsEnd);
end.setTimeZone(DateUtils.tzRegistry.getTimeZone(tzID));
dtEnd = new DtEnd(end);
}
}
}

@ -1,786 +0,0 @@
/*
* 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.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentProviderOperation.Builder;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Entity;
import android.content.EntityIterator;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.net.Uri;
import android.os.Build;
import android.os.RemoteException;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Attendees;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.Reminders;
import android.util.Log;
import net.fortuna.ical4j.model.Date;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.Dur;
import net.fortuna.ical4j.model.Parameter;
import net.fortuna.ical4j.model.ParameterList;
import net.fortuna.ical4j.model.PropertyList;
import net.fortuna.ical4j.model.component.VAlarm;
import net.fortuna.ical4j.model.parameter.Cn;
import net.fortuna.ical4j.model.parameter.CuType;
import net.fortuna.ical4j.model.parameter.PartStat;
import net.fortuna.ical4j.model.parameter.Role;
import net.fortuna.ical4j.model.parameter.Rsvp;
import net.fortuna.ical4j.model.property.Action;
import net.fortuna.ical4j.model.property.Attendee;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.Duration;
import net.fortuna.ical4j.model.property.ExDate;
import net.fortuna.ical4j.model.property.ExRule;
import net.fortuna.ical4j.model.property.Organizer;
import net.fortuna.ical4j.model.property.RDate;
import net.fortuna.ical4j.model.property.RRule;
import net.fortuna.ical4j.model.property.RecurrenceId;
import net.fortuna.ical4j.model.property.Status;
import org.apache.commons.lang3.StringUtils;
import java.net.URI;
import java.net.URISyntaxException;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.LinkedList;
import java.util.List;
import at.bitfire.davdroid.DavUtils;
import at.bitfire.davdroid.DateUtils;
import at.bitfire.davdroid.webdav.WebDavResource;
import lombok.Cleanup;
import lombok.Getter;
/**
* Represents a locally stored calendar, containing Events.
* Communicates with the Android Contacts Provider which uses an SQLite
* database to store the contacts.
*/
public class LocalCalendar extends LocalCollection<Event> {
private static final String TAG = "davdroid.LocalCalendar";
@Getter protected String url;
@Getter protected long id;
protected static final String COLLECTION_COLUMN_CTAG = Calendars.CAL_SYNC1;
/* database fields */
@Override protected Uri entriesURI() { return syncAdapterURI(Events.CONTENT_URI); }
@Override protected String entryColumnAccountType() { return Events.ACCOUNT_TYPE; }
@Override protected String entryColumnAccountName() { return Events.ACCOUNT_NAME; }
@Override protected String entryColumnParentID() { return Events.CALENDAR_ID; }
@Override protected String entryColumnID() { return Events._ID; }
@Override protected String entryColumnRemoteName() { return Events._SYNC_ID; }
@Override protected String entryColumnETag() { return Events.SYNC_DATA1; }
@Override protected String entryColumnDirty() { return Events.DIRTY; }
@Override protected String entryColumnDeleted() { return Events.DELETED; }
@Override
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
protected String entryColumnUID() {
return (android.os.Build.VERSION.SDK_INT >= 17) ?
Events.UID_2445 : Events.SYNC_DATA2;
}
/* class methods, constructor */
@SuppressLint("InlinedApi")
public static Uri create(Account account, ContentResolver resolver, ServerInfo.ResourceInfo info) throws LocalStorageException {
@Cleanup("release") final ContentProviderClient client = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY);
if (client == null)
throw new LocalStorageException("No Calendar Provider found (Calendar app disabled?)");
ContentValues values = new ContentValues();
values.put(Calendars.ACCOUNT_NAME, account.name);
values.put(Calendars.ACCOUNT_TYPE, account.type);
values.put(Calendars.NAME, info.getURL());
values.put(Calendars.CALENDAR_DISPLAY_NAME, info.getTitle());
values.put(Calendars.CALENDAR_COLOR, info.getColor() != null ? info.getColor() : DavUtils.calendarGreen);
values.put(Calendars.OWNER_ACCOUNT, account.name);
values.put(Calendars.SYNC_EVENTS, 1);
values.put(Calendars.VISIBLE, 1);
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT);
if (info.isReadOnly())
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ);
else {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER);
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1);
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1);
}
if (android.os.Build.VERSION.SDK_INT >= 15) {
values.put(Calendars.ALLOWED_AVAILABILITY, Events.AVAILABILITY_BUSY + "," + Events.AVAILABILITY_FREE + "," + Events.AVAILABILITY_TENTATIVE);
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, Attendees.TYPE_NONE + "," + Attendees.TYPE_OPTIONAL + "," + Attendees.TYPE_REQUIRED + "," + Attendees.TYPE_RESOURCE);
}
if (info.getTimezone() != null)
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(info.getTimezone()));
Log.i(TAG, "Inserting calendar: " + values.toString());
try {
return client.insert(calendarsURI(account), values);
} catch (RemoteException e) {
throw new LocalStorageException(e);
}
}
public static LocalCalendar[] findAll(Account account, ContentProviderClient providerClient) throws RemoteException {
@Cleanup Cursor cursor = providerClient.query(calendarsURI(account),
new String[] { Calendars._ID, Calendars.NAME },
Calendars.DELETED + "=0 AND " + Calendars.SYNC_EVENTS + "=1", null, null);
LinkedList<LocalCalendar> calendars = new LinkedList<>();
while (cursor != null && cursor.moveToNext())
calendars.add(new LocalCalendar(account, providerClient, cursor.getInt(0), cursor.getString(1)));
return calendars.toArray(new LocalCalendar[calendars.size()]);
}
public LocalCalendar(Account account, ContentProviderClient providerClient, long id, String url) {
super(account, providerClient);
this.id = id;
this.url = url;
sqlFilter = "ORIGINAL_ID IS NULL";
}
/* collection operations */
@Override
public String getCTag() throws LocalStorageException {
try {
@Cleanup Cursor c = providerClient.query(ContentUris.withAppendedId(calendarsURI(), id),
new String[] { COLLECTION_COLUMN_CTAG }, null, null, null);
if (c != null && c.moveToFirst())
return c.getString(0);
else
throw new LocalStorageException("Couldn't query calendar CTag");
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
@Override
public void setCTag(String cTag) throws LocalStorageException {
ContentValues values = new ContentValues(1);
values.put(COLLECTION_COLUMN_CTAG, cTag);
try {
providerClient.update(ContentUris.withAppendedId(calendarsURI(), id), values, null, null);
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
@Override
public void updateMetaData(WebDavResource.Properties properties) throws LocalStorageException {
ContentValues values = new ContentValues();
final String displayName = properties.getDisplayName();
if (displayName != null)
values.put(Calendars.CALENDAR_DISPLAY_NAME, displayName);
final Integer color = properties.getColor();
if (color != null)
values.put(Calendars.CALENDAR_COLOR, color);
try {
if (values.size() > 0)
providerClient.update(ContentUris.withAppendedId(calendarsURI(), id), values, null, null);
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
@Override
public long[] findUpdated() throws LocalStorageException {
// mark (recurring) events with changed/deleted exceptions as dirty
String where = entryColumnID() + " IN (SELECT DISTINCT " + Events.ORIGINAL_ID + " FROM events WHERE " +
Events.ORIGINAL_ID + " IS NOT NULL AND (" + Events.DIRTY + "=1 OR " + Events.DELETED + "=1))";
ContentValues dirty = new ContentValues(1);
dirty.put(CalendarContract.Events.DIRTY, 1);
try {
int rows = providerClient.update(entriesURI(), dirty, where, null);
if (rows > 0)
Log.d(TAG, rows + " event(s) marked as dirty because of dirty/deleted exceptions");
} catch (RemoteException e) {
Log.e(TAG, "Couldn't mark events with updated exceptions as dirty", e);
}
// new find and return updated (master) events
return super.findUpdated();
}
/* create/update/delete */
public Event newResource(long localID, String resourceName, String eTag) {
return new Event(localID, resourceName, eTag);
}
public int deleteAllExceptRemoteNames(Resource[] remoteResources) throws LocalStorageException {
List<String> sqlFileNames = new LinkedList<>();
for (Resource res : remoteResources)
sqlFileNames.add(DatabaseUtils.sqlEscapeString(res.getName()));
// delete master events
String where = entryColumnParentID() + "=?";
where += sqlFileNames.isEmpty() ?
" AND " + entryColumnRemoteName() + " IS NOT NULL" : // don't retain anything (delete all)
" AND " + entryColumnRemoteName() + " NOT IN (" + StringUtils.join(sqlFileNames, ",") + ")"; // retain by remote file name
if (sqlFilter != null)
where += " AND (" + sqlFilter + ")";
pendingOperations.add(ContentProviderOperation.newDelete(entriesURI())
.withSelection(where, new String[] { String.valueOf(id) })
.build());
// delete exceptions, too
where = entryColumnParentID() + "=?";
where += sqlFileNames.isEmpty() ?
" AND " + Events.ORIGINAL_SYNC_ID + " IS NOT NULL" : // don't retain anything (delete all)
" AND " + Events.ORIGINAL_SYNC_ID + " NOT IN (" + StringUtils.join(sqlFileNames, ",") + ")"; // retain by remote file name
pendingOperations.add(ContentProviderOperation
.newDelete(entriesURI())
.withSelection(where, new String[]{String.valueOf(id)})
.withYieldAllowed(true)
.build()
);
return commit();
}
@Override
public void delete(Resource resource) {
super.delete(resource);
// delete all exceptions of this event, too
pendingOperations.add(ContentProviderOperation
.newDelete(entriesURI())
.withSelection(Events.ORIGINAL_ID + "=?", new String[] { String.valueOf(resource.getLocalID()) })
.build()
);
}
@Override
public void clearDirty(Resource resource) {
super.clearDirty(resource);
// clear dirty flag of all exceptions of this event
pendingOperations.add(ContentProviderOperation
.newUpdate(entriesURI())
.withValue(Events.DIRTY, 0)
.withSelection(Events.ORIGINAL_ID + "=?", new String[]{String.valueOf(resource.getLocalID())})
.build()
);
}
/* methods for populating the data object from the content provider */
@Override
public void populate(Resource resource) throws LocalStorageException {
Event event = (Event)resource;
try {
@Cleanup EntityIterator iterEvents = CalendarContract.EventsEntity.newEntityIterator(
providerClient.query(
syncAdapterURI(CalendarContract.EventsEntity.CONTENT_URI),
null, Events._ID + "=" + event.getLocalID(),
null, null),
providerClient
);
while (iterEvents.hasNext()) {
Entity e = iterEvents.next();
ContentValues values = e.getEntityValues();
populateEvent(event, values);
List<Entity.NamedContentValues> subValues = e.getSubValues();
for (Entity.NamedContentValues subValue : subValues) {
values = subValue.values;
if (Attendees.CONTENT_URI.equals(subValue.uri))
populateAttendee(event, values);
if (Reminders.CONTENT_URI.equals(subValue.uri))
populateReminder(event, values);
}
populateExceptions(event);
}
} catch (RemoteException ex) {
throw new LocalStorageException("Couldn't process locally stored event", ex);
}
}
protected void populateEvent(Event e, ContentValues values) {
e.setUid(values.getAsString(entryColumnUID()));
e.summary = values.getAsString(Events.TITLE);
e.location = values.getAsString(Events.EVENT_LOCATION);
e.description = values.getAsString(Events.DESCRIPTION);
final boolean allDay = values.getAsInteger(Events.ALL_DAY) != 0;
final long tsStart = values.getAsLong(Events.DTSTART);
final String duration = values.getAsString(Events.DURATION);
String tzId;
Long tsEnd = values.getAsLong(Events.DTEND);
if (allDay) {
e.setDtStart(tsStart, null);
if (tsEnd == null) {
Dur dur = new Dur(duration);
java.util.Date dEnd = dur.getTime(new java.util.Date(tsStart));
tsEnd = dEnd.getTime();
}
e.setDtEnd(tsEnd, null);
} else {
// use the start time zone for the end time, too
// because apps like Samsung Planner allow the user to change "the" time zone but change the start time zone only
tzId = values.getAsString(Events.EVENT_TIMEZONE);
e.setDtStart(tsStart, tzId);
if (tsEnd != null)
e.setDtEnd(tsEnd, tzId);
else if (!StringUtils.isEmpty(duration))
e.duration = new Duration(new Dur(duration));
}
// recurrence
try {
String strRRule = values.getAsString(Events.RRULE);
if (!StringUtils.isEmpty(strRRule))
e.rrule = new RRule(strRRule);
String strRDate = values.getAsString(Events.RDATE);
if (!StringUtils.isEmpty(strRDate)) {
RDate rDate = (RDate)DateUtils.androidStringToRecurrenceSet(strRDate, RDate.class, allDay);
e.getRdates().add(rDate);
}
String strExRule = values.getAsString(Events.EXRULE);
if (!StringUtils.isEmpty(strExRule)) {
ExRule exRule = new ExRule();
exRule.setValue(strExRule);
e.exrule = exRule;
}
String strExDate = values.getAsString(Events.EXDATE);
if (!StringUtils.isEmpty(strExDate)) {
ExDate exDate = (ExDate)DateUtils.androidStringToRecurrenceSet(strExDate, ExDate.class, allDay);
e.getExdates().add(exDate);
}
} catch (ParseException ex) {
Log.w(TAG, "Couldn't parse recurrence rules, ignoring", ex);
} catch (IllegalArgumentException ex) {
Log.w(TAG, "Invalid recurrence rules, ignoring", ex);
}
if (values.containsKey(Events.ORIGINAL_INSTANCE_TIME)) {
// this event is an exception of a recurring event
long originalInstanceTime = values.getAsLong(Events.ORIGINAL_INSTANCE_TIME);
boolean originalAllDay = false;
if (values.containsKey(Events.ORIGINAL_ALL_DAY))
originalAllDay = values.getAsInteger(Events.ORIGINAL_ALL_DAY) != 0;
Date originalDate = originalAllDay ?
new Date(originalInstanceTime) :
new DateTime(originalInstanceTime);
if (originalDate instanceof DateTime)
((DateTime)originalDate).setUtc(true);
e.recurrenceId = new RecurrenceId(originalDate);
}
// status
if (values.containsKey(Events.STATUS))
switch (values.getAsInteger(Events.STATUS)) {
case Events.STATUS_CONFIRMED:
e.status = Status.VEVENT_CONFIRMED;
break;
case Events.STATUS_TENTATIVE:
e.status = Status.VEVENT_TENTATIVE;
break;
case Events.STATUS_CANCELED:
e.status = Status.VEVENT_CANCELLED;
}
// availability
e.opaque = values.getAsInteger(Events.AVAILABILITY) != Events.AVAILABILITY_FREE;
// set ORGANIZER if there's attendee data
if (values.getAsInteger(Events.HAS_ATTENDEE_DATA) != 0)
try {
e.organizer = new Organizer(new URI("mailto", values.getAsString(Events.ORGANIZER), null));
} catch (URISyntaxException ex) {
Log.e(TAG, "Error when creating ORGANIZER mailto URI, ignoring", ex);
}
// classification
switch (values.getAsInteger(Events.ACCESS_LEVEL)) {
case Events.ACCESS_CONFIDENTIAL:
case Events.ACCESS_PRIVATE:
e.forPublic = false;
break;
case Events.ACCESS_PUBLIC:
e.forPublic = true;
}
}
void populateExceptions(Event e) throws RemoteException {
@Cleanup Cursor c = providerClient.query(syncAdapterURI(Events.CONTENT_URI),
new String[]{Events._ID, entryColumnRemoteName()},
Events.ORIGINAL_ID + "=?", new String[]{ String.valueOf(e.getLocalID()) }, null);
while (c != null && c.moveToNext()) {
long exceptionId = c.getLong(0);
String exceptionRemoteName = c.getString(1);
try {
Event exception = new Event(exceptionId, exceptionRemoteName, null);
populate(exception);
e.getExceptions().add(exception);
} catch (LocalStorageException ex) {
Log.e(TAG, "Couldn't find exception details, ignoring");
}
}
}
void populateAttendee(Event event, ContentValues values) {
try {
final Attendee attendee;
final String
email = values.getAsString(Attendees.ATTENDEE_EMAIL),
idNS = values.getAsString(Attendees.ATTENDEE_ID_NAMESPACE),
id = values.getAsString(Attendees.ATTENDEE_IDENTITY);
if (idNS != null || id != null) {
// attendee identified by namespace and ID
attendee = new Attendee(new URI(idNS, id, null));
if (email != null)
attendee.getParameters().add(new iCalendar.Email(email));
} else
// attendee identified by email address
attendee = new Attendee(new URI("mailto", email, null));
final ParameterList params = attendee.getParameters();
String cn = values.getAsString(Attendees.ATTENDEE_NAME);
if (cn != null)
params.add(new Cn(cn));
// type
int type = values.getAsInteger(Attendees.ATTENDEE_TYPE);
params.add((type == Attendees.TYPE_RESOURCE) ? CuType.RESOURCE : CuType.INDIVIDUAL);
// role
int relationship = values.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP);
switch (relationship) {
case Attendees.RELATIONSHIP_ORGANIZER:
case Attendees.RELATIONSHIP_ATTENDEE:
case Attendees.RELATIONSHIP_PERFORMER:
case Attendees.RELATIONSHIP_SPEAKER:
params.add((type == Attendees.TYPE_REQUIRED) ? Role.REQ_PARTICIPANT : Role.OPT_PARTICIPANT);
params.add(new Rsvp(true));
break;
case Attendees.RELATIONSHIP_NONE:
params.add(Role.NON_PARTICIPANT);
}
// status
switch (values.getAsInteger(Attendees.ATTENDEE_STATUS)) {
case Attendees.ATTENDEE_STATUS_INVITED:
params.add(PartStat.NEEDS_ACTION);
break;
case Attendees.ATTENDEE_STATUS_ACCEPTED:
params.add(PartStat.ACCEPTED);
break;
case Attendees.ATTENDEE_STATUS_DECLINED:
params.add(PartStat.DECLINED);
break;
case Attendees.ATTENDEE_STATUS_TENTATIVE:
params.add(PartStat.TENTATIVE);
break;
}
event.getAttendees().add(attendee);
} catch (URISyntaxException ex) {
Log.e(TAG, "Couldn't parse attendee information, ignoring", ex);
}
}
void populateReminder(Event event, ContentValues row) {
VAlarm alarm = new VAlarm(new Dur(0, 0, -row.getAsInteger(Reminders.MINUTES), 0));
PropertyList props = alarm.getProperties();
props.add(Action.DISPLAY);
props.add(new Description(event.summary));
event.getAlarms().add(alarm);
}
/* content builder methods */
@Override
protected Builder buildEntry(Builder builder, Resource resource, boolean update) {
final Event event = (Event)resource;
builder .withValue(Events.CALENDAR_ID, id)
.withValue(Events.ALL_DAY, event.isAllDay() ? 1 : 0)
.withValue(Events.DTSTART, event.getDtStartInMillis())
.withValue(Events.EVENT_TIMEZONE, event.getDtStartTzID())
.withValue(Events.HAS_ALARM, event.getAlarms().isEmpty() ? 0 : 1)
.withValue(Events.HAS_ATTENDEE_DATA, event.getAttendees().isEmpty() ? 0 : 1)
.withValue(Events.GUESTS_CAN_INVITE_OTHERS, 1)
.withValue(Events.GUESTS_CAN_MODIFY, 1)
.withValue(Events.GUESTS_CAN_SEE_GUESTS, 1);
if (event.recurrenceId == null) {
// this event is a "master event" (not an exception)
builder .withValue(entryColumnRemoteName(), event.getName())
.withValue(entryColumnETag(), event.getETag())
.withValue(entryColumnUID(), event.getUid());
} else {
// event is an exception
builder.withValue(Events.ORIGINAL_SYNC_ID, event.getName());
// ORIGINAL_INSTANCE_TIME and ORIGINAL_ALL_DAY is set in buildExceptions.
// It's not possible to use only the RECURRENCE-ID to calculate
// ORIGINAL_INSTANCE_TIME and ORIGINAL_ALL_DAY because iCloud sends DATE-TIME
// RECURRENCE-IDs even if the original event is an all-day event.
}
boolean recurring = false;
if (event.rrule != null) {
recurring = true;
builder.withValue(Events.RRULE, event.rrule.getValue());
}
if (!event.getRdates().isEmpty()) {
recurring = true;
try {
builder.withValue(Events.RDATE, DateUtils.recurrenceSetsToAndroidString(event.getRdates(), event.isAllDay()));
} catch (ParseException e) {
Log.e(TAG, "Couldn't parse RDate(s)", e);
}
}
if (event.exrule != null)
builder.withValue(Events.EXRULE, event.exrule.getValue());
if (!event.getExceptions().isEmpty())
try {
builder.withValue(Events.EXDATE, DateUtils.recurrenceSetsToAndroidString(event.getExdates(), event.isAllDay()));
} catch (ParseException e) {
Log.e(TAG, "Couldn't parse ExDate(s)", e);
}
// set either DTEND for single-time events or DURATION for recurring events
// because that's the way Android likes it (see docs)
if (recurring) {
// calculate DURATION from start and end date
Duration duration = new Duration(event.dtStart.getDate(), event.dtEnd.getDate());
builder.withValue(Events.DURATION, duration.getValue());
} else
builder .withValue(Events.DTEND, event.getDtEndInMillis())
.withValue(Events.EVENT_END_TIMEZONE, event.getDtEndTzID());
if (event.summary != null)
builder.withValue(Events.TITLE, event.summary);
if (event.location != null)
builder.withValue(Events.EVENT_LOCATION, event.location);
if (event.description != null)
builder.withValue(Events.DESCRIPTION, event.description);
if (event.organizer != null) {
final URI uri = event.organizer.getCalAddress();
String email = null;
if (uri != null && "mailto".equalsIgnoreCase(uri.getScheme()))
email = uri.getSchemeSpecificPart();
else {
iCalendar.Email emailParam = (iCalendar.Email)event.organizer.getParameter(iCalendar.Email.PARAMETER_NAME);
if (emailParam != null)
email = emailParam.getValue();
}
if (email != null)
builder.withValue(Events.ORGANIZER, email);
else
Log.w(TAG, "Got ORGANIZER without email address which is not supported by Android, ignoring");
}
if (event.status!= null) {
int statusCode = Events.STATUS_TENTATIVE;
if (event.status == Status.VEVENT_CONFIRMED)
statusCode = Events.STATUS_CONFIRMED;
else if (event.status == Status.VEVENT_CANCELLED)
statusCode = Events.STATUS_CANCELED;
builder.withValue(Events.STATUS, statusCode);
}
builder.withValue(Events.AVAILABILITY, event.opaque ? Events.AVAILABILITY_BUSY : Events.AVAILABILITY_FREE);
if (event.forPublic != null)
builder.withValue(Events.ACCESS_LEVEL, event.forPublic ? Events.ACCESS_PUBLIC : Events.ACCESS_PRIVATE);
return builder;
}
@Override
protected void addDataRows(Resource resource, long localID, int backrefIdx) {
final Event event = (Event)resource;
// add attendees
for (Attendee attendee : event.getAttendees())
pendingOperations.add(buildAttendee(newDataInsertBuilder(Attendees.CONTENT_URI, Attendees.EVENT_ID, localID, backrefIdx), attendee).build());
// add reminders
for (VAlarm alarm : event.getAlarms())
pendingOperations.add(buildReminder(newDataInsertBuilder(Reminders.CONTENT_URI, Reminders.EVENT_ID, localID, backrefIdx), alarm).build());
// add exceptions
for (Event exception : event.getExceptions()) {
final int backrefIdxEx = pendingOperations.size(); // save exception ID as backref value
pendingOperations.add(buildException(newDataInsertBuilder(Events.CONTENT_URI, Events.ORIGINAL_ID, localID, backrefIdx), event, exception).build());
addDataRows(exception, -1, backrefIdxEx); // build attendees and reminders for exception
}
}
@Override
protected void removeDataRows(Resource resource) {
final Event event = (Event)resource;
// delete exceptions
pendingOperations.add(ContentProviderOperation.newDelete(syncAdapterURI(Events.CONTENT_URI))
.withSelection(Events.ORIGINAL_ID + "=?", new String[] { String.valueOf(event.getLocalID())}).build());
// delete attendees
pendingOperations.add(ContentProviderOperation.newDelete(syncAdapterURI(Attendees.CONTENT_URI))
.withSelection(Attendees.EVENT_ID + "=?", new String[]{String.valueOf(event.getLocalID())}).build());
// delete reminders
pendingOperations.add(ContentProviderOperation.newDelete(syncAdapterURI(Reminders.CONTENT_URI))
.withSelection(Reminders.EVENT_ID + "=?", new String[]{String.valueOf(event.getLocalID())}).build());
}
protected Builder buildException(Builder builder, Event master, Event exception) {
buildEntry(builder, exception, false);
final boolean originalAllDay = master.isAllDay();
Date date = exception.recurrenceId.getDate();
if (originalAllDay && date instanceof DateTime) { // correct VALUE=DATE-TIME RECURRENCE-IDs to VALUE=DATE
final DateFormat dateFormatDate = new SimpleDateFormat("yyyyMMdd");
final String dateString = dateFormatDate.format(exception.recurrenceId.getDate());
try {
date = new Date(dateString);
} catch (ParseException e) {
Log.e(TAG, "Couldn't parse DATE part of DATE-TIME RECURRENCE-ID", e);
}
}
builder.withValue(Events.ORIGINAL_INSTANCE_TIME, date.getTime());
builder.withValue(Events.ORIGINAL_ALL_DAY, originalAllDay ? 1 : 0);
return builder;
}
@SuppressLint("InlinedApi")
protected Builder buildAttendee(Builder builder, Attendee attendee) {
final URI member = attendee.getCalAddress();
if ("mailto".equalsIgnoreCase(member.getScheme()))
// attendee identified by email
builder = builder.withValue(Attendees.ATTENDEE_EMAIL, member.getSchemeSpecificPart());
else {
// attendee identified by other URI
builder = builder
.withValue(Attendees.ATTENDEE_ID_NAMESPACE, member.getScheme())
.withValue(Attendees.ATTENDEE_IDENTITY, member.getSchemeSpecificPart());
iCalendar.Email email = (iCalendar.Email)attendee.getParameter(iCalendar.Email.PARAMETER_NAME);
if (email != null)
builder = builder.withValue(Attendees.ATTENDEE_EMAIL, email.getValue());
}
final Cn cn = (Cn)attendee.getParameter(Parameter.CN);
if (cn != null)
builder.withValue(Attendees.ATTENDEE_NAME, cn.getValue());
int type = Attendees.TYPE_NONE;
CuType cutype = (CuType)attendee.getParameter(Parameter.CUTYPE);
if (cutype == CuType.RESOURCE || cutype == CuType.ROOM)
// "attendee" is a (physical) resource
type = Attendees.TYPE_RESOURCE;
else {
// attendee is not a (physical) resource
Role role = (Role)attendee.getParameter(Parameter.ROLE);
int relationship;
if (role == Role.CHAIR)
relationship = Attendees.RELATIONSHIP_ORGANIZER;
else {
relationship = Attendees.RELATIONSHIP_ATTENDEE;
if (role == Role.OPT_PARTICIPANT)
type = Attendees.TYPE_OPTIONAL;
else if (role == Role.REQ_PARTICIPANT)
type = Attendees.TYPE_REQUIRED;
}
builder.withValue(Attendees.ATTENDEE_RELATIONSHIP, relationship);
}
int status = Attendees.ATTENDEE_STATUS_NONE;
PartStat partStat = (PartStat)attendee.getParameter(Parameter.PARTSTAT);
if (partStat == null || partStat == PartStat.NEEDS_ACTION)
status = Attendees.ATTENDEE_STATUS_INVITED;
else if (partStat == PartStat.ACCEPTED)
status = Attendees.ATTENDEE_STATUS_ACCEPTED;
else if (partStat == PartStat.DECLINED)
status = Attendees.ATTENDEE_STATUS_DECLINED;
else if (partStat == PartStat.TENTATIVE)
status = Attendees.ATTENDEE_STATUS_TENTATIVE;
return builder
.withValue(Attendees.ATTENDEE_TYPE, type)
.withValue(Attendees.ATTENDEE_STATUS, status);
}
protected Builder buildReminder(Builder builder, VAlarm alarm) {
int minutes = 0;
if (alarm.getTrigger() != null) {
Dur duration = alarm.getTrigger().getDuration();
if (duration != null) {
// negative value in TRIGGER means positive value in Reminders.MINUTES and vice versa
minutes = -(((duration.getWeeks() * 7 + duration.getDays()) * 24 + duration.getHours()) * 60 + duration.getMinutes());
if (duration.isNegative())
minutes *= -1;
}
}
Log.d(TAG, "Adding alarm " + minutes + " minutes before");
return builder
.withValue(Reminders.METHOD, Reminders.METHOD_ALERT)
.withValue(Reminders.MINUTES, minutes);
}
/* private helper methods */
protected static Uri calendarsURI(Account account) {
return Calendars.CONTENT_URI.buildUpon()
.appendQueryParameter(Calendars.ACCOUNT_NAME, account.name)
.appendQueryParameter(Calendars.ACCOUNT_TYPE, account.type)
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.build();
}
protected Uri calendarsURI() {
return calendarsURI(account);
}
}

@ -1,430 +0,0 @@
/*
* 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.ContentProviderOperation;
import android.content.ContentProviderOperation.Builder;
import android.content.ContentProviderResult;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.net.Uri;
import android.os.RemoteException;
import android.provider.CalendarContract;
import android.util.Log;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import at.bitfire.davdroid.webdav.WebDavResource;
import lombok.Cleanup;
/**
* Represents a locally-stored synchronizable collection (for instance, the
* address book or a calendar). Manages a CTag that stores the last known
* remote CTag (the remote CTag changes whenever something in the remote collection changes).
*
* @param <T> Subtype of Resource that can be stored in the collection
*/
public abstract class LocalCollection<T extends Resource> {
private static final String TAG = "davdroid.Collection";
final protected Account account;
final protected ContentProviderClient providerClient;
final protected ArrayList<ContentProviderOperation> pendingOperations = new ArrayList<>();
// database fields
/** base Uri of the collection's entries (for instance, Events.CONTENT_URI);
* apply syncAdapterURI() before returning a value */
abstract protected Uri entriesURI();
/** column name of the type of the account the entry belongs to */
abstract protected String entryColumnAccountType();
/** column name of the name of the account the entry belongs to */
abstract protected String entryColumnAccountName();
/** column name of the collection ID the entry belongs to */
abstract protected String entryColumnParentID();
/** column name of an entry's ID */
abstract protected String entryColumnID();
/** column name of an entry's file name on the WebDAV server */
abstract protected String entryColumnRemoteName();
/** column name of an entry's last ETag on the WebDAV server; null if entry hasn't been uploaded yet */
abstract protected String entryColumnETag();
/** column name of an entry's "dirty" flag (managed by content provider) */
abstract protected String entryColumnDirty();
/** column name of an entry's "deleted" flag (managed by content provider) */
abstract protected String entryColumnDeleted();
/** column name of an entry's UID */
abstract protected String entryColumnUID();
/** SQL filter expression */
String sqlFilter;
LocalCollection(Account account, ContentProviderClient providerClient) {
this.account = account;
this.providerClient = providerClient;
}
// collection operations
/** gets the ID if the collection (for instance, ID of the Android calendar) */
abstract public long getId();
/** sets local stored CTag */
abstract public void setCTag(String cTag) throws LocalStorageException;
/** gets the CTag of the collection */
abstract public String getCTag() throws LocalStorageException;
/** update locally stored collection properties (e.g. display name and color) from a WebDavResource */
abstract public void updateMetaData(WebDavResource.Properties properties) throws LocalStorageException;
// content provider (= database) querying
/**
* Finds new resources (resources which haven't been uploaded yet).
* New resources are 1) dirty, and 2) don't have an ETag yet.
* Only records matching sqlFilter will be returned.
*
* @return IDs of new resources
* @throws LocalStorageException when the content provider couldn't be queried
*/
public long[] findNew() throws LocalStorageException {
String where = entryColumnDirty() + "=1 AND " + entryColumnETag() + " IS NULL";
if (entryColumnParentID() != null)
where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId());
if (sqlFilter != null)
where += " AND (" + sqlFilter + ")";
try {
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
new String[] { entryColumnID() },
where, null, null);
if (cursor == null)
throw new LocalStorageException("Couldn't query new records");
long[] fresh = new long[cursor.getCount()];
for (int idx = 0; cursor.moveToNext(); idx++) {
long id = cursor.getLong(0);
// new record: we have to generate UID + remote file name for uploading
T resource = findById(id, false);
resource.initialize();
// write generated UID + remote file name into database
ContentValues values = new ContentValues(2);
values.put(entryColumnUID(), resource.getUid());
values.put(entryColumnRemoteName(), resource.getName());
providerClient.update(ContentUris.withAppendedId(entriesURI(), id), values, null, null);
fresh[idx] = id;
}
return fresh;
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
/**
* Finds updated resources (resources which have already been uploaded, but have changed locally).
* Updated resources are 1) dirty, and 2) already have an ETag. Only records matching sqlFilter
* will be returned.
*
* @return IDs of updated resources
* @throws LocalStorageException when the content provider couldn't be queried
*/
public long[] findUpdated() throws LocalStorageException {
String where = entryColumnDirty() + "=1 AND " + entryColumnETag() + " IS NOT NULL";
if (entryColumnParentID() != null)
where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId());
if (sqlFilter != null)
where += " AND (" + sqlFilter + ")";
try {
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
where, null, null);
if (cursor == null)
throw new LocalStorageException("Couldn't query updated records");
long[] updated = new long[cursor.getCount()];
for (int idx = 0; cursor.moveToNext(); idx++)
updated[idx] = cursor.getLong(0);
return updated;
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
/**
* Finds deleted resources (resources which have been marked for deletion).
* Deleted resources have the "deleted" flag set.
* Only records matching sqlFilter will be returned.
*
* @return IDs of deleted resources
* @throws LocalStorageException when the content provider couldn't be queried
*/
public long[] findDeleted() throws LocalStorageException {
String where = entryColumnDeleted() + "=1";
if (entryColumnParentID() != null)
where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId());
if (sqlFilter != null)
where += " AND (" + sqlFilter + ")";
try {
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
where, null, null);
if (cursor == null)
throw new LocalStorageException("Couldn't query dirty records");
long deleted[] = new long[cursor.getCount()];
for (int idx = 0; cursor.moveToNext(); idx++)
deleted[idx] = cursor.getLong(0);
return deleted;
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
/**
* Finds a specific resource by ID. Only records matching sqlFilter are taken into account.
* @param localID ID of the resource
* @param populate true: populates all data fields (for instance, contact or event details);
* false: only remote file name and ETag are populated
* @return resource with either ID/remote file/name/ETag or all fields populated
* @throws RecordNotFoundException when the resource couldn't be found
* @throws LocalStorageException when the content provider couldn't be queried
*/
public T findById(long localID, boolean populate) throws LocalStorageException {
try {
@Cleanup Cursor cursor = providerClient.query(ContentUris.withAppendedId(entriesURI(), localID),
new String[] { entryColumnRemoteName(), entryColumnETag() }, sqlFilter, null, null);
if (cursor != null && cursor.moveToNext()) {
T resource = newResource(localID, cursor.getString(0), cursor.getString(1));
if (populate)
populate(resource);
return resource;
} else
throw new RecordNotFoundException();
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
/**
* Finds a specific resource by remote file name. Only records matching sqlFilter are taken into account.
* @param remoteName remote file name of the resource
* @param populate true: populates all data fields (for instance, contact or event details);
* false: only remote file name and ETag are populated
* @return resource with either ID/remote file/name/ETag or all fields populated
* @throws RecordNotFoundException when the resource couldn't be found
* @throws LocalStorageException when the content provider couldn't be queried
*/
public T findByRemoteName(String remoteName, boolean populate) throws LocalStorageException {
String where = entryColumnRemoteName() + "=?";
if (sqlFilter != null)
where += " AND (" + sqlFilter + ")";
try {
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
where, new String[] { remoteName }, null);
if (cursor != null && cursor.moveToNext()) {
T resource = newResource(cursor.getLong(0), cursor.getString(1), cursor.getString(2));
if (populate)
populate(resource);
return resource;
} else
throw new RecordNotFoundException();
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
/** populates all data fields from the content provider */
public abstract void populate(Resource record) throws LocalStorageException;
// create/update/delete
/**
* Creates a new resource object in memory. No content provider operations involved.
* @param localID the ID of the resource
* @param resourceName the (remote) file name of the resource
* @param eTag ETag of the resource
* @return the new resource object */
abstract public T newResource(long localID, String resourceName, String eTag);
/** Adds the resource (including all data) to the local collection.
* @param resource Resource to be added
*/
public void add(Resource resource) throws LocalStorageException {
int idx = pendingOperations.size();
pendingOperations.add(
buildEntry(ContentProviderOperation.newInsert(entriesURI()), resource, false)
.withYieldAllowed(true)
.build());
addDataRows(resource, -1, idx);
commit();
}
/** Updates an existing resource in the local collection. The resource will be found by
* the remote file name and all data will be updated. */
public void updateByRemoteName(Resource remoteResource) throws LocalStorageException {
T localResource = findByRemoteName(remoteResource.getName(), false);
pendingOperations.add(
buildEntry(ContentProviderOperation.newUpdate(ContentUris.withAppendedId(entriesURI(), localResource.getLocalID())), remoteResource, true)
.withValue(entryColumnETag(), remoteResource.getETag())
.withYieldAllowed(true)
.build());
removeDataRows(localResource);
addDataRows(remoteResource, localResource.getLocalID(), -1);
commit();
}
/** Enqueues deleting a resource from the local collection. Requires commit() to be effective! */
public void delete(Resource resource) {
pendingOperations.add(ContentProviderOperation
.newDelete(ContentUris.withAppendedId(entriesURI(), resource.getLocalID()))
.withYieldAllowed(true)
.build());
}
/**
* Deletes all resources except the give ones from the local collection.
* @param remoteResources resources with these remote file names will be kept
* @return number of deleted resources
*/
public int deleteAllExceptRemoteNames(Resource[] remoteResources) throws LocalStorageException
{
final String where;
if (remoteResources.length != 0) {
// delete all except certain entries
final List<String> sqlFileNames = new LinkedList<>();
for (final Resource res : remoteResources)
sqlFileNames.add(DatabaseUtils.sqlEscapeString(res.getName()));
where = entryColumnRemoteName() + " NOT IN (" + StringUtils.join(sqlFileNames, ",") + ')';
} else
// delete all entries
where = entryColumnRemoteName() + " IS NOT NULL";
try {
if (entryColumnParentID() != null)
// entries have a parent collection (for instance, events which have a calendar)
return providerClient.delete(
entriesURI(),
entryColumnParentID() + "=? AND (" + where + ')', // restrict deletion to parent collection
new String[] { String.valueOf(getId()) }
);
else
// entries don't have a parent collection (contacts are stored directly and not within an address book)
return providerClient.delete(entriesURI(), where, null);
} catch (RemoteException e) {
throw new LocalStorageException("Couldn't delete local resources", e);
}
}
/** Updates the locally-known ETag of a resource. */
public void updateETag(Resource res, String eTag) throws LocalStorageException {
Log.d(TAG, "Setting ETag of local resource " + res.getName() + " to " + eTag);
ContentValues values = new ContentValues(1);
values.put(entryColumnETag(), eTag);
try {
providerClient.update(ContentUris.withAppendedId(entriesURI(), res.getLocalID()), values, null, new String[] {});
} catch (RemoteException e) {
throw new LocalStorageException(e);
}
}
/** Enqueues removing the dirty flag from a locally-stored resource. Requires commit() to be effective! */
public void clearDirty(Resource resource) {
pendingOperations.add(ContentProviderOperation
.newUpdate(ContentUris.withAppendedId(entriesURI(), resource.getLocalID()))
.withValue(entryColumnDirty(), 0)
.build());
}
/** Commits enqueued operations to the content provider (for batch operations). */
public int commit() throws LocalStorageException {
int affected = 0;
if (!pendingOperations.isEmpty())
try {
Log.d(TAG, "Committing " + pendingOperations.size() + " operations ...");
ContentProviderResult[] results = providerClient.applyBatch(pendingOperations);
for (ContentProviderResult result : results)
if (result != null) // will have either .uri or .count set
if (result.count != null)
affected += result.count;
else if (result.uri != null)
affected = 1;
Log.d(TAG, "... " + affected + " record(s) affected");
pendingOperations.clear();
} catch(IllegalArgumentException|OperationApplicationException|RemoteException ex) {
throw new LocalStorageException(ex);
}
return affected;
}
// helpers
protected void queueOperation(Builder builder) {
if (builder != null)
pendingOperations.add(builder.build());
}
/** Appends account type, name and CALLER_IS_SYNCADAPTER to an Uri. */
protected Uri syncAdapterURI(Uri baseURI) {
return baseURI.buildUpon()
.appendQueryParameter(entryColumnAccountType(), account.type)
.appendQueryParameter(entryColumnAccountName(), account.name)
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.build();
}
protected Builder newDataInsertBuilder(Uri dataUri, String refFieldName, long raw_ref_id, int backrefIdx) {
Builder builder = ContentProviderOperation.newInsert(syncAdapterURI(dataUri));
if (backrefIdx != -1)
return builder.withValueBackReference(refFieldName, backrefIdx);
else
return builder.withValue(refFieldName, raw_ref_id);
}
// content builders
/**
* Builds the main entry (for instance, a ContactsContract.RawContacts row) from a resource.
* The entry is built for insertion to the location identified by entriesURI().
*
* @param builder Builder to be extended by all resource data that can be stored without extra data rows.
* @param resource Event, task or note resource whose contents shall be inserted/updated
* @param update false when the entry is built the first time (when creating the row), true if it's an update
*/
protected abstract Builder buildEntry(Builder builder, Resource resource, boolean update);
/** Enqueues adding extra data rows of the resource to the local collection. */
protected abstract void addDataRows(Resource resource, long localID, int backrefIdx);
/** Enqueues removing all extra data rows of the resource from the local collection. */
protected abstract void removeDataRows(Resource resource);
}

@ -1,31 +0,0 @@
/*
* 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;
public class LocalStorageException extends Exception {
private static final long serialVersionUID = -7787658815291629529L;
private static final String detailMessage = "Couldn't access local content provider";
public LocalStorageException(String detailMessage, Throwable throwable) {
super(detailMessage, throwable);
}
public LocalStorageException(String detailMessage) {
super(detailMessage);
}
public LocalStorageException(Throwable throwable) {
super(detailMessage, throwable);
}
public LocalStorageException() {
super(detailMessage);
}
}

@ -1,395 +0,0 @@
/*
* 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.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.RemoteException;
import android.util.Log;
import net.fortuna.ical4j.model.Date;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.Dur;
import net.fortuna.ical4j.model.TimeZone;
import net.fortuna.ical4j.model.property.Clazz;
import net.fortuna.ical4j.model.property.Completed;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.Due;
import net.fortuna.ical4j.model.property.Duration;
import net.fortuna.ical4j.model.property.Status;
import net.fortuna.ical4j.util.TimeZones;
import org.apache.commons.lang3.StringUtils;
import org.dmfs.provider.tasks.TaskContract;
import java.util.LinkedList;
import at.bitfire.davdroid.DavUtils;
import at.bitfire.davdroid.DateUtils;
import at.bitfire.davdroid.webdav.WebDavResource;
import lombok.Cleanup;
import lombok.Getter;
public class LocalTaskList extends LocalCollection<Task> {
private static final String TAG = "davdroid.LocalTaskList";
@Getter protected String url;
@Getter protected long id;
public static final String TASKS_AUTHORITY = "org.dmfs.tasks";
protected static final String COLLECTION_COLUMN_CTAG = TaskContract.TaskLists.SYNC1;
@Override protected Uri entriesURI() { return syncAdapterURI(TaskContract.Tasks.getContentUri(TASKS_AUTHORITY)); }
@Override protected String entryColumnAccountType() { return TaskContract.Tasks.ACCOUNT_TYPE; }
@Override protected String entryColumnAccountName() { return TaskContract.Tasks.ACCOUNT_NAME; }
@Override protected String entryColumnParentID() { return TaskContract.Tasks.LIST_ID; }
@Override protected String entryColumnID() { return TaskContract.Tasks._ID; }
@Override protected String entryColumnRemoteName() { return TaskContract.Tasks._SYNC_ID; }
@Override protected String entryColumnETag() { return TaskContract.Tasks.SYNC1; }
@Override protected String entryColumnDirty() { return TaskContract.Tasks._DIRTY; }
@Override protected String entryColumnDeleted() { return TaskContract.Tasks._DELETED; }
@Override protected String entryColumnUID() { return TaskContract.Tasks.SYNC2; }
public static Uri create(Account account, ContentResolver resolver, ServerInfo.ResourceInfo info) throws LocalStorageException {
@Cleanup("release") final ContentProviderClient client = resolver.acquireContentProviderClient(TASKS_AUTHORITY);
if (client == null)
throw new LocalStorageException("No tasks provider found");
ContentValues values = new ContentValues();
values.put(TaskContract.TaskLists.ACCOUNT_NAME, account.name);
values.put(TaskContract.TaskLists.ACCOUNT_TYPE, account.type);
values.put(TaskContract.TaskLists._SYNC_ID, info.getURL());
values.put(TaskContract.TaskLists.LIST_NAME, info.getTitle());
values.put(TaskContract.TaskLists.LIST_COLOR, info.getColor() != null ? info.getColor() : DavUtils.calendarGreen);
values.put(TaskContract.TaskLists.OWNER, account.name);
values.put(TaskContract.TaskLists.ACCESS_LEVEL, 0);
values.put(TaskContract.TaskLists.SYNC_ENABLED, 1);
values.put(TaskContract.TaskLists.VISIBLE, 1);
Log.i(TAG, "Inserting task list: " + values.toString());
try {
return client.insert(taskListsURI(account), values);
} catch (RemoteException e) {
throw new LocalStorageException(e);
}
}
public static LocalTaskList[] findAll(Account account, ContentProviderClient providerClient) throws RemoteException {
@Cleanup Cursor cursor = providerClient.query(taskListsURI(account),
new String[]{TaskContract.TaskLists._ID, TaskContract.TaskLists._SYNC_ID},
null, null, null);
LinkedList<LocalTaskList> taskList = new LinkedList<>();
while (cursor != null && cursor.moveToNext())
taskList.add(new LocalTaskList(account, providerClient, cursor.getInt(0), cursor.getString(1)));
return taskList.toArray(new LocalTaskList[taskList.size()]);
}
public LocalTaskList(Account account, ContentProviderClient providerClient, long id, String url) {
super(account, providerClient);
this.id = id;
this.url = url;
}
@Override
public String getCTag() throws LocalStorageException {
try {
@Cleanup Cursor c = providerClient.query(ContentUris.withAppendedId(taskListsURI(account), id),
new String[] { COLLECTION_COLUMN_CTAG }, null, null, null);
if (c != null && c.moveToFirst())
return c.getString(0);
else
throw new LocalStorageException("Couldn't query task list CTag");
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
@Override
public void setCTag(String cTag) throws LocalStorageException {
ContentValues values = new ContentValues(1);
values.put(COLLECTION_COLUMN_CTAG, cTag);
try {
providerClient.update(ContentUris.withAppendedId(taskListsURI(account), id), values, null, null);
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
@Override
public void updateMetaData(WebDavResource.Properties properties) throws LocalStorageException {
ContentValues values = new ContentValues();
final String displayName = properties.getDisplayName();
if (displayName != null)
values.put(TaskContract.TaskLists.LIST_NAME, displayName);
final Integer color = properties.getColor();
if (color != null)
values.put(TaskContract.TaskLists.LIST_COLOR, color);
try {
if (values.size() > 0)
providerClient.update(ContentUris.withAppendedId(taskListsURI(account), id), values, null, null);
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
@Override
public Task newResource(long localID, String resourceName, String eTag) {
return new Task(localID, resourceName, eTag);
}
@Override
public void populate(Resource record) throws LocalStorageException {
try {
@Cleanup final Cursor cursor = providerClient.query(entriesURI(),
new String[] {
/* 0 */ entryColumnUID(), TaskContract.Tasks.TITLE, TaskContract.Tasks.LOCATION, TaskContract.Tasks.DESCRIPTION, TaskContract.Tasks.URL,
/* 5 */ TaskContract.Tasks.CLASSIFICATION, TaskContract.Tasks.STATUS, TaskContract.Tasks.PERCENT_COMPLETE,
/* 8 */ TaskContract.Tasks.TZ, TaskContract.Tasks.DTSTART, TaskContract.Tasks.IS_ALLDAY,
/* 11 */ TaskContract.Tasks.DUE, TaskContract.Tasks.DURATION, TaskContract.Tasks.COMPLETED,
/* 14 */ TaskContract.Tasks.CREATED, TaskContract.Tasks.LAST_MODIFIED, TaskContract.Tasks.PRIORITY
}, entryColumnID() + "=?", new String[]{ String.valueOf(record.getLocalID()) }, null);
Task task = (Task)record;
if (cursor != null && cursor.moveToFirst()) {
task.setUid(cursor.getString(0));
if (!cursor.isNull(14))
task.setCreatedAt(new DateTime(cursor.getLong(14)));
if (!cursor.isNull(15))
task.setLastModified(new DateTime(cursor.getLong(15)));
if (!StringUtils.isEmpty(cursor.getString(1)))
task.setSummary(cursor.getString(1));
if (!StringUtils.isEmpty(cursor.getString(2)))
task.setLocation(cursor.getString(2));
if (!StringUtils.isEmpty(cursor.getString(3)))
task.setDescription(cursor.getString(3));
if (!StringUtils.isEmpty(cursor.getString(4)))
task.setUrl(cursor.getString(4));
if (!cursor.isNull(16))
task.setPriority(cursor.getInt(16));
if (!cursor.isNull(5))
switch (cursor.getInt(5)) {
case TaskContract.Tasks.CLASSIFICATION_PUBLIC:
task.setClassification(Clazz.PUBLIC);
break;
case TaskContract.Tasks.CLASSIFICATION_CONFIDENTIAL:
task.setClassification(Clazz.CONFIDENTIAL);
break;
default:
task.setClassification(Clazz.PRIVATE);
}
if (!cursor.isNull(6))
switch (cursor.getInt(6)) {
case TaskContract.Tasks.STATUS_IN_PROCESS:
task.setStatus(Status.VTODO_IN_PROCESS);
break;
case TaskContract.Tasks.STATUS_COMPLETED:
task.setStatus(Status.VTODO_COMPLETED);
break;
case TaskContract.Tasks.STATUS_CANCELLED:
task.setStatus(Status.VTODO_CANCELLED);
break;
default:
task.setStatus(Status.VTODO_NEEDS_ACTION);
}
if (!cursor.isNull(7))
task.setPercentComplete(cursor.getInt(7));
TimeZone tz = null;
if (!cursor.isNull(8))
tz = DateUtils.tzRegistry.getTimeZone(cursor.getString(8));
if (!cursor.isNull(9) && !cursor.isNull(10)) {
long ts = cursor.getLong(9);
boolean allDay = cursor.getInt(10) != 0;
Date dt;
if (allDay)
dt = new Date(ts);
else {
dt = new DateTime(ts);
if (tz != null)
((DateTime)dt).setTimeZone(tz);
}
task.setDtStart(new DtStart(dt));
}
if (!cursor.isNull(11)) {
DateTime dt = new DateTime(cursor.getLong(11));
if (tz != null)
dt.setTimeZone(tz);
task.setDue(new Due(dt));
}
if (!cursor.isNull(12))
task.setDuration(new Duration(new Dur(cursor.getString(12))));
if (!cursor.isNull(13))
task.setCompletedAt(new Completed(new DateTime(cursor.getLong(13))));
}
} catch (RemoteException e) {
throw new LocalStorageException("Couldn't process locally stored task", e);
}
}
@Override
protected ContentProviderOperation.Builder buildEntry(ContentProviderOperation.Builder builder, Resource resource, boolean update) {
final Task task = (Task)resource;
if (!update)
builder .withValue(entryColumnParentID(), id)
.withValue(entryColumnRemoteName(), task.getName())
.withValue(entryColumnDirty(), 0); // _DIRTY is INTEGER DEFAULT 1 in org.dmfs.provider.tasks
builder.withValue(entryColumnUID(), task.getUid())
.withValue(entryColumnETag(), task.getETag())
.withValue(TaskContract.Tasks.TITLE, task.getSummary())
.withValue(TaskContract.Tasks.LOCATION, task.getLocation())
.withValue(TaskContract.Tasks.DESCRIPTION, task.getDescription())
.withValue(TaskContract.Tasks.URL, task.getUrl())
.withValue(TaskContract.Tasks.PRIORITY, task.getPriority());
if (task.getCreatedAt() != null)
builder.withValue(TaskContract.Tasks.CREATED, task.getCreatedAt().getTime());
if (task.getLastModified() != null)
builder.withValue(TaskContract.Tasks.LAST_MODIFIED, task.getLastModified().getTime());
if (task.getClassification() != null) {
int classCode = TaskContract.Tasks.CLASSIFICATION_PRIVATE;
if (task.getClassification() == Clazz.PUBLIC)
classCode = TaskContract.Tasks.CLASSIFICATION_PUBLIC;
else if (task.getClassification() == Clazz.CONFIDENTIAL)
classCode = TaskContract.Tasks.CLASSIFICATION_CONFIDENTIAL;
builder = builder.withValue(TaskContract.Tasks.CLASSIFICATION, classCode);
}
int statusCode = TaskContract.Tasks.STATUS_DEFAULT;
if (task.getStatus() != null) {
if (task.getStatus() == Status.VTODO_NEEDS_ACTION)
statusCode = TaskContract.Tasks.STATUS_NEEDS_ACTION;
else if (task.getStatus() == Status.VTODO_IN_PROCESS)
statusCode = TaskContract.Tasks.STATUS_IN_PROCESS;
else if (task.getStatus() == Status.VTODO_COMPLETED)
statusCode = TaskContract.Tasks.STATUS_COMPLETED;
else if (task.getStatus() == Status.VTODO_CANCELLED)
statusCode = TaskContract.Tasks.STATUS_CANCELLED;
}
builder .withValue(TaskContract.Tasks.STATUS, statusCode)
.withValue(TaskContract.Tasks.PERCENT_COMPLETE, task.getPercentComplete());
TimeZone tz = null;
if (task.getDtStart() != null) {
Date start = task.getDtStart().getDate();
boolean allDay;
if (start instanceof DateTime) {
allDay = false;
tz = ((DateTime)start).getTimeZone();
} else
allDay = true;
long ts = start.getTime();
builder .withValue(TaskContract.Tasks.DTSTART, ts)
.withValue(TaskContract.Tasks.IS_ALLDAY, allDay ? 1 : 0);
}
if (task.getDue() != null) {
Due due = task.getDue();
builder.withValue(TaskContract.Tasks.DUE, due.getDate().getTime());
if (tz == null)
tz = due.getTimeZone();
} else if (task.getDuration() != null)
builder.withValue(TaskContract.Tasks.DURATION, task.getDuration().getValue());
if (task.getCompletedAt() != null) {
Date completed = task.getCompletedAt().getDate();
boolean allDay;
if (completed instanceof DateTime) {
allDay = false;
if (tz == null)
tz = ((DateTime)completed).getTimeZone();
} else {
task.getCompletedAt().setUtc(true);
allDay = true;
}
long ts = completed.getTime();
builder .withValue(TaskContract.Tasks.COMPLETED, ts)
.withValue(TaskContract.Tasks.COMPLETED_IS_ALLDAY, allDay ? 1 : 0);
}
// TZ *must* be provided when DTSTART or DUE is set
if ((task.getDtStart() != null || task.getDue() != null) && tz == null)
tz = DateUtils.tzRegistry.getTimeZone(TimeZones.GMT_ID);
if (tz != null)
builder.withValue(TaskContract.Tasks.TZ, DateUtils.findAndroidTimezoneID(tz.getID()));
return builder;
}
@Override
protected void addDataRows(Resource resource, long localID, int backrefIdx) {
}
@Override
protected void removeDataRows(Resource resource) {
}
// helpers
public static boolean isAvailable(Context context) {
try {
@Cleanup("release") ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(TASKS_AUTHORITY);
return client != null;
} catch (SecurityException e) {
Log.e(TAG, "DAVdroid is not allowed to access tasks", e);
return false;
}
}
@Override
protected Uri syncAdapterURI(Uri baseURI) {
return baseURI.buildUpon()
.appendQueryParameter(entryColumnAccountType(), account.type)
.appendQueryParameter(entryColumnAccountName(), account.name)
.appendQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER, "true")
.build();
}
protected static Uri taskListsURI(Account account) {
return TaskContract.TaskLists.getContentUri(TASKS_AUTHORITY).buildUpon()
.appendQueryParameter(TaskContract.TaskLists.ACCOUNT_TYPE, account.type)
.appendQueryParameter(TaskContract.TaskLists.ACCOUNT_NAME, account.name)
.appendQueryParameter(TaskContract.CALLER_IS_SYNCADAPTER, "true")
.build();
}
}

@ -1,24 +0,0 @@
/*
* 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;
/**
* Thrown when a local record (for instance, Contact with ID 12345) should be read
* but could not be found.
*/
public class RecordNotFoundException extends LocalStorageException {
private static final long serialVersionUID = 4961024282198632578L;
private static final String detailMessage = "Record not found in local content provider";
RecordNotFoundException() {
super(detailMessage);
}
}

@ -1,65 +0,0 @@
/*
* 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 org.apache.http.entity.ContentType;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.HttpException;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* Represents a resource that can be contained in a LocalCollection or RemoteCollection
* for synchronization by WebDAV.
*/
@ToString
public abstract class Resource {
@Getter @Setter protected String name, ETag;
@Getter @Setter protected String uid;
@Getter protected long localID;
public Resource(String name, String ETag) {
this.name = name;
this.ETag = ETag;
}
public Resource(long localID, String name, String ETag) {
this(name, ETag);
this.localID = localID;
}
/** initializes UID and remote file name (required for first upload) */
public abstract void initialize();
/** fills the resource data from an input stream (for instance, .vcf file for Contact)
* @param entity entity to parse
* @param downloader will be used to fetch additional resources like contact images
**/
public abstract void parseEntity(InputStream entity, Charset charset, AssetDownloader downloader) throws IOException, InvalidResourceException;
/* returns the MIME type that toEntity() will produce */
public abstract ContentType getContentType();
/** writes the resource data to an output stream (for instance, .vcf file for Contact) */
public abstract ByteArrayOutputStream toEntity() throws IOException;
public interface AssetDownloader {
byte[] download(URI url) throws URISyntaxException, IOException, HttpException, DavException;
}
}

@ -1,223 +0,0 @@
/*
* 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.util.Log;
import net.fortuna.ical4j.data.CalendarOutputter;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.ComponentList;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.PropertyList;
import net.fortuna.ical4j.model.TimeZone;
import net.fortuna.ical4j.model.ValidationException;
import net.fortuna.ical4j.model.component.VToDo;
import net.fortuna.ical4j.model.property.Clazz;
import net.fortuna.ical4j.model.property.Completed;
import net.fortuna.ical4j.model.property.Created;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.Due;
import net.fortuna.ical4j.model.property.Duration;
import net.fortuna.ical4j.model.property.LastModified;
import net.fortuna.ical4j.model.property.Location;
import net.fortuna.ical4j.model.property.PercentComplete;
import net.fortuna.ical4j.model.property.Priority;
import net.fortuna.ical4j.model.property.Status;
import net.fortuna.ical4j.model.property.Summary;
import net.fortuna.ical4j.model.property.Uid;
import net.fortuna.ical4j.model.property.Url;
import net.fortuna.ical4j.model.property.Version;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.HashSet;
import java.util.Set;
import at.bitfire.davdroid.Constants;
import lombok.Cleanup;
import lombok.Getter;
import lombok.Setter;
public class Task extends iCalendar {
private final static String TAG = "davdroid.Task";
@Getter @Setter DateTime createdAt;
@Getter @Setter DateTime lastModified;
@Getter @Setter String summary, location, description, url;
@Getter @Setter int priority;
@Getter @Setter Clazz classification;
@Getter @Setter Status status;
@Getter @Setter DtStart dtStart;
@Getter @Setter Due due;
@Getter @Setter Duration duration;
@Getter @Setter Completed completedAt;
@Getter @Setter Integer percentComplete;
public Task(String name, String ETag) {
super(name, ETag);
}
public Task(long localId, String name, String ETag)
{
super(localId, name, ETag);
}
@Override
public void parseEntity(InputStream entity, Charset charset, AssetDownloader downloader) throws IOException, InvalidResourceException {
final net.fortuna.ical4j.model.Calendar ical;
try {
if (charset != null) {
@Cleanup InputStreamReader reader = new InputStreamReader(entity, charset);
ical = calendarBuilder.build(reader);
} else
ical = calendarBuilder.build(entity);
if (ical == null)
throw new InvalidResourceException("No iCalendar found");
} catch (ParserException e) {
throw new InvalidResourceException(e);
}
ComponentList notes = ical.getComponents(Component.VTODO);
if (notes == null || notes.isEmpty())
throw new InvalidResourceException("No VTODO found");
VToDo todo = (VToDo)notes.get(0);
if (todo.getUid() != null)
uid = todo.getUid().getValue();
else {
Log.w(TAG, "Received VTODO without UID, generating new one");
generateUID();
}
if (todo.getCreated() != null)
createdAt = todo.getCreated().getDateTime();
if (todo.getLastModified() != null)
lastModified = todo.getLastModified().getDateTime();
if (todo.getSummary() != null)
summary = todo.getSummary().getValue();
if (todo.getLocation() != null)
location = todo.getLocation().getValue();
if (todo.getDescription() != null)
description = todo.getDescription().getValue();
if (todo.getUrl() != null)
url = todo.getUrl().getValue();
priority = (todo.getPriority() != null) ? todo.getPriority().getLevel() : 0;
if (todo.getClassification() != null)
classification = todo.getClassification();
if (todo.getStatus() != null)
status = todo.getStatus();
if (todo.getDue() != null) {
due = todo.getDue();
validateTimeZone(due);
}
if (todo.getDuration() != null)
duration = todo.getDuration();
if (todo.getStartDate() != null) {
dtStart = todo.getStartDate();
validateTimeZone(dtStart);
}
if (todo.getDateCompleted() != null) {
completedAt = todo.getDateCompleted();
validateTimeZone(completedAt);
}
if (todo.getPercentComplete() != null)
percentComplete = todo.getPercentComplete().getPercentage();
}
@Override
public ByteArrayOutputStream toEntity() throws IOException {
final net.fortuna.ical4j.model.Calendar ical = new net.fortuna.ical4j.model.Calendar();
ical.getProperties().add(Version.VERSION_2_0);
ical.getProperties().add(Constants.ICAL_PRODID);
final VToDo todo = new VToDo();
ical.getComponents().add(todo);
final PropertyList props = todo.getProperties();
if (uid != null)
props.add(new Uid(uid));
if (createdAt != null)
props.add(new Created(createdAt));
if (lastModified != null)
props.add(new LastModified(lastModified));
if (summary != null)
props.add(new Summary(summary));
if (location != null)
props.add(new Location(location));
if (description != null)
props.add(new Description(description));
if (url != null)
try {
props.add(new Url(new URI(url)));
} catch (URISyntaxException e) {
Log.e(TAG, "Ignoring invalid task URL: " + url, e);
}
if (priority != 0)
props.add(new Priority(priority));
if (classification != null)
props.add(classification);
if (status != null)
props.add(status);
// remember used time zones
Set<TimeZone> usedTimeZones = new HashSet<>();
if (due != null) {
props.add(due);
if (due.getTimeZone() != null)
usedTimeZones.add(due.getTimeZone());
}
if (duration != null)
props.add(duration);
if (dtStart != null) {
props.add(dtStart);
if (dtStart.getTimeZone() != null)
usedTimeZones.add(dtStart.getTimeZone());
}
if (completedAt != null) {
props.add(completedAt);
if (completedAt.getTimeZone() != null)
usedTimeZones.add(completedAt.getTimeZone());
}
if (percentComplete != null)
props.add(new PercentComplete(percentComplete));
// add VTIMEZONE components
for (TimeZone timeZone : usedTimeZones)
ical.getComponents().add(timeZone.getVTimeZone());
CalendarOutputter output = new CalendarOutputter(false);
ByteArrayOutputStream os = new ByteArrayOutputStream();
try {
output.output(ical, os);
} catch (ValidationException e) {
Log.e(TAG, "Generated invalid iCalendar");
}
return os;
}
}

@ -1,226 +0,0 @@
/*
* 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.util.Log;
import org.apache.commons.io.IOUtils;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.client.CloseableHttpClient;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.LinkedList;
import java.util.List;
import at.bitfire.davdroid.URIUtils;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.DavMultiget;
import at.bitfire.davdroid.webdav.DavNoContentException;
import at.bitfire.davdroid.webdav.HttpException;
import at.bitfire.davdroid.webdav.HttpPropfind;
import at.bitfire.davdroid.webdav.WebDavResource;
import at.bitfire.davdroid.webdav.WebDavResource.PutMode;
import ezvcard.io.text.VCardParseException;
import lombok.Cleanup;
import lombok.Getter;
/**
* Represents a remotely stored synchronizable collection (collection as in
* WebDAV terminology).
*
* @param <T> Subtype of Resource that can be stored in the collection
*/
public abstract class WebDavCollection<T extends Resource> {
private static final String TAG = "davdroid.resource";
URI baseURI;
@Getter WebDavResource collection;
abstract protected String memberAcceptedMimeTypes();
abstract protected DavMultiget.Type multiGetType();
abstract protected T newResourceSkeleton(String name, String ETag);
public WebDavCollection(CloseableHttpClient httpClient, String baseURL, String user, String password, boolean preemptiveAuth) throws URISyntaxException {
baseURI = URIUtils.parseURI(baseURL, false);
collection = new WebDavResource(httpClient, baseURI, user, password, preemptiveAuth);
}
/* collection operations */
public void getProperties() throws URISyntaxException, IOException, HttpException, DavException {
collection.propfind(HttpPropfind.Mode.COLLECTION_PROPERTIES);
}
/**
* Returns a body for the REPORT request that queries all members of the collection
* that should be considered. Allows collections to implement remote filters.
* @return body for REPORT request or null if PROPFIND shall be used
*/
public String getMemberETagsQuery() {
return null;
}
/**
* Gets the ETags of the resources in a collection. If davQuery is set, it's required to be a
* complete addressbook-query or calendar-query XML and will cause getMemberETags() to use REPORT
* instead of PROPFIND.
* @return array of Resources where only the resource names and ETags are set
*/
public Resource[] getMemberETags() throws URISyntaxException, IOException, DavException, HttpException {
String query = getMemberETagsQuery();
if (query != null)
collection.report(query);
else
collection.propfind(HttpPropfind.Mode.MEMBERS_ETAG);
List<T> resources = new LinkedList<>();
if (collection.getMembers() != null)
for (WebDavResource member : collection.getMembers())
resources.add(newResourceSkeleton(member.getName(), member.getProperties().getETag()));
return resources.toArray(new Resource[resources.size()]);
}
@SuppressWarnings("unchecked")
public Resource[] multiGet(Resource[] resources) throws URISyntaxException, IOException, DavException, HttpException {
try {
if (resources.length == 1)
return new Resource[] { get(resources[0]) };
Log.i(TAG, "Multi-getting " + resources.length + " remote resource(s)");
LinkedList<String> names = new LinkedList<>();
for (Resource resource : resources)
names.add(resource.getName());
LinkedList<T> foundResources = new LinkedList<>();
collection.multiGet(multiGetType(), names.toArray(new String[names.size()]));
if (collection.getMembers() == null)
throw new DavNoContentException();
for (WebDavResource member : collection.getMembers()) {
T resource = newResourceSkeleton(member.getName(), member.getProperties().getETag());
try {
if (member.getContent() != null) {
@Cleanup InputStream is = new ByteArrayInputStream(member.getContent());
resource.parseEntity(is, null, getDownloader());
foundResources.add(resource);
} else
Log.e(TAG, "Ignoring entity without content");
} catch (InvalidResourceException e) {
Log.e(TAG, "Ignoring unparseable entity in multi-response", e);
}
}
return foundResources.toArray(new Resource[foundResources.size()]);
} catch (InvalidResourceException e) {
Log.e(TAG, "Couldn't parse entity from GET", e);
}
return new Resource[0];
}
/* internal member operations */
public Resource get(Resource resource) throws URISyntaxException, IOException, HttpException, DavException, InvalidResourceException {
WebDavResource member = new WebDavResource(collection, resource.getName());
member.get(memberAcceptedMimeTypes());
final byte[] data = member.getContent();
if (data == null)
throw new DavNoContentException();
@Cleanup InputStream is = new ByteArrayInputStream(data);
try {
Charset charset = null;
ContentType mime = member.getProperties().getContentType();
if (mime != null)
charset = mime.getCharset();
resource.parseEntity(is, charset, getDownloader());
} catch (VCardParseException e) {
throw new InvalidResourceException(e);
}
return resource;
}
// returns ETag of the created resource, if returned by server
public String add(Resource res) throws URISyntaxException, IOException, HttpException {
WebDavResource member = new WebDavResource(collection, res.getName(), res.getETag());
member.getProperties().setContentType(res.getContentType());
@Cleanup ByteArrayOutputStream os = res.toEntity();
String eTag = member.put(os.toByteArray(), PutMode.ADD_DONT_OVERWRITE);
// after a successful upload, the collection has implicitly changed, too
collection.getProperties().invalidateCTag();
return eTag;
}
public void delete(Resource res) throws URISyntaxException, IOException, HttpException {
WebDavResource member = new WebDavResource(collection, res.getName(), res.getETag());
member.delete();
collection.getProperties().invalidateCTag();
}
// returns ETag of the updated resource, if returned by server
public String update(Resource res) throws URISyntaxException, IOException, HttpException {
WebDavResource member = new WebDavResource(collection, res.getName(), res.getETag());
member.getProperties().setContentType(res.getContentType());
@Cleanup ByteArrayOutputStream os = res.toEntity();
String eTag = member.put(os.toByteArray(), PutMode.UPDATE_DONT_OVERWRITE);
// after a successful upload, the collection has implicitely changed, too
collection.getProperties().invalidateCTag();
return eTag;
}
// helpers
Resource.AssetDownloader getDownloader() {
return new Resource.AssetDownloader() {
@Override
public byte[] download(URI uri) throws URISyntaxException, IOException, HttpException, DavException {
if (!uri.isAbsolute())
throw new URISyntaxException(uri.toString(), "URI referenced from entity must be absolute");
// send credentials when asset has same URI authority as baseURI
// we have to construct these helper URIs because
// "https://server:443" is NOT equal to "https://server" otherwise
URI baseUriAuthority = new URI(baseURI.getScheme(), null, baseURI.getHost(), baseURI.getPort(), null, null, null),
assetUriAuthority = new URI(uri.getScheme(), null, uri.getHost(), baseURI.getPort(), null, null, null);
if (baseUriAuthority.equals(assetUriAuthority)) {
Log.d(TAG, "Asset is on same server, sending credentials");
WebDavResource file = new WebDavResource(collection, uri);
file.get("image/*");
return file.getContent();
} else
// resource is on an external server, don't send credentials
return IOUtils.toByteArray(uri);
}
};
}
}

@ -1,169 +0,0 @@
/*
* 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.util.Log;
import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.data.CalendarParserFactory;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.Parameter;
import net.fortuna.ical4j.model.ParameterFactory;
import net.fortuna.ical4j.model.ParameterFactoryImpl;
import net.fortuna.ical4j.model.ParameterFactoryRegistry;
import net.fortuna.ical4j.model.PropertyFactoryRegistry;
import net.fortuna.ical4j.model.component.VTimeZone;
import net.fortuna.ical4j.model.property.DateProperty;
import net.fortuna.ical4j.util.CompatibilityHints;
import net.fortuna.ical4j.util.SimpleHostInfo;
import net.fortuna.ical4j.util.Strings;
import net.fortuna.ical4j.util.UidGenerator;
import org.apache.commons.codec.CharEncoding;
import org.apache.http.entity.ContentType;
import java.io.IOException;
import java.io.StringReader;
import java.net.URISyntaxException;
import java.util.TimeZone;
import at.bitfire.davdroid.DateUtils;
import at.bitfire.davdroid.syncadapter.DavSyncAdapter;
import lombok.Getter;
import lombok.NonNull;
public abstract class iCalendar extends Resource {
private static final String TAG = "DAVdroid.iCal";
// static ical4j initialization
static {
CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_UNFOLDING, true);
CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_PARSING, true);
CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_OUTLOOK_COMPATIBILITY, true);
}
public static final ContentType MIME_ICALENDAR = ContentType.create("text/calendar", CharEncoding.UTF_8);
public iCalendar(long localID, String name, String ETag) {
super(localID, name, ETag);
}
public iCalendar(String name, String ETag) {
super(name, ETag);
}
@Override
public void initialize() {
generateUID();
name = uid + ".ics";
}
protected void generateUID() {
UidGenerator generator = new UidGenerator(new SimpleHostInfo(DavSyncAdapter.getAndroidID()), String.valueOf(android.os.Process.myPid()));
uid = generator.generateUid().getValue().replace("@", "_");
}
@Override
public ContentType getContentType() {
return MIME_ICALENDAR;
}
// time zone helpers
protected static boolean isDateTime(DateProperty date) {
return date.getDate() instanceof DateTime;
}
/**
* Ensures that a given DateProperty has a time zone with an ID that is available in Android.
* @param date DateProperty to validate. Values which are not DATE-TIME will be ignored.
*/
protected static void validateTimeZone(DateProperty date) {
if (isDateTime(date)) {
final TimeZone tz = date.getTimeZone();
if (tz == null)
return;
final String tzID = tz.getID();
if (tzID == null)
return;
String deviceTzID = DateUtils.findAndroidTimezoneID(tzID);
if (!tzID.equals(deviceTzID))
date.setTimeZone(DateUtils.tzRegistry.getTimeZone(deviceTzID));
}
}
/**
* Takes a string with a timezone definition and returns the time zone ID.
* @param timezoneDef time zone definition (VCALENDAR with VTIMEZONE component)
* @return time zone id (TZID) if VTIMEZONE contains a TZID,
* null otherwise
*/
public static String TimezoneDefToTzId(@NonNull String timezoneDef) {
try {
CalendarBuilder builder = new CalendarBuilder();
net.fortuna.ical4j.model.Calendar cal = builder.build(new StringReader(timezoneDef));
VTimeZone timezone = (VTimeZone)cal.getComponent(VTimeZone.VTIMEZONE);
if (timezone != null && timezone.getTimeZoneId() != null)
return timezone.getTimeZoneId().getValue();
} catch (IOException|ParserException e) {
Log.e(TAG, "Can't understand time zone definition", e);
}
return null;
}
// ical4j helpers and extensions
private static final ParameterFactoryRegistry parameterFactoryRegistry = new ParameterFactoryRegistry();
static {
parameterFactoryRegistry.register(Email.PARAMETER_NAME, Email.FACTORY);
}
protected static final CalendarBuilder calendarBuilder = new CalendarBuilder(
CalendarParserFactory.getInstance().createParser(),
new PropertyFactoryRegistry(), parameterFactoryRegistry, DateUtils.tzRegistry);
public static class Email extends Parameter {
/* EMAIL property for ATTENDEE properties, as used by iCloud:
ATTENDEE;EMAIL=bla@domain.tld;/path/to/principal
*/
public static final ParameterFactory FACTORY = new Factory();
public static final String PARAMETER_NAME = "EMAIL";
@Getter private String value;
protected Email() {
super(PARAMETER_NAME, ParameterFactoryImpl.getInstance());
}
public Email(String aValue) {
super(PARAMETER_NAME, ParameterFactoryImpl.getInstance());
value = Strings.unquote(aValue);
}
public static class Factory implements ParameterFactory {
@Override
public Parameter createParameter(String value) throws URISyntaxException {
return new Email(value);
}
@Override
public boolean supports(String name) {
return PARAMETER_NAME.equalsIgnoreCase(name);
}
}
}
}

@ -9,21 +9,13 @@ package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.app.Service;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import at.bitfire.davdroid.resource.CalDavCalendar;
import at.bitfire.davdroid.resource.LocalCalendar;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.WebDavCollection;
public class CalendarsSyncAdapterService extends Service {
private static SyncAdapter syncAdapter;
@ -36,7 +28,6 @@ public class CalendarsSyncAdapterService extends Service {
@Override
public void onDestroy() {
syncAdapter.close();
syncAdapter = null;
}
@ -46,35 +37,16 @@ public class CalendarsSyncAdapterService extends Service {
}
private static class SyncAdapter extends DavSyncAdapter {
private final static String TAG = "davdroid.CalendarsSync";
private static class SyncAdapter extends AbstractThreadedSyncAdapter {
private SyncAdapter(Context context) {
super(context);
}
@Override
protected Map<LocalCollection<?>, WebDavCollection<?>> getSyncPairs(Account account, ContentProviderClient provider) {
AccountSettings settings = new AccountSettings(getContext(), account);
String userName = settings.getUserName(),
password = settings.getPassword();
boolean preemptive = settings.getPreemptiveAuth();
public SyncAdapter(Context context) {
super(context, false);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
}
}
try {
Map<LocalCollection<?>, WebDavCollection<?>> map = new HashMap<>();
for (LocalCalendar calendar : LocalCalendar.findAll(account, provider)) {
WebDavCollection<?> dav = new CalDavCalendar(httpClient, calendar.getUrl(), userName, password, preemptive);
map.put(calendar, dav);
}
return map;
} catch (RemoteException ex) {
Log.e(TAG, "Couldn't find local calendars", ex);
} catch (URISyntaxException ex) {
Log.e(TAG, "Couldn't build calendar URI", ex);
}
return null;
}
}
}

@ -8,70 +8,20 @@
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.AbstractThreadedSyncAdapter;
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.os.IBinder;
import android.text.TextUtils;
import com.squareup.okhttp.HttpUrl;
import com.squareup.okhttp.MediaType;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;
import com.squareup.okhttp.ResponseBody;
import org.apache.commons.io.Charsets;
import java.io.ByteArrayInputStream;
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;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.dav4android.property.SupportedAddressData;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.davdroid.resource.LocalContact;
import at.bitfire.davdroid.ui.DebugInfoActivity;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import ezvcard.VCardVersion;
import ezvcard.util.IOUtils;
import lombok.Cleanup;
import lombok.RequiredArgsConstructor;
public class ContactsSyncAdapterService extends Service {
private static ContactsSyncAdapter syncAdapter;
protected static final int MAX_MULTIGET = 10;
@Override
@ -92,354 +42,21 @@ public class ContactsSyncAdapterService extends Service {
private static class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
private static final int
NOTIFICATION_ERROR = 1,
SYNC_PHASE_QUERY_CAPABILITIES = 0,
SYNC_PHASE_PROCESS_LOCALLY_DELETED = 1,
SYNC_PHASE_PREPARE_LOCALLY_CREATED = 2,
SYNC_PHASE_UPLOAD_DIRTY = 3,
SYNC_PHASE_CHECK_STATE = 4,
SYNC_PHASE_LIST_LOCAL = 5,
SYNC_PHASE_LIST_REMOTE = 6,
SYNC_PHASE_COMPARE_ENTRIES = 7,
SYNC_PHASE_DOWNLOAD_REMOTE = 8,
SYNC_PHASE_SAVE_STATE = 9;
public ContactsSyncAdapter(Context context) {
super(context, false);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
Constants.log.info("Starting sync for authority " + authority);
AccountSettings settings = new AccountSettings(getContext(), account);
HttpClient httpClient = new HttpClient(getContext(), settings.getUserName(), settings.getPassword(), settings.getPreemptiveAuth());
HttpUrl addressBookURL = HttpUrl.parse(settings.getAddressBookURL());
DavAddressBook dav = new DavAddressBook(httpClient, addressBookURL);
// dismiss previous error notifications
NotificationManager notificationManager = (NotificationManager)getContext().getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(account.name, NOTIFICATION_ERROR);
// prepare local address book
LocalAddressBook addressBook = new LocalAddressBook(account, provider);
int syncPhase = SYNC_PHASE_QUERY_CAPABILITIES;
try {
// prepare remote address book
boolean hasVCard4 = false;
dav.propfind(0, SupportedAddressData.NAME, GetCTag.NAME);
SupportedAddressData supportedAddressData = (SupportedAddressData)dav.properties.get(SupportedAddressData.NAME);
if (supportedAddressData != null)
for (MediaType type : supportedAddressData.types)
if ("text/vcard; version=4.0".equalsIgnoreCase(type.toString()))
hasVCard4 = true;
Constants.log.info("Server advertises VCard/4 support: " + hasVCard4);
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED;
// 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++;
}
syncPhase = SYNC_PHASE_PREPARE_LOCALLY_CREATED;
// assign file names and UIDs to new contacts so that we can use the file name as an index
localList = addressBook.getWithoutFileName();
for (LocalContact local : localList) {
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);
}
syncPhase = SYNC_PHASE_UPLOAD_DIRTY;
// upload dirty contacts
localList = addressBook.getDirty();
for (LocalContact local : localList) {
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);
}
syncPhase = SYNC_PHASE_CHECK_STATE;
// check CTag (ignore on manual sync)
String currentCTag = null;
GetCTag getCTag = (GetCTag) dav.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");
} else /* remote CTag has changed */ {
syncPhase = SYNC_PHASE_LIST_LOCAL;
// fetch list of local contacts and build hash table to index file name
localList = addressBook.getAll();
Map<String, LocalContact> localContacts = new HashMap<>(localList.length);
for (LocalContact contact : localList) {
Constants.log.debug("Found local contact: " + contact.getFileName());
localContacts.put(contact.getFileName(), contact);
}
syncPhase = SYNC_PHASE_LIST_REMOTE;
// fetch list of remote VCards and build hash table to index file name
Constants.log.info("Listing remote VCards");
dav.queryMemberETags();
Map<String, DavResource> remoteContacts = new HashMap<>(dav.members.size());
for (DavResource vCard : dav.members) {
String fileName = vCard.fileName();
Constants.log.debug("Found remote VCard: " + fileName);
remoteContacts.put(fileName, vCard);
}
syncPhase = SYNC_PHASE_COMPARE_ENTRIES;
/* 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
*/
Set<DavResource> 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());
}
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE;
Constants.log.info("Downloading " + toDownload.size() + " contacts (" + MAX_MULTIGET + " at once)");
Constants.log.info("Starting contacts sync (" + authority + ")");
// prepare downloader which may be used to download external resource like contact photos
Contact.Downloader downloader = new ResourceDownloader(httpClient, addressBookURL);
// download new/updated VCards from server
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
Constants.log.info("Downloading " + TextUtils.join(" + ", bunch));
if (bunch.length == 1) {
// only one contact, use GET
DavResource remote = bunch[0];
String fileName = remote.fileName();
ResponseBody body = remote.get("text/vcard;q=0.5, text/vcard;charset=utf-8;q=0.8, text/vcard;version=4.0");
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);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.location);
dav.multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4);
// process multiget results
for (DavResource remote : dav.members) {
String eTag = null;
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);
}
AddressData addressData = (AddressData)remote.properties.get(AddressData.NAME);
if (addressData == null || addressData.vCard == null)
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);
}
}
}
syncPhase = SYNC_PHASE_SAVE_STATE;
/* 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);
}
} catch (IOException e) {
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);
syncResult.stats.numParseExceptions++;
Intent detailsIntent = new Intent(getContext(), DebugInfoActivity.class);
detailsIntent.putExtra(DebugInfoActivity.KEY_EXCEPTION, e);
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase);
Notification.Builder builder = new Notification.Builder(getContext());
Notification notification = null;
builder .setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(getContext().getString(R.string.sync_error_title, account.name))
.setContentIntent(PendingIntent.getActivity(getContext(), 0, detailsIntent, PendingIntent.FLAG_UPDATE_CURRENT));
String[] phases = getContext().getResources().getStringArray(R.array.sync_error_phases);
if (phases.length > syncPhase)
builder.setContentText(getContext().getString(R.string.sync_error_http, phases[syncPhase]));
if (Build.VERSION.SDK_INT >= 16) {
if (Build.VERSION.SDK_INT >= 21)
builder.setCategory(Notification.CATEGORY_ERROR);
notification = builder.build();
} else {
notification = builder.getNotification();
}
notificationManager.notify(account.name, NOTIFICATION_ERROR, notification);
} catch(DavException e) {
;
} catch(ContactsStorageException e) {
syncResult.databaseError = true;
}
ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, extras, provider, syncResult);
syncManager.performSync();
Constants.log.info("Sync complete for authority " + authority);
}
private void processVCard(SyncResult syncResult, LocalAddressBook addressBook, Map<String, LocalContact>localContacts, 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);
if (localContact != null) {
Constants.log.info("Updating " + fileName + " in local address book");
localContact.eTag = eTag;
localContact.update(newData);
syncResult.stats.numUpdates++;
} else {
Constants.log.info("Adding " + fileName + " to local address book");
localContact = new LocalContact(addressBook, newData, fileName, eTag);
localContact.add();
syncResult.stats.numInserts++;
}
} else
Constants.log.error("Received VCard with not exactly one VCARD, ignoring " + fileName);
}
}
@RequiredArgsConstructor
static class ResourceDownloader implements Contact.Downloader {
final HttpClient httpClient;
final HttpUrl baseUrl;
@Override
public byte[] download(String url, String accepts) {
HttpUrl httpUrl = HttpUrl.parse(url);
HttpClient resourceClient = new HttpClient(httpClient, httpUrl.host());
try {
Response response = resourceClient.newCall(new Request.Builder()
.get()
.url(httpUrl)
.build()).execute();
ResponseBody body = response.body();
if (body != null) {
@Cleanup InputStream stream = body.byteStream();
if (response.isSuccessful() && stream != null) {
return IOUtils.toByteArray(stream);
} else
Constants.log.error("Couldn't download external resource");
}
} catch(IOException e) {
Constants.log.error("Couldn't download external resource", e);
}
return null;
}
}
}

@ -0,0 +1,380 @@
/*
* 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.ContentResolver;
import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import android.text.TextUtils;
import com.google.common.base.Charsets;
import com.squareup.okhttp.HttpUrl;
import com.squareup.okhttp.MediaType;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;
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.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;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.dav4android.property.SupportedAddressData;
import at.bitfire.davdroid.ArrayUtils;
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.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import ezvcard.VCardVersion;
import ezvcard.util.IOUtils;
import lombok.Cleanup;
import lombok.RequiredArgsConstructor;
public class ContactsSyncManager extends SyncManager {
protected static final int
MAX_MULTIGET = 10,
NOTIFICATION_ID = 1;
protected HttpUrl addressBookURL;
protected DavAddressBook davCollection;
protected boolean hasVCard4;
protected LocalAddressBook addressBook;
String currentCTag;
Map<String, LocalContact> localContacts;
Map<String, DavResource> remoteContacts;
Set<DavResource> toDownload;
public ContactsSyncManager(Context context, Account account, Bundle extras, ContentProviderClient provider, SyncResult result) {
super(NOTIFICATION_ID, context, account, extras, provider, result);
}
@Override
protected void prepare() {
addressBookURL = HttpUrl.parse(settings.getAddressBookURL());
davCollection = new DavAddressBook(httpClient, addressBookURL);
// prepare local address book
addressBook = new LocalAddressBook(account, provider);
}
@Override
protected void queryCapabilities() throws DavException, IOException, HttpException {
// prepare remote address book
hasVCard4 = false;
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME);
SupportedAddressData supportedAddressData = (SupportedAddressData) davCollection.properties.get(SupportedAddressData.NAME);
if (supportedAddressData != null)
for (MediaType type : supportedAddressData.types)
if ("text/vcard; version=4.0".equalsIgnoreCase(type.toString()))
hasVCard4 = true;
Constants.log.info("Server advertises VCard/4 support: " + hasVCard4);
}
@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;
}
@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 {
// 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());
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());
}
}
@Override
protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException {
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);
// download new/updated VCards from server
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
Constants.log.info("Downloading " + TextUtils.join(" + ", bunch));
if (bunch.length == 1) {
// only one contact, use GET
DavResource remote = bunch[0];
ResponseBody body = remote.get("text/vcard;q=0.5, text/vcard;charset=utf-8;q=0.8, text/vcard;version=4.0");
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);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.location);
davCollection.multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4);
// 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);
}
AddressData addressData = (AddressData) remote.properties.get(AddressData.NAME);
if (addressData == null || addressData.vCard == null)
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);
}
}
}
}
@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, Map<String, LocalContact>localContacts, 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);
if (localContact != null) {
Constants.log.info("Updating " + fileName + " in local address book");
localContact.eTag = eTag;
localContact.update(newData);
syncResult.stats.numUpdates++;
} else {
Constants.log.info("Adding " + fileName + " to local address book");
localContact = new LocalContact(addressBook, newData, fileName, eTag);
localContact.add();
syncResult.stats.numInserts++;
}
} else
Constants.log.error("Received VCard with not exactly one VCARD, ignoring " + fileName);
}
@RequiredArgsConstructor
static class ResourceDownloader implements Contact.Downloader {
final HttpClient httpClient;
final HttpUrl baseUrl;
@Override
public byte[] download(String url, String accepts) {
HttpUrl httpUrl = HttpUrl.parse(url);
HttpClient resourceClient = new HttpClient(httpClient, httpUrl.host());
try {
Response response = resourceClient.newCall(new Request.Builder()
.get()
.url(httpUrl)
.build()).execute();
ResponseBody body = response.body();
if (body != null) {
@Cleanup InputStream stream = body.byteStream();
if (response.isSuccessful() && stream != null) {
return IOUtils.toByteArray(stream);
} else
Constants.log.error("Couldn't download external resource");
}
} catch(IOException e) {
Constants.log.error("Couldn't download external resource", e);
}
return null;
}
}
}

@ -1,204 +0,0 @@
/*
* 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.annotation.TargetApi;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.http.HttpStatus;
import org.apache.http.impl.client.CloseableHttpClient;
import java.io.Closeable;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import javax.net.ssl.SSLException;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.LocalStorageException;
import at.bitfire.davdroid.resource.WebDavCollection;
import at.bitfire.davdroid.ui.settings.AccountActivity;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.DavHttpClient;
import at.bitfire.davdroid.webdav.HttpException;
import lombok.Getter;
public abstract class DavSyncAdapter extends AbstractThreadedSyncAdapter implements Closeable {
private final static String TAG = "davdroid.DavSyncAdapter";
@Getter private static String androidID;
final protected Context context;
/* We use one static httpClient for
* - all sync adapters (CalendarsSyncAdapter, ContactsSyncAdapter)
* - and all threads (= accounts) of each sync adapter
* so that HttpClient's threaded pool management can do its best.
*/
protected static CloseableHttpClient httpClient;
/* One static read/write lock pair for the static httpClient:
* Use the READ lock when httpClient will only be called (to prevent it from being unset while being used).
* Use the WRITE lock when httpClient will be modified (set/unset). */
private final static ReentrantReadWriteLock httpClientLock = new ReentrantReadWriteLock();
public DavSyncAdapter(Context context) {
super(context, true);
synchronized(this) {
if (androidID == null)
androidID = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
}
this.context = context;
}
@Override
public void close() {
Log.d(TAG, "Closing httpClient");
// may be called from a GUI thread, so we need an AsyncTask
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
try {
httpClientLock.writeLock().lock();
if (httpClient != null) {
httpClient.close();
httpClient = null;
}
httpClientLock.writeLock().unlock();
} catch (IOException e) {
Log.w(TAG, "Couldn't close HTTP client", e);
}
return null;
}
}.execute();
}
protected abstract Map<LocalCollection<?>, WebDavCollection<?>> getSyncPairs(Account account, ContentProviderClient provider);
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
Log.i(TAG, "Performing sync for authority " + authority);
/* Set class loader for iCal4j ResourceLoader this is required because the various
* sync adapters (contacts, events, tasks) share the same :sync process (see AndroidManifest */
Thread.currentThread().setContextClassLoader(getContext().getClassLoader());
// create httpClient, if necessary
httpClientLock.writeLock().lock();
if (httpClient == null) {
Log.d(TAG, "Creating new DavHttpClient");
httpClient = DavHttpClient.create();
}
// prevent httpClient shutdown until we're ready by holding a read lock
// acquiring read lock before releasing write lock will downgrade the write lock to a read lock
httpClientLock.readLock().lock();
httpClientLock.writeLock().unlock();
Exception exceptionToShow = null; // exception to show notification for
Intent exceptionIntent = null; // what shall happen when clicking on the exception notification
try {
// get local <-> remote collection pairs
Map<LocalCollection<?>, WebDavCollection<?>> syncCollections = getSyncPairs(account, provider);
if (syncCollections == null)
Log.i(TAG, "Nothing to synchronize");
else
try {
for (Map.Entry<LocalCollection<?>, WebDavCollection<?>> entry : syncCollections.entrySet())
new SyncManager(entry.getKey(), entry.getValue()).synchronize(extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL), syncResult);
} catch (DavException ex) {
syncResult.stats.numParseExceptions++;
Log.e(TAG, "Invalid DAV response", ex);
exceptionToShow = ex;
} catch (HttpException ex) {
if (ex.getCode() == HttpStatus.SC_UNAUTHORIZED) {
Log.e(TAG, "HTTP Unauthorized " + ex.getCode(), ex);
syncResult.stats.numAuthExceptions++; // hard error
exceptionToShow = ex;
exceptionIntent = new Intent(context, AccountActivity.class);
exceptionIntent.putExtra(AccountActivity.EXTRA_ACCOUNT, account);
} else if (ex.isClientError()) {
Log.e(TAG, "Hard HTTP error " + ex.getCode(), ex);
syncResult.stats.numParseExceptions++; // hard error
exceptionToShow = ex;
} else {
Log.w(TAG, "Soft HTTP error " + ex.getCode() + " (Android will try again later)", ex);
syncResult.stats.numIoExceptions++; // soft error
}
} catch (LocalStorageException ex) {
syncResult.databaseError = true; // hard error
Log.e(TAG, "Local storage (content provider) exception", ex);
exceptionToShow = ex;
} catch (IOException ex) {
syncResult.stats.numIoExceptions++; // soft error
Log.e(TAG, "I/O error (Android will try again later)", ex);
if (ex instanceof SSLException) // always notify on SSL/TLS errors
exceptionToShow = ex;
} catch (URISyntaxException ex) {
syncResult.stats.numParseExceptions++; // hard error
Log.e(TAG, "Invalid URI (file name) syntax", ex);
exceptionToShow = ex;
}
} finally {
// allow httpClient shutdown
httpClientLock.readLock().unlock();
}
// show sync errors as notification
if (exceptionToShow != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
if (exceptionIntent == null)
exceptionIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(Constants.WEB_URL_VIEW_LOGS));
PendingIntent contentIntent = PendingIntent.getActivity(context, 0, exceptionIntent, 0);
Notification.Builder builder = new Notification.Builder(context)
.setSmallIcon(R.drawable.ic_launcher)
.setPriority(Notification.PRIORITY_LOW)
.setOnlyAlertOnce(true)
.setWhen(System.currentTimeMillis())
.setContentTitle(context.getString(R.string.sync_error_title))
.setContentText(exceptionToShow.getLocalizedMessage())
.setContentInfo(account.name)
.setStyle(new Notification.BigTextStyle().bigText(account.name + ":\n" + ExceptionUtils.getStackTrace(exceptionToShow)))
.setContentIntent(contentIntent);
NotificationManager notificationManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.notify(account.name.hashCode(), builder.build());
}
Log.i(TAG, "Sync complete for " + authority);
}
}

@ -7,210 +7,171 @@
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ContentProviderClient;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.util.Log;
import android.os.Build;
import android.os.Bundle;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.HashSet;
import java.util.Set;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.LocalStorageException;
import at.bitfire.davdroid.resource.RecordNotFoundException;
import at.bitfire.davdroid.resource.Resource;
import at.bitfire.davdroid.resource.WebDavCollection;
import at.bitfire.davdroid.webdav.ConflictException;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.ForbiddenException;
import at.bitfire.davdroid.webdav.HttpException;
import at.bitfire.davdroid.webdav.NotFoundException;
import at.bitfire.davdroid.webdav.PreconditionFailedException;
import at.bitfire.davdroid.webdav.WebDavResource;
public class SyncManager {
private static final String TAG = "davdroid.SyncManager";
private static final int MAX_MULTIGET_RESOURCES = 35;
final protected LocalCollection<? extends Resource> local;
final protected WebDavCollection<? extends Resource> remote;
public SyncManager(LocalCollection<? extends Resource> local, WebDavCollection<? extends Resource> remote) {
this.local = local;
this.remote = remote;
}
public void synchronize(boolean manualSync, SyncResult syncResult) throws URISyntaxException, LocalStorageException, IOException, HttpException, DavException {
// PHASE 1: fetch collection properties
remote.getProperties();
final WebDavResource.Properties collectionProperties = remote.getCollection().getProperties();
local.updateMetaData(collectionProperties);
// PHASE 2: push local changes to server
int deletedRemotely = pushDeleted(),
addedRemotely = pushNew(),
updatedRemotely = pushDirty();
// PHASE 3A: check if there's a reason to do a sync with remote (= forced sync or remote CTag changed)
boolean syncMembers = (deletedRemotely + addedRemotely + updatedRemotely) > 0;
if (manualSync) {
Log.i(TAG, "Full synchronization forced");
syncMembers = true;
}
if (!syncMembers) {
final String
currentCTag = collectionProperties.getCTag(),
lastCTag = local.getCTag();
Log.d(TAG, "Last local CTag = " + lastCTag + "; current remote CTag = " + currentCTag);
if (currentCTag == null || !currentCTag.equals(lastCTag))
syncMembers = true;
}
if (!syncMembers) {
Log.i(TAG, "No local changes and CTags match, no need to sync");
return;
}
// PHASE 3B: detect details of remote changes
Log.i(TAG, "Fetching remote resource list");
Set<Resource> remotelyAdded = new HashSet<>(),
remotelyUpdated = new HashSet<>();
Resource[] remoteResources = remote.getMemberETags();
for (Resource remoteResource : remoteResources) {
try {
Resource localResource = local.findByRemoteName(remoteResource.getName(), false);
if (localResource.getETag() == null || !localResource.getETag().equals(remoteResource.getETag()))
remotelyUpdated.add(remoteResource);
} catch(RecordNotFoundException e) {
remotelyAdded.add(remoteResource);
}
}
// PHASE 4: pull remote changes from server
syncResult.stats.numInserts = pullNew(remotelyAdded.toArray(new Resource[remotelyAdded.size()]));
syncResult.stats.numUpdates = pullChanged(remotelyUpdated.toArray(new Resource[remotelyUpdated.size()]));
Log.i(TAG, "Removing entries that are not present remotely anymore (retaining " + remoteResources.length + " entries)");
syncResult.stats.numDeletes = local.deleteAllExceptRemoteNames(remoteResources);
syncResult.stats.numEntries = syncResult.stats.numInserts + syncResult.stats.numUpdates + syncResult.stats.numDeletes;
// update collection CTag
Log.i(TAG, "Sync complete, fetching new CTag");
local.setCTag(collectionProperties.getCTag());
}
private int pushDeleted() throws URISyntaxException, LocalStorageException, IOException, HttpException {
int count = 0;
long[] deletedIDs = local.findDeleted();
try {
Log.i(TAG, "Remotely removing " + deletedIDs.length + " deleted resource(s) (if not changed)");
for (long id : deletedIDs)
try {
Resource res = local.findById(id, false);
if (res.getName() != null) // is this resource even present remotely?
try {
remote.delete(res);
} catch(NotFoundException e) {
Log.i(TAG, "Locally-deleted resource has already been removed from server");
} catch(PreconditionFailedException|ConflictException e) {
Log.i(TAG, "Locally-deleted resource has been changed on the server in the meanwhile");
}
local.delete(res);
count++;
} catch (RecordNotFoundException e) {
Log.wtf(TAG, "Couldn't read locally-deleted record", e);
}
} finally {
local.commit();
}
return count;
}
private int pushNew() throws URISyntaxException, LocalStorageException, IOException, HttpException {
int count = 0;
long[] newIDs = local.findNew();
Log.i(TAG, "Uploading " + newIDs.length + " new resource(s) (if not existing)");
try {
for (long id : newIDs)
try {
Resource res = local.findById(id, true);
String eTag = remote.add(res);
if (eTag != null)
local.updateETag(res, eTag);
local.clearDirty(res);
count++;
} catch (ConflictException|PreconditionFailedException e) {
Log.i(TAG, "Didn't overwrite existing resource with other content");
} catch (RecordNotFoundException e) {
Log.wtf(TAG, "Couldn't read new record", e);
}
} finally {
local.commit();
}
return count;
}
private int pushDirty() throws URISyntaxException, LocalStorageException, IOException, HttpException {
int count = 0;
long[] dirtyIDs = local.findUpdated();
Log.i(TAG, "Uploading " + dirtyIDs.length + " modified resource(s) (if not changed)");
try {
for (long id : dirtyIDs) {
try {
Resource res = local.findById(id, true);
String eTag = remote.update(res);
if (eTag != null)
local.updateETag(res, eTag);
local.clearDirty(res);
count++;
} catch (ForbiddenException e) {
Log.w(TAG, "Server has rejected local changes, server wins", e);
} catch (ConflictException|PreconditionFailedException e) {
Log.i(TAG, "Locally changed resource has been changed on the server in the meanwhile", e);
} catch (RecordNotFoundException e) {
Log.e(TAG, "Couldn't read dirty record", e);
}
}
} finally {
local.commit();
}
return count;
}
private int pullNew(Resource[] resourcesToAdd) throws URISyntaxException, LocalStorageException, IOException, HttpException, DavException {
int count = 0;
Log.i(TAG, "Fetching " + resourcesToAdd.length + " new remote resource(s)");
for (Resource[] resources : ArrayUtils.partition(resourcesToAdd, MAX_MULTIGET_RESOURCES))
for (Resource res : remote.multiGet(resources)) {
Log.d(TAG, "Adding " + res.getName());
local.add(res);
count++;
}
return count;
}
private int pullChanged(Resource[] resourcesToUpdate) throws URISyntaxException, LocalStorageException, IOException, HttpException, DavException {
int count = 0;
Log.i(TAG, "Fetching " + resourcesToUpdate.length + " updated remote resource(s)");
for (Resource[] resources : ArrayUtils.partition(resourcesToUpdate, MAX_MULTIGET_RESOURCES))
for (Resource res : remote.multiGet(resources)) {
Log.i(TAG, "Updating " + res.getName());
local.updateByRemoteName(res);
count++;
}
return count;
}
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.ui.DebugInfoActivity;
import at.bitfire.vcard4android.ContactsStorageException;
abstract public class SyncManager {
protected final int SYNC_PHASE_PREPARE = 0,
SYNC_PHASE_QUERY_CAPABILITIES = 1,
SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2,
SYNC_PHASE_PREPARE_LOCALLY_CREATED = 3,
SYNC_PHASE_UPLOAD_DIRTY = 4,
SYNC_PHASE_CHECK_SYNC_STATE = 5,
SYNC_PHASE_LIST_LOCAL = 6,
SYNC_PHASE_LIST_REMOTE = 7,
SYNC_PHASE_COMPARE_ENTRIES = 8,
SYNC_PHASE_DOWNLOAD_REMOTE = 9,
SYNC_PHASE_SAVE_SYNC_STATE = 10;
final NotificationManager notificationManager;
final int notificationId;
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;
this.account = account;
this.extras = extras;
this.provider = provider;
this.syncResult = syncResult;
// get account settings and generate httpClient
settings = new AccountSettings(context, account);
httpClient = new HttpClient(context, settings.getUserName(), settings.getPassword(), settings.getPreemptiveAuth());
// dismiss previous error notifications
notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(account.name, this.notificationId = notificationId);
}
public void performSync() {
int syncPhase = SYNC_PHASE_PREPARE;
try {
prepare();
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES;
queryCapabilities();
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED;
processLocallyDeleted();
syncPhase = SYNC_PHASE_PREPARE_LOCALLY_CREATED;
processLocallyCreated();
syncPhase = SYNC_PHASE_UPLOAD_DIRTY;
uploadDirty();
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE;
if (checkSyncState()) {
syncPhase = SYNC_PHASE_LIST_LOCAL;
listLocal();
syncPhase = SYNC_PHASE_LIST_REMOTE;
listRemote();
syncPhase = SYNC_PHASE_COMPARE_ENTRIES;
compareEntries();
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE;
downloadRemote();
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE;
saveSyncState();
} else
Constants.log.info("Remote collection didn't change, skipping remote sync");
} catch (IOException e) {
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);
syncResult.stats.numParseExceptions++;
Intent detailsIntent = new Intent(context, DebugInfoActivity.class);
detailsIntent.putExtra(DebugInfoActivity.KEY_EXCEPTION, e);
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase);
Notification.Builder builder = new Notification.Builder(context);
Notification notification;
builder .setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(context.getString(R.string.sync_error_title, account.name))
.setContentIntent(PendingIntent.getActivity(context, 0, detailsIntent, PendingIntent.FLAG_UPDATE_CURRENT));
String[] phases = context.getResources().getStringArray(R.array.sync_error_phases);
if (phases.length > syncPhase)
builder.setContentText(context.getString(R.string.sync_error_http, phases[syncPhase]));
if (Build.VERSION.SDK_INT >= 16) {
if (Build.VERSION.SDK_INT >= 21)
builder.setCategory(Notification.CATEGORY_ERROR);
notification = builder.build();
} else {
notification = builder.getNotification();
}
notificationManager.notify(account.name, notificationId, notification);
} catch(DavException e) {
// TODO
} catch(ContactsStorageException e) {
syncResult.databaseError = true;
}
}
abstract protected void prepare();
abstract protected void queryCapabilities() throws IOException, HttpException, DavException, ContactsStorageException;
abstract protected void processLocallyDeleted() throws IOException, HttpException, DavException, ContactsStorageException;
abstract protected void processLocallyCreated() throws IOException, HttpException, DavException, ContactsStorageException;
abstract protected void uploadDirty() throws IOException, HttpException, DavException, ContactsStorageException;
/**
* 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
*/
abstract protected boolean checkSyncState() throws IOException, HttpException, DavException, ContactsStorageException;
abstract protected void listLocal() throws IOException, HttpException, DavException, ContactsStorageException;
abstract protected void listRemote() throws IOException, HttpException, DavException, ContactsStorageException;
abstract protected void compareEntries() throws IOException, HttpException, DavException, ContactsStorageException;
abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException;
abstract protected void saveSyncState() throws IOException, HttpException, DavException, ContactsStorageException;
}

@ -9,21 +9,13 @@ package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.app.Service;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import at.bitfire.davdroid.resource.CalDavTaskList;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.davdroid.resource.WebDavCollection;
public class TasksSyncAdapterService extends Service {
private static SyncAdapter syncAdapter;
@ -36,7 +28,6 @@ public class TasksSyncAdapterService extends Service {
@Override
public void onDestroy() {
syncAdapter.close();
syncAdapter = null;
}
@ -46,35 +37,15 @@ public class TasksSyncAdapterService extends Service {
}
private static class SyncAdapter extends DavSyncAdapter {
private final static String TAG = "davdroid.TasksSync";
private static class SyncAdapter extends AbstractThreadedSyncAdapter {
public SyncAdapter(Context context) {
super(context, false);
}
private SyncAdapter(Context context) {
super(context);
}
@Override
protected Map<LocalCollection<?>, WebDavCollection<?>> getSyncPairs(Account account, ContentProviderClient provider) {
AccountSettings settings = new AccountSettings(getContext(), account);
String userName = settings.getUserName(),
password = settings.getPassword();
boolean preemptive = settings.getPreemptiveAuth();
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
try {
Map<LocalCollection<?>, WebDavCollection<?>> map = new HashMap<>();
}
}
for (LocalTaskList calendar : LocalTaskList.findAll(account, provider)) {
WebDavCollection<?> dav = new CalDavTaskList(httpClient, calendar.getUrl(), userName, password, preemptive);
map.put(calendar, dav);
}
return map;
} catch (RemoteException ex) {
Log.e(TAG, "Couldn't find local task lists", ex);
} catch (URISyntaxException ex) {
Log.e(TAG, "Couldn't build task list URI", ex);
}
return null;
}
}
}

@ -20,7 +20,6 @@ import android.provider.CalendarContract;
import android.provider.ContactsContract;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.davdroid.syncadapter.AccountSettings;
import ezvcard.VCardVersion;
@ -120,7 +119,7 @@ public class AccountFragment extends PreferenceFragment {
prefSyncCalendars.setSummary(R.string.settings_sync_summary_not_available);
}
final ListPreference prefSyncTasks = (ListPreference)findPreference("sync_interval_tasks");
/*final ListPreference prefSyncTasks = (ListPreference)findPreference("sync_interval_tasks");
final Long syncIntervalTasks = settings.getSyncInterval(LocalTaskList.TASKS_AUTHORITY);
if (syncIntervalTasks != null) {
prefSyncTasks.setValue(syncIntervalTasks.toString());
@ -139,7 +138,7 @@ public class AccountFragment extends PreferenceFragment {
} else {
prefSyncTasks.setEnabled(false);
prefSyncTasks.setSummary(R.string.settings_sync_summary_not_available);
}
}*/
// category: address book
final CheckBoxPreference prefVCard4 = (CheckBoxPreference) findPreference("vcard4_support");

@ -14,7 +14,6 @@ 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;
@ -34,9 +33,6 @@ 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.LocalStorageException;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.davdroid.resource.ServerInfo;
import at.bitfire.davdroid.syncadapter.AccountSettings;
import at.bitfire.vcard4android.ContactsStorageException;
@ -99,7 +95,7 @@ public class AccountDetailsFragment extends Fragment implements TextWatcher {
if (accountManager.addAccountExplicitly(account, serverInfo.getPassword(), userData)) {
addSync(account, ContactsContract.AUTHORITY, serverInfo.getAddressBooks(), new AddSyncCallback() {
@Override
public void createLocalCollection(Account account, ServerInfo.ResourceInfo resource) throws LocalStorageException, ContactsStorageException {
public void createLocalCollection(Account account, ServerInfo.ResourceInfo resource) throws ContactsStorageException {
@Cleanup("release") ContentProviderClient provider = getActivity().getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY);
LocalAddressBook addressBook = new LocalAddressBook(account, provider);
ContentValues settings = new ContentValues(2);
@ -109,10 +105,10 @@ 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);
LocalCalendar.create(account, getActivity().getContentResolver(), calendar);
}
});
@ -121,7 +117,7 @@ public class AccountDetailsFragment extends Fragment implements TextWatcher {
public void createLocalCollection(Account account, ServerInfo.ResourceInfo todoList) throws LocalStorageException {
LocalTaskList.create(account, getActivity().getContentResolver(), todoList);
}
});
});*/
getActivity().finish();
} else
@ -129,7 +125,7 @@ public class AccountDetailsFragment extends Fragment implements TextWatcher {
}
protected interface AddSyncCallback {
void createLocalCollection(Account account, ServerInfo.ResourceInfo resource) throws LocalStorageException, ContactsStorageException;
void createLocalCollection(Account account, ServerInfo.ResourceInfo resource) throws ContactsStorageException;
}
protected void addSync(Account account, String authority, List<ServerInfo.ResourceInfo> resourceList, AddSyncCallback callback) {
@ -140,7 +136,7 @@ public class AccountDetailsFragment extends Fragment implements TextWatcher {
if (callback != null)
try {
callback.createLocalCollection(account, resource);
} catch(LocalStorageException|ContactsStorageException e) {
} catch(ContactsStorageException e) {
Log.e(TAG, "Couldn't add sync collection", e);
Toast.makeText(getActivity(), "Couldn't set up synchronization for " + authority, Toast.LENGTH_LONG).show();
}

@ -12,6 +12,7 @@ import android.app.Fragment;
import android.app.FragmentTransaction;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.Menu;
@ -26,8 +27,6 @@ import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;
import org.apache.commons.lang3.StringUtils;
import java.net.URI;
import java.net.URISyntaxException;
@ -128,7 +127,7 @@ public class LoginURLFragment extends Fragment implements TextWatcher {
// check host name
try {
if (!StringUtils.isBlank(getBaseURI().getHost()))
if (!TextUtils.isEmpty(getBaseURI().getHost()))
urlOk = true;
} catch (Exception e) {
}

@ -20,18 +20,14 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import org.apache.commons.lang3.exception.ExceptionUtils;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.cert.CertPathValidatorException;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.DavResourceFinder;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.davdroid.resource.ServerInfo;
public class QueryServerDialogFragment extends DialogFragment implements LoaderCallbacks<ServerInfo> {
@ -72,7 +68,7 @@ public class QueryServerDialogFragment extends DialogFragment implements LoaderC
((AddAccountActivity)getActivity()).serverInfo = serverInfo;
Fragment nextFragment;
if (!serverInfo.getTaskLists().isEmpty() && !LocalTaskList.isAvailable(getActivity()))
if (!serverInfo.getTaskLists().isEmpty() /*&& !LocalTaskList.isAvailable(getActivity())*/)
nextFragment = new InstallAppsFragment();
else
nextFragment = new SelectCollectionsFragment();
@ -120,8 +116,8 @@ public class QueryServerDialogFragment extends DialogFragment implements LoaderC
// general message
serverInfo.setErrorMessage(getContext().getString(R.string.exception_io, e.getLocalizedMessage()));
// overwrite by more specific message, if possible
if (ExceptionUtils.indexOfType(e, CertPathValidatorException.class) != -1)
serverInfo.setErrorMessage(getContext().getString(R.string.exception_cert_path_validation, e.getMessage()));
/*if (ExceptionUtils.indexOfType(e, CertPathValidatorException.class) != -1)
serverInfo.setErrorMessage(getContext().getString(R.string.exception_cert_path_validation, e.getMessage()));*/
} catch (HttpException e) {
Log.e(TAG, "HTTP error while querying server info", e);
serverInfo.setErrorMessage(getContext().getString(R.string.exception_http, e.getLocalizedMessage()));

@ -18,7 +18,6 @@ import android.widget.CheckedTextView;
import android.widget.ListAdapter;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.davdroid.resource.ServerInfo;
import lombok.Getter;
@ -172,7 +171,7 @@ public class SelectCollectionsAdapter extends BaseAdapter implements ListAdapter
}
// disable task list selection if there's no local task provider
if (viewType == TYPE_TASK_LISTS_ROW && !LocalTaskList.isAvailable(context)) {
if (viewType == TYPE_TASK_LISTS_ROW /*&& !LocalTaskList.isAvailable(context)*/) {
final CheckedTextView check = (CheckedTextView)v;
check.setEnabled(false);
check.setOnClickListener(new View.OnClickListener() {

@ -1,17 +0,0 @@
/*
* 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.webdav;
import org.apache.http.HttpStatus;
public class ConflictException extends HttpException {
public ConflictException(String reason) {
super(HttpStatus.SC_CONFLICT, reason);
}
}

@ -1,21 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Namespace;
import org.simpleframework.xml.NamespaceList;
import org.simpleframework.xml.Root;
@Root(name="addressbook-multiget")
@NamespaceList({
@Namespace(reference="DAV:"),
@Namespace(prefix="CD",reference="urn:ietf:params:xml:ns:carddav")
})
@Namespace(prefix="CD",reference="urn:ietf:params:xml:ns:carddav")
public class DavAddressbookMultiget extends DavMultiget {
}

@ -1,21 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Namespace;
import org.simpleframework.xml.NamespaceList;
import org.simpleframework.xml.Root;
@Root(name="calendar-multiget")
@NamespaceList({
@Namespace(reference="DAV:"),
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
})
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
public class DavCalendarMultiget extends DavMultiget {
}

@ -1,31 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.Namespace;
import org.simpleframework.xml.NamespaceList;
import org.simpleframework.xml.Root;
import lombok.Getter;
import lombok.Setter;
@Root(name="calendar-query")
@NamespaceList({
@Namespace(reference="DAV:"),
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
})
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
public class DavCalendarQuery {
@Element
@Getter @Setter DavProp prop;
@Element
@Getter @Setter DavFilter filter;
}

@ -1,29 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Attribute;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.Namespace;
import lombok.Getter;
import lombok.Setter;
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
public class DavCompFilter {
public DavCompFilter(String name) {
this.name = name;
}
@Attribute(required=false)
final String name;
@Element(required=false,name="comp-filter")
@Getter @Setter DavCompFilter compFilter;
}

@ -1,25 +0,0 @@
/*
* 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.webdav;
public class DavException extends Exception {
private static final long serialVersionUID = -2118919144443165706L;
final private static String prefix = "Invalid DAV response: ";
/* used to indiciate DAV protocol errors */
public DavException(String message) {
super(prefix + message);
}
public DavException(String message, Throwable ex) {
super(prefix + message, ex);
}
}

@ -1,21 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.Namespace;
import lombok.Getter;
import lombok.Setter;
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
public class DavFilter {
@Element(required=false,name="comp-filter")
@Getter @Setter DavCompFilter compFilter;
}

@ -1,26 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Namespace;
import org.simpleframework.xml.Root;
import org.simpleframework.xml.Text;
@Root(name="href")
@Namespace(prefix="D",reference="DAV:")
public class DavHref {
@Text
String href;
DavHref() {
}
public DavHref(String href) {
this.href = href;
}
}

@ -1,71 +0,0 @@
/*
* 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.webdav;
import android.annotation.SuppressLint;
import android.util.Log;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import at.bitfire.davdroid.BuildConfig;
import at.bitfire.davdroid.Constants;
public class DavHttpClient {
private final static String TAG = "davdroid.DavHttpClient";
private final static RequestConfig defaultRqConfig;
private final static Registry<ConnectionSocketFactory> socketFactoryRegistry;
static {
socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory> create()
.register("http", PlainConnectionSocketFactory.getSocketFactory())
.register("https", TlsSniSocketFactory.getSocketFactory())
.build();
// use request defaults from AndroidHttpClient
defaultRqConfig = RequestConfig.copy(RequestConfig.DEFAULT)
.setConnectTimeout(20*1000)
.setSocketTimeout(45*1000)
.setStaleConnectionCheckEnabled(false)
.build();
}
@SuppressLint("LogTagMismatch")
public static CloseableHttpClient create() {
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(socketFactoryRegistry);
// limits per DavHttpClient (= per DavSyncAdapter extends AbstractThreadedSyncAdapter)
connectionManager.setMaxTotal(3); // max. 3 connections in total
connectionManager.setDefaultMaxPerRoute(2); // max. 2 connections per host
HttpClientBuilder builder = HttpClients.custom()
.useSystemProperties()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(defaultRqConfig)
.setRetryHandler(DavHttpRequestRetryHandler.INSTANCE)
.setRedirectStrategy(DavRedirectStrategy.INSTANCE)
.setUserAgent("DAVdroid/" + BuildConfig.VERSION_NAME);
if (Log.isLoggable("Wire", Log.DEBUG)) {
Log.i(TAG, "Wire logging active, disabling HTTP compression");
builder = builder.disableContentCompression();
}
return builder.build();
}
}

@ -1,34 +0,0 @@
/*
* 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.webdav;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.http.HttpRequest;
import org.apache.http.impl.client.DefaultHttpRequestRetryHandlerHC4;
import java.util.Locale;
public class DavHttpRequestRetryHandler extends DefaultHttpRequestRetryHandlerHC4 {
final static DavHttpRequestRetryHandler INSTANCE = new DavHttpRequestRetryHandler();
// see http://www.iana.org/assignments/http-methods/http-methods.xhtml
private final static String idempotentMethods[] = {
"DELETE", "GET", "HEAD", "MKCALENDAR", "MKCOL", "OPTIONS", "PROPFIND", "PROPPATCH",
"PUT", "REPORT", "SEARCH", "TRACE"
};
public DavHttpRequestRetryHandler() {
super(/* retry count */ 3, /* retry already sent requests? */ false);
}
@Override
protected boolean handleAsIdempotent(final HttpRequest request) {
final String method = request.getRequestLine().getMethod().toUpperCase(Locale.ROOT);
return ArrayUtils.contains(idempotentMethods, method);
}
}

@ -1,18 +0,0 @@
/*
* 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.webdav;
public class DavIncapableException extends DavException {
private static final long serialVersionUID = -7199786680939975667L;
/* used to indicate that the server doesn't support DAV */
public DavIncapableException(String msg) {
super(msg);
}
}

@ -1,57 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.ElementList;
import org.simpleframework.xml.Order;
import java.util.ArrayList;
@Order(elements={"prop","href"})
public class DavMultiget {
public enum Type {
ADDRESS_BOOK,
ADDRESS_BOOK_V4,
CALENDAR
}
@Element
DavProp prop;
@ElementList(inline=true)
ArrayList<DavHref> hrefs;
public static DavMultiget newRequest(Type type, String names[]) {
DavMultiget multiget = (type == Type.CALENDAR) ? new DavCalendarMultiget() : new DavAddressbookMultiget();
multiget.prop = new DavProp();
multiget.prop.getetag = new DavProp.GetETag();
switch (type) {
case ADDRESS_BOOK:
multiget.prop.addressData = new DavProp.AddressData();
break;
case ADDRESS_BOOK_V4:
DavProp.AddressData addressData = new DavProp.AddressData();
addressData.setContentType("text/vcard");
addressData.setVersion("4.0");
multiget.prop.addressData = addressData;
break;
case CALENDAR:
multiget.prop.calendarData = new DavProp.CalendarData();
}
multiget.hrefs = new ArrayList<>(names.length);
for (String name : names)
multiget.hrefs.add(new DavHref(name));
return multiget;
}
}

@ -1,22 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.ElementList;
import org.simpleframework.xml.Namespace;
import org.simpleframework.xml.Root;
import java.util.ArrayList;
import java.util.List;
@Namespace(reference="DAV:")
@Root(strict=false)
public class DavMultistatus {
@ElementList(inline=true,entry="response",required=false)
ArrayList<DavResponse> response;
}

@ -1,18 +0,0 @@
/*
* 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.webdav;
public class DavNoContentException extends DavException {
private static final long serialVersionUID = 6256645020350945477L;
private final static String message = "HTTP response entity (content) expected but not received";
public DavNoContentException() {
super(message);
}
}

@ -1,18 +0,0 @@
/*
* 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.webdav;
public class DavNoMultiStatusException extends DavException {
private static final long serialVersionUID = -3600405724694229828L;
private final static String message = "207 Multi-Status expected but not received";
public DavNoMultiStatusException() {
super(message);
}
}

@ -1,218 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Attribute;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.ElementList;
import org.simpleframework.xml.Namespace;
import org.simpleframework.xml.Root;
import org.simpleframework.xml.Text;
import java.util.ArrayList;
import lombok.Getter;
import lombok.Setter;
@Namespace(prefix="D",reference="DAV:")
@Root(strict=false)
public class DavProp {
/* RFC 4918 WebDAV */
@Element(required=false)
ResourceType resourcetype;
@Element(required=false)
DisplayName displayname;
@Element(required=false)
GetCTag getctag;
@Element(required=false)
@Setter GetETag getetag;
@Root(strict=false)
public static class ResourceType {
@Element(required=false)
@Getter private Collection collection;
public static class Collection { }
@Element(required=false)
@Getter private Addressbook addressbook;
@Namespace(prefix="CD",reference="urn:ietf:params:xml:ns:carddav")
public static class Addressbook { }
@Element(required=false)
@Getter private Calendar calendar;
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
public static class Calendar { }
}
public static class DisplayName {
@Text(required=false)
@Getter private String displayName;
}
@Namespace(prefix="CS",reference="http://calendarserver.org/ns/")
public static class GetCTag {
@Text(required=false)
@Getter private String CTag;
}
public static class GetETag {
@Text(required=false)
@Getter private String ETag;
}
/* RFC 5397 WebDAV Current Principal Extension */
@Element(required=false,name="current-user-principal")
CurrentUserPrincipal currentUserPrincipal;
public static class CurrentUserPrincipal {
@Element(required=false)
@Getter private DavHref href;
}
/* RFC 3744 WebDAV Access Control Protocol */
@ElementList(required=false,name="current-user-privilege-set",entry="privilege")
ArrayList<Privilege> currentUserPrivilegeSet;
public static class Privilege {
@Element(required=false)
@Getter private PrivAll all;
@Element(required=false)
@Getter private PrivBind bind;
@Element(required=false)
@Getter private PrivUnbind unbind;
@Element(required=false)
@Getter private PrivWrite write;
@Element(required=false,name="write-content")
@Getter private PrivWriteContent writeContent;
public static class PrivAll { }
public static class PrivBind { }
public static class PrivUnbind { }
public static class PrivWrite { }
public static class PrivWriteContent { }
}
/* RFC 4791 CalDAV, RFC 6352 CardDAV */
@Element(required=false,name="addressbook-home-set")
AddressbookHomeSet addressbookHomeSet;
@Element(required=false,name="calendar-home-set")
CalendarHomeSet calendarHomeSet;
@Element(required=false,name="addressbook-description")
AddressbookDescription addressbookDescription;
@Namespace(prefix="CD",reference="urn:ietf:params:xml:ns:carddav")
@ElementList(required=false,name="supported-address-data",entry="address-data-type")
ArrayList<AddressDataType> supportedAddressData;
@Element(required=false,name="calendar-description")
CalendarDescription calendarDescription;
@Element(required=false,name="calendar-color")
CalendarColor calendarColor;
@Element(required=false,name="calendar-timezone")
CalendarTimezone calendarTimezone;
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
@ElementList(required=false,name="supported-calendar-component-set",entry="comp")
ArrayList<Comp> supportedCalendarComponentSet;
@Element(name="address-data",required=false)
AddressData addressData;
@Element(name="calendar-data",required=false)
CalendarData calendarData;
@Namespace(prefix="CD",reference="urn:ietf:params:xml:ns:carddav")
public static class AddressbookHomeSet {
@Element(required=false)
@Getter private DavHref href;
}
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
public static class CalendarHomeSet {
@Element(required=false)
@Getter private DavHref href;
}
@Namespace(prefix="CD",reference="urn:ietf:params:xml:ns:carddav")
public static class AddressbookDescription {
@Text(required=false)
@Getter private String description;
}
@Namespace(prefix="CD",reference="urn:ietf:params:xml:ns:carddav")
public static class AddressDataType {
@Attribute(name="content-type")
@Getter private String contentType;
@Attribute
@Getter private String version;
}
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
public static class CalendarDescription {
@Text(required=false)
@Getter private String description;
}
@Namespace(prefix="A",reference="http://apple.com/ns/ical/")
public static class CalendarColor {
@Text(required=false)
@Getter private String color;
}
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
public static class CalendarTimezone {
@Text(required=false)
@Getter private String timezone;
}
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
public static class Comp {
@Attribute
@Getter String name;
}
@Namespace(prefix="CD",reference="urn:ietf:params:xml:ns:carddav")
public static class AddressData {
@Attribute(name="content-type", required=false)
@Getter @Setter String contentType;
@Attribute(required=false)
@Getter @Setter String version;
@Text(required=false)
@Getter String vcard;
}
@Namespace(prefix="C",reference="urn:ietf:params:xml:ns:caldav")
public static class CalendarData {
@Text(required=false)
@Getter String ical;
}
}

@ -1,19 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.Namespace;
import org.simpleframework.xml.Root;
@Namespace(reference="DAV:")
@Root(name="propfind")
public class DavPropfind {
@Element(required=false)
protected DavProp prop;
}

@ -1,20 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.Root;
@Root(strict=false,name="propstat")
public class DavPropstat {
@Element
DavProp prop;
@Element
String status;
}

@ -1,108 +0,0 @@
/*
* 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.webdav;
import android.util.Log;
import org.apache.http.Header;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.ProtocolException;
import org.apache.http.RequestLine;
import org.apache.http.client.RedirectStrategy;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.protocol.HttpContext;
import java.net.URI;
import java.net.URISyntaxException;
import at.bitfire.davdroid.URIUtils;
/**
* Custom Redirect Strategy that handles 30x for CalDAV/CardDAV-specific requests correctly
*/
public class DavRedirectStrategy implements RedirectStrategy {
private final static String TAG = "davdroid.DavRedirectStrategy";
public final static DavRedirectStrategy INSTANCE = new DavRedirectStrategy();
protected final static String REDIRECTABLE_METHODS[] = {
"OPTIONS", "GET", "PUT", "DELETE"
};
@Override
public HttpUriRequest getRedirect(HttpRequest request, HttpResponse response, HttpContext context) throws ProtocolException {
RequestLine line = request.getRequestLine();
String location = getLocation(request, response, context).toString();
Log.i(TAG, "Following redirection: " + line.getMethod() + " " + line.getUri() + " -> " + location);
return RequestBuilder
.copy(request)
.setUri(location)
.removeHeaders("Content-Length") // Content-Length will be set again automatically, if required;
// remove it now to avoid duplicate header
.build();
}
/**
* Determines whether a response indicates a redirection and if it does, whether to follow this redirection.
* PROPFIND and REPORT must handle redirections explicitely because multi-status processing requires knowledge of the content location.
* @return true for 3xx responses on OPTIONS, GET, PUT, DELETE requests that have a valid Location header; false otherwise
*/
@Override
public boolean isRedirected(HttpRequest request, HttpResponse response, HttpContext context) throws ProtocolException {
if (response.getStatusLine().getStatusCode()/100 == 3) {
boolean redirectable = false;
for (String method : REDIRECTABLE_METHODS)
if (method.equalsIgnoreCase(request.getRequestLine().getMethod())) {
redirectable = true;
break;
}
return redirectable && getLocation(request, response, context) != null;
}
return false;
}
/**
* Gets the destination of a redirection
* @return absolute URL of new location; null if not available
*/
static URI getLocation(HttpRequest request, HttpResponse response, HttpContext context) {
Header locationHdr = response.getFirstHeader("Location");
if (locationHdr == null) {
Log.e(TAG, "Received redirection without Location header, ignoring");
return null;
}
try {
URI location = URIUtils.parseURI(locationHdr.getValue(), false);
// some servers don't return absolute URLs as required by RFC 2616
if (!location.isAbsolute()) {
Log.w(TAG, "Received invalid redirection to relative URL, repairing");
URI originalURI = URIUtils.parseURI(request.getRequestLine().getUri(), false);
if (!originalURI.isAbsolute()) {
final HttpHost target = HttpClientContext.adapt(context).getTargetHost();
if (target != null)
originalURI = org.apache.http.client.utils.URIUtilsHC4.rewriteURI(originalURI, target);
else
return null;
}
return originalURI.resolve(location);
}
return location;
} catch (URISyntaxException e) {
Log.e(TAG, "Received redirection from/to invalid URI, ignoring", e);
}
return null;
}
}

@ -1,26 +0,0 @@
/*
* 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.webdav;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.ElementList;
import org.simpleframework.xml.Root;
import java.util.ArrayList;
@Root(strict=false)
public class DavResponse {
@Element
DavHref href;
@Element(required=false)
String status;
@ElementList(inline=true,required=false)
ArrayList<DavPropstat> propstat;
}

@ -1,19 +0,0 @@
/*
* 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.webdav;
import org.apache.http.HttpStatus;
public class ForbiddenException extends HttpException {
private static final long serialVersionUID = 102282229174086113L;
public ForbiddenException(String reason) {
super(HttpStatus.SC_FORBIDDEN, reason);
}
}

@ -1,26 +0,0 @@
/*
* 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.webdav;
import lombok.Getter;
public class HttpException extends org.apache.http.HttpException {
private static final long serialVersionUID = -4805778240079377401L;
@Getter private int code;
HttpException(int code, String message) {
super(message);
this.code = code;
}
public boolean isClientError() {
return code/100 == 4;
}
}

@ -1,105 +0,0 @@
/*
* 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.webdav;
import android.util.Log;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBaseHC4;
import org.apache.http.entity.StringEntity;
import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import java.io.StringWriter;
import java.net.URI;
import java.util.ArrayList;
public class HttpPropfind extends HttpEntityEnclosingRequestBaseHC4 {
private static final String TAG = "davdroid.HttpPropfind";
public final static String METHOD_NAME = "PROPFIND";
public enum Mode {
CURRENT_USER_PRINCIPAL,
HOME_SETS,
CARDDAV_COLLECTIONS,
CALDAV_COLLECTIONS,
COLLECTION_PROPERTIES,
MEMBERS_ETAG
}
HttpPropfind(URI uri) {
setURI(uri);
}
HttpPropfind(URI uri, Mode mode) {
this(uri);
DavPropfind propfind = new DavPropfind();
propfind.prop = new DavProp();
int depth = 0;
switch (mode) {
case CURRENT_USER_PRINCIPAL:
propfind.prop.currentUserPrincipal = new DavProp.CurrentUserPrincipal();
break;
case HOME_SETS:
propfind.prop.addressbookHomeSet = new DavProp.AddressbookHomeSet();
propfind.prop.calendarHomeSet = new DavProp.CalendarHomeSet();
break;
case CARDDAV_COLLECTIONS:
depth = 1;
propfind.prop.displayname = new DavProp.DisplayName();
propfind.prop.resourcetype = new DavProp.ResourceType();
propfind.prop.currentUserPrivilegeSet = new ArrayList<>();
propfind.prop.addressbookDescription = new DavProp.AddressbookDescription();
break;
case CALDAV_COLLECTIONS:
depth = 1;
propfind.prop.displayname = new DavProp.DisplayName();
propfind.prop.resourcetype = new DavProp.ResourceType();
propfind.prop.currentUserPrivilegeSet = new ArrayList<>();
propfind.prop.calendarDescription = new DavProp.CalendarDescription();
propfind.prop.calendarColor = new DavProp.CalendarColor();
propfind.prop.calendarTimezone = new DavProp.CalendarTimezone();
propfind.prop.supportedCalendarComponentSet = new ArrayList<>();
break;
case COLLECTION_PROPERTIES:
propfind.prop.getctag = new DavProp.GetCTag();
propfind.prop.resourcetype = new DavProp.ResourceType();
propfind.prop.displayname = new DavProp.DisplayName();
propfind.prop.calendarColor = new DavProp.CalendarColor();
propfind.prop.supportedAddressData = new ArrayList<>();
break;
case MEMBERS_ETAG:
depth = 1;
propfind.prop.getctag = new DavProp.GetCTag();
propfind.prop.getetag = new DavProp.GetETag();
break;
}
try {
Serializer serializer = new Persister();
StringWriter writer = new StringWriter();
serializer.write(propfind, writer);
setHeader("Content-Type", "text/xml; charset=UTF-8");
setHeader("Accept", "text/xml");
setHeader("Depth", String.valueOf(depth));
setEntity(new StringEntity(writer.toString(), "UTF-8"));
} catch(Exception ex) {
Log.e(TAG, "Couldn't prepare PROPFIND request for " + uri, ex);
abort();
}
}
@Override
public String getMethod() {
return METHOD_NAME;
}
}

@ -1,46 +0,0 @@
/*
* 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.webdav;
import android.util.Log;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBaseHC4;
import org.apache.http.entity.StringEntity;
import java.io.UnsupportedEncodingException;
import java.net.URI;
public class HttpReport extends HttpEntityEnclosingRequestBaseHC4 {
private static final String TAG = "davdroid.HttpEntityEncloseRequestBase";
public final static String METHOD_NAME = "REPORT";
HttpReport(URI uri) {
setURI(uri);
}
HttpReport(URI uri, String entity) {
this(uri);
setHeader("Content-Type", "text/xml; charset=UTF-8");
setHeader("Accept", "text/xml");
setHeader("Depth", "1");
try {
setEntity(new StringEntity(entity, "UTF-8"));
} catch (UnsupportedEncodingException e) {
Log.wtf(TAG, "String entity doesn't support UTF-8");
}
}
@Override
public String getMethod() {
return METHOD_NAME;
}
}

@ -1,19 +0,0 @@
/*
* 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.webdav;
import org.apache.http.HttpStatus;
public class NotAuthorizedException extends HttpException {
private static final long serialVersionUID = 2490525047224413586L;
public NotAuthorizedException(String reason) {
super(HttpStatus.SC_UNAUTHORIZED, reason);
}
}

@ -1,18 +0,0 @@
/*
* 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.webdav;
import org.apache.http.HttpStatus;
public class NotFoundException extends HttpException {
private static final long serialVersionUID = 1565961502781880483L;
public NotFoundException(String reason) {
super(HttpStatus.SC_NOT_FOUND, reason);
}
}

@ -1,18 +0,0 @@
/*
* 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.webdav;
import org.apache.http.HttpStatus;
public class PreconditionFailedException extends HttpException {
private static final long serialVersionUID = 102282229174086113L;
public PreconditionFailedException(String reason) {
super(HttpStatus.SC_PRECONDITION_FAILED, reason);
}
}

@ -1,108 +0,0 @@
/*
* 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.webdav;
import android.os.Build;
import android.util.Log;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.conn.ssl.BrowserCompatHostnameVerifierHC4;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.conn.ssl.X509HostnameVerifier;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import lombok.Cleanup;
public class TlsSniSocketFactory extends SSLConnectionSocketFactory {
private static final String TAG = "davdroid.TLS_SNI";
public static TlsSniSocketFactory getSocketFactory() {
return new TlsSniSocketFactory(
(SSLSocketFactory) SSLSocketFactory.getDefault(),
new BrowserCompatHostnameVerifierHC4() // use BrowserCompatHostnameVerifier to allow IP addresses in the Common Name
);
}
// Android 5.0+ (API level21) provides reasonable default settings
// but it still allows SSLv3
// https://developer.android.com/about/versions/android-5.0-changes.html#ssl
static String protocols[] = null, cipherSuites[] = null;
static {
try {
@Cleanup SSLSocket socket = (SSLSocket)SSLSocketFactory.getDefault().createSocket();
/* set reasonable protocol versions */
// - enable all supported protocols (enables TLSv1.1 and TLSv1.2 on Android <5.0)
// - remove all SSL versions (especially SSLv3) because they're insecure now
List<String> protocols = new LinkedList<>();
for (String protocol : socket.getSupportedProtocols())
if (!protocol.toUpperCase().contains("SSL"))
protocols.add(protocol);
Log.v(TAG, "Setting allowed TLS protocols: " + StringUtils.join(protocols, ", "));
TlsSniSocketFactory.protocols = protocols.toArray(new String[protocols.size()]);
/* set up reasonable cipher suites */
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
// choose known secure cipher suites
List<String> allowedCiphers = Arrays.asList(
// TLS 1.2
"TLS_RSA_WITH_AES_256_GCM_SHA384",
"TLS_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
"TLS_ECHDE_RSA_WITH_AES_128_GCM_SHA256",
// maximum interoperability
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_RSA_WITH_AES_128_CBC_SHA",
// additionally
"TLS_RSA_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA");
List<String> availableCiphers = Arrays.asList(socket.getSupportedCipherSuites());
Log.v(TAG, "Available cipher suites: " + StringUtils.join(availableCiphers, ", "));
Log.v(TAG, "Cipher suites enabled by default: " + StringUtils.join(socket.getEnabledCipherSuites(), ", "));
// take all allowed ciphers that are available and put them into preferredCiphers
HashSet<String> preferredCiphers = new HashSet<>(allowedCiphers);
preferredCiphers.retainAll(availableCiphers);
/* For maximum security, preferredCiphers should *replace* enabled ciphers (thus disabling
* ciphers which are enabled by default, but have become unsecure), but I guess for
* the security level of DAVdroid and maximum compatibility, disabling of insecure
* ciphers should be a server-side task */
// add preferred ciphers to enabled ciphers
HashSet<String> enabledCiphers = preferredCiphers;
enabledCiphers.addAll(new HashSet<>(Arrays.asList(socket.getEnabledCipherSuites())));
Log.v(TAG, "Enabling (only) those TLS ciphers: " + StringUtils.join(enabledCiphers, ", "));
TlsSniSocketFactory.cipherSuites = enabledCiphers.toArray(new String[enabledCiphers.size()]);
}
} catch (IOException e) {
}
}
public TlsSniSocketFactory(SSLSocketFactory socketfactory, X509HostnameVerifier hostnameVerifier) {
super(socketfactory, protocols, cipherSuites, hostnameVerifier);
}
}

@ -1,574 +0,0 @@
/*
* 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.webdav;
import android.util.Log;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.StatusLine;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.AuthCache;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDeleteHC4;
import org.apache.http.client.methods.HttpGetHC4;
import org.apache.http.client.methods.HttpOptionsHC4;
import org.apache.http.client.methods.HttpPutHC4;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.entity.ByteArrayEntityHC4;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.auth.BasicSchemeHC4;
import org.apache.http.impl.client.BasicAuthCache;
import org.apache.http.impl.client.BasicCredentialsProviderHC4;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.message.BasicLineParserHC4;
import org.apache.http.util.EntityUtilsHC4;
import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import at.bitfire.davdroid.DavUtils;
import at.bitfire.davdroid.URIUtils;
import at.bitfire.davdroid.resource.iCalendar;
import at.bitfire.davdroid.webdav.DavProp.Comp;
import ezvcard.VCardVersion;
import lombok.Cleanup;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* Represents a WebDAV resource (file or collection).
* This class is used for all CalDAV/CardDAV communcation.
*/
@ToString
public class WebDavResource {
private static final String TAG = "davdroid.WebDavResource";
public enum PutMode {
ADD_DONT_OVERWRITE,
UPDATE_DONT_OVERWRITE
}
// location of this resource
@Getter protected URI location;
// DAV capabilities (DAV: header) and allowed DAV methods (set for OPTIONS request)
protected Set<String> capabilities = new HashSet<>(),
methods = new HashSet<>();
// list of members (only for collections)
@Getter Properties properties = new Properties();
@Getter protected List<WebDavResource> members;
// content (available after GET)
@Getter protected byte[] content;
protected CloseableHttpClient httpClient;
protected HttpClientContext context;
public WebDavResource(CloseableHttpClient httpClient, URI baseURI) {
this.httpClient = httpClient;
location = baseURI;
context = HttpClientContext.create();
context.setCredentialsProvider(new BasicCredentialsProviderHC4());
}
public WebDavResource(CloseableHttpClient httpClient, URI baseURI, String username, String password, boolean preemptive) {
this(httpClient, baseURI);
context.getCredentialsProvider().setCredentials(
AuthScope.ANY,
new UsernamePasswordCredentials(username, password)
);
if (preemptive) {
HttpHost host = new HttpHost(baseURI.getHost(), baseURI.getPort(), baseURI.getScheme());
Log.d(TAG, "Using preemptive authentication (not compatible with Digest auth)");
AuthCache authCache = context.getAuthCache();
if (authCache == null)
authCache = new BasicAuthCache();
authCache.put(host, new BasicSchemeHC4());
context.setAuthCache(authCache);
}
}
public WebDavResource(WebDavResource parent) { // copy constructor: based on existing WebDavResource, reuse settings
// reuse httpClient, context and location (no deep copy)
httpClient = parent.httpClient;
context = parent.context;
location = parent.location;
}
public WebDavResource(WebDavResource parent, URI url) {
this(parent);
location = parent.location.resolve(url);
}
/**
* Creates a WebDavResource representing a member of the parent collection.
* @param parent Parent collection
* @param member File name of the member, unescaped. This may contain ":" without leading "./"!
* To create a new collection with a relative path that may not be a member, use the
* WebDavResource(WebDavResource parent, URI url) constructor.
* @throws URISyntaxException
*/
public WebDavResource(WebDavResource parent, String member) throws URISyntaxException {
this(parent);
location = parent.location.resolve(new URI(null, null, "./" + member, null));
}
public WebDavResource(WebDavResource parent, String member, String eTag) throws URISyntaxException {
this(parent, member);
properties.eTag = eTag;
}
/* feature detection */
public void options() throws URISyntaxException, IOException, HttpException {
HttpOptionsHC4 options = new HttpOptionsHC4(location);
@Cleanup CloseableHttpResponse response = httpClient.execute(options, context);
checkResponse(response);
Header[] allowHeaders = response.getHeaders("Allow");
for (Header allowHeader : allowHeaders)
methods.addAll(Arrays.asList(allowHeader.getValue().split(", ?")));
Header[] capHeaders = response.getHeaders("DAV");
for (Header capHeader : capHeaders)
capabilities.addAll(Arrays.asList(capHeader.getValue().split(", ?")));
}
public boolean supportsDAV(String capability) {
return capabilities.contains(capability);
}
public boolean supportsMethod(String method) {
return methods.contains(method);
}
/* file hierarchy methods */
public String getName() {
String[] names = StringUtils.split(location.getPath(), "/");
return names[names.length - 1];
}
/* collection operations */
public void propfind(HttpPropfind.Mode mode) throws URISyntaxException, IOException, DavException, HttpException {
@Cleanup CloseableHttpResponse response = null;
// processMultiStatus() requires knowledge of the actual content location,
// so we have to handle redirections manually and create a new request for the new location
for (int i = context.getRequestConfig().getMaxRedirects(); i > 0; i--) {
HttpPropfind propfind = new HttpPropfind(location, mode);
response = httpClient.execute(propfind, context);
if (response.getStatusLine().getStatusCode()/100 == 3) {
location = DavRedirectStrategy.getLocation(propfind, response, context);
Log.i(TAG, "Redirection on PROPFIND; trying again at new content URL: " + location);
// don't forget to throw away the unneeded response content
HttpEntity entity = response.getEntity();
if (entity != null) { @Cleanup InputStream content = entity.getContent(); }
} else
break; // answer was NOT a redirection, continue
}
if (response == null)
throw new DavNoContentException();
checkResponse(response); // will also handle Content-Location
processMultiStatus(response);
}
public void multiGet(DavMultiget.Type type, String[] names) throws URISyntaxException, IOException, DavException, HttpException {
@Cleanup CloseableHttpResponse response = null;
// processMultiStatus() requires knowledge of the actual content location,
// so we have to handle redirections manually and create a new request for the new location
for (int i = context.getRequestConfig().getMaxRedirects(); i > 0; i--) {
// build multi-get XML request
List<String> hrefs = new LinkedList<>();
for (String name : names)
// name may contain "%" which have to be encoded → use non-quoting URI constructor and getRawPath()
// name may also contain ":", so prepend "./" because even the non-quoting URI constructor parses after constructing
// DAVdroid ensures that collections always have a trailing slash, so "./" won't go down in directory hierarchy
hrefs.add(location.resolve(new URI(null, null, "./" + name, null)).getRawPath());
DavMultiget multiget = DavMultiget.newRequest(type, hrefs.toArray(new String[hrefs.size()]));
StringWriter writer = new StringWriter();
try {
Serializer serializer = new Persister();
serializer.write(multiget, writer);
} catch (Exception ex) {
Log.e(TAG, "Couldn't create XML multi-get request", ex);
throw new DavException("Couldn't create multi-get request");
}
// submit REPORT request
HttpReport report = new HttpReport(location, writer.toString());
response = httpClient.execute(report, context);
if (response.getStatusLine().getStatusCode()/100 == 3) {
location = DavRedirectStrategy.getLocation(report, response, context);
Log.i(TAG, "Redirection on REPORT multi-get; trying again at new content URL: " + location);
// don't forget to throw away the unneeded response content
HttpEntity entity = response.getEntity();
if (entity != null) { @Cleanup InputStream content = entity.getContent(); }
} else
break; // answer was NOT a redirection, continue
}
if (response == null)
throw new DavNoContentException();
checkResponse(response); // will also handle Content-Location
processMultiStatus(response);
}
public void report(String query) throws IOException, HttpException, DavException {
HttpReport report = new HttpReport(location, query);
report.setHeader("Depth", "1");
@Cleanup CloseableHttpResponse response = httpClient.execute(report, context);
if (response == null)
throw new DavNoContentException();
checkResponse(response);
processMultiStatus(response);
}
/* resource operations */
public void get(String acceptedMimeTypes) throws URISyntaxException, IOException, HttpException, DavException {
HttpGetHC4 get = new HttpGetHC4(location);
get.addHeader("Accept", acceptedMimeTypes);
@Cleanup CloseableHttpResponse response = httpClient.execute(get, context);
checkResponse(response);
HttpEntity entity = response.getEntity();
if (entity == null)
throw new DavNoContentException();
properties.contentType = ContentType.get(entity);
content = EntityUtilsHC4.toByteArray(entity);
}
// returns the ETag of the created/updated resource, if available (null otherwise)
public String put(byte[] data, PutMode mode) throws URISyntaxException, IOException, HttpException {
HttpPutHC4 put = new HttpPutHC4(location);
put.setEntity(new ByteArrayEntityHC4(data));
switch (mode) {
case ADD_DONT_OVERWRITE:
put.addHeader("If-None-Match", "*");
break;
case UPDATE_DONT_OVERWRITE:
put.addHeader("If-Match", (properties.eTag != null) ? properties.eTag : "*");
break;
}
if (properties.contentType != null)
put.addHeader("Content-Type", properties.contentType.toString());
@Cleanup CloseableHttpResponse response = httpClient.execute(put, context);
checkResponse(response);
Header eTag = response.getLastHeader("ETag");
if (eTag != null)
return eTag.getValue();
return null;
}
public void delete() throws URISyntaxException, IOException, HttpException {
HttpDeleteHC4 delete = new HttpDeleteHC4(location);
if (properties.eTag != null)
delete.addHeader("If-Match", properties.eTag);
@Cleanup CloseableHttpResponse response = httpClient.execute(delete, context);
checkResponse(response);
}
/* helpers */
protected void checkResponse(HttpResponse response) throws HttpException {
checkResponse(response.getStatusLine());
// handle Content-Location header (see RFC 4918 5.2 Collection Resources)
Header contentLocationHdr = response.getFirstHeader("Content-Location");
if (contentLocationHdr != null) {
// Content-Location was set, update location correspondingly
location = location.resolve(contentLocationHdr.getValue());
Log.d(TAG, "Set Content-Location to " + location);
}
}
protected static void checkResponse(StatusLine statusLine) throws HttpException {
int code = statusLine.getStatusCode();
if (code/100 == 1 || code/100 == 2) // everything OK
return;
String reason = code + " " + statusLine.getReasonPhrase();
switch (code) {
case HttpStatus.SC_UNAUTHORIZED:
throw new NotAuthorizedException(reason);
case HttpStatus.SC_FORBIDDEN:
throw new ForbiddenException(reason);
case HttpStatus.SC_NOT_FOUND:
throw new NotFoundException(reason);
case HttpStatus.SC_CONFLICT:
throw new ConflictException(reason);
case HttpStatus.SC_PRECONDITION_FAILED:
throw new PreconditionFailedException(reason);
default:
throw new HttpException(code, reason);
}
}
/**
* Process a 207 Multi-status response as defined in RFC 4918 "13. Multi-Status Response"
*/
protected void processMultiStatus(HttpResponse response) throws IOException, HttpException, DavException {
if (response.getStatusLine().getStatusCode() != HttpStatus.SC_MULTI_STATUS)
throw new DavNoMultiStatusException();
HttpEntity entity = response.getEntity();
if (entity == null)
throw new DavNoContentException();
properties.contentType = ContentType.get(entity);
@Cleanup InputStream content = entity.getContent();
DavMultistatus multiStatus;
try {
Serializer serializer = new Persister();
multiStatus = serializer.read(DavMultistatus.class, content, false);
} catch (Exception ex) {
throw new DavException("Couldn't parse Multi-Status response on REPORT multi-get", ex);
}
if (multiStatus.response == null) // empty response
return;
// member list will be built from response
List<WebDavResource> members = new LinkedList<>();
// iterate through all resources (either ourselves or member)
for (DavResponse singleResponse : multiStatus.response) {
URI href;
try {
href = location.resolve(URIUtils.parseURI(singleResponse.href.href, false));
} catch(Exception ex) {
Log.w(TAG, "Ignoring illegal member URI in multi-status response", ex);
continue;
}
Log.d(TAG, "Processing multi-status element: " + href);
// process known properties
Properties properties = new Properties();
byte[] data = null;
// in <response>, either <status> or <propstat> must be present
if (singleResponse.status != null) { // method 1 (status of resource as a whole)
StatusLine status = BasicLineParserHC4.parseStatusLine(singleResponse.status, new BasicLineParserHC4());
checkResponse(status);
} else for (DavPropstat singlePropstat : singleResponse.propstat) { // method 2 (propstat)
StatusLine status = BasicLineParserHC4.parseStatusLine(singlePropstat.status, new BasicLineParserHC4());
// ignore information about missing properties etc.
if (status.getStatusCode()/100 != 1 && status.getStatusCode()/100 != 2)
continue;
DavProp prop = singlePropstat.prop;
properties.process(prop);
if (prop.calendarData != null && prop.calendarData.ical != null)
data = prop.calendarData.ical.getBytes();
else if (prop.addressData != null && prop.addressData.vcard != null)
data = prop.addressData.vcard.getBytes();
}
// about which resource is this response?
if (properties.isCollection) // ensure trailing slashs for collections
href = URIUtils.ensureTrailingSlash(href);
if (location.equals(href) || URIUtils.ensureTrailingSlash(location).equals(href)) { // about ourselves
this.properties = properties;
this.content = data;
} else { // about a member
WebDavResource member = new WebDavResource(this, href);
member.properties = properties;
member.content = data;
members.add(member);
}
}
this.members = members;
}
public static class Properties {
// DAV properties
protected String
currentUserPrincipal,
addressBookHomeset,
calendarHomeset,
color;
@Getter protected String
displayName,
description,
timeZone,
eTag,
cTag;
@Getter @Setter protected ContentType contentType;
@Getter protected boolean
readOnly,
isCollection,
isCalendar,
isAddressBook;
@Getter protected List<String> supportedComponents;
@Getter protected VCardVersion supportedVCardVersion;
// fill from DavProp
protected void process(DavProp prop) {
if (prop.currentUserPrincipal != null && prop.currentUserPrincipal.getHref() != null)
currentUserPrincipal = prop.currentUserPrincipal.getHref().href;
if (prop.currentUserPrivilegeSet != null) {
// privilege info available
boolean mayAll = false,
mayBind = false,
mayUnbind = false,
mayWrite = false,
mayWriteContent = false;
for (DavProp.Privilege privilege : prop.currentUserPrivilegeSet) {
if (privilege.getAll() != null) mayAll = true;
if (privilege.getBind() != null) mayBind = true;
if (privilege.getUnbind() != null) mayUnbind = true;
if (privilege.getWrite() != null) mayWrite = true;
if (privilege.getWriteContent() != null) mayWriteContent = true;
}
if (!mayAll && !mayWrite && !(mayWriteContent && mayBind && mayUnbind))
readOnly = true;
}
if (prop.addressbookHomeSet != null && prop.addressbookHomeSet.getHref() != null)
addressBookHomeset = URIUtils.ensureTrailingSlash(prop.addressbookHomeSet.getHref().href);
if (prop.calendarHomeSet != null && prop.calendarHomeSet.getHref() != null)
calendarHomeset = URIUtils.ensureTrailingSlash(prop.calendarHomeSet.getHref().href);
if (prop.displayname != null)
displayName = prop.displayname.getDisplayName();
if (prop.resourcetype != null) {
if (prop.resourcetype.getCollection() != null)
isCollection = true;
if (prop.resourcetype.getAddressbook() != null) { // CardDAV collection properties
isAddressBook = true;
if (prop.addressbookDescription != null)
description = prop.addressbookDescription.getDescription();
if (prop.supportedAddressData != null)
for (DavProp.AddressDataType dataType : prop.supportedAddressData)
if ("text/vcard".equalsIgnoreCase(dataType.getContentType()))
// ignore "3.0" as it MUST be supported anyway
if ("4.0".equals(dataType.getVersion()))
supportedVCardVersion = VCardVersion.V4_0;
}
if (prop.resourcetype.getCalendar() != null) { // CalDAV collection propertioes
isCalendar = true;
if (prop.calendarDescription != null)
description = prop.calendarDescription.getDescription();
if (prop.calendarColor != null)
color = prop.calendarColor.getColor();
if (prop.calendarTimezone != null)
timeZone = prop.calendarTimezone.getTimezone();
if (prop.supportedCalendarComponentSet != null) {
supportedComponents = new LinkedList<>();
for (Comp component : prop.supportedCalendarComponentSet)
supportedComponents.add(component.getName());
}
}
}
if (prop.getctag != null)
cTag = prop.getctag.getCTag();
if (prop.getetag != null)
eTag = prop.getetag.getETag();
}
// getters / setters
public Integer getColor() {
return color != null ? DavUtils.CalDAVtoARGBColor(color) : null;
}
public URI getCurrentUserPrincipal() throws URISyntaxException {
return currentUserPrincipal != null ? URIUtils.parseURI(currentUserPrincipal, false) : null;
}
public URI getAddressbookHomeSet() throws URISyntaxException {
return addressBookHomeset != null ? URIUtils.parseURI(addressBookHomeset, false) : null;
}
public URI getCalendarHomeSet() throws URISyntaxException {
return calendarHomeset != null ? URIUtils.parseURI(calendarHomeset, false) : null;
}
public String getTimeZone() {
return timeZone != null ? iCalendar.TimezoneDefToTzId(timeZone) : null;
}
public void invalidateCTag() {
cTag = null;
}
}
}

@ -180,15 +180,16 @@
<string name="sync_error_title">Synchronisierung von %s fehlgeschlagen</string>
<string name="sync_error_http">HTTP-Fehler beim %1$s</string>
<string-array name="sync_error_phases">
<item>Vorbereiten der Synchronisierung</item>
<item>Abfragen der Server-Fähigkeiten</item>
<item>Verarbeiten lokal gelöschter Einträge</item>
<item>Vorbereiten neuer lokaler Einträge</item>
<item>Hochladen neuer/geänderter lokaler Einträge</item>
<item>Abfragen des Sync.-Zustands</item>
<item>Abfragen des Synchronisierungs-Zustands</item>
<item>Auflisten lokaler Einträge</item>
<item>Auflisten der Server-Einträge</item>
<item>Herunterladen von Server-Einträgen</item>
<item>Speichern des Sync.-Zustands</item>
<item>Speichern des Synchronisierungs-Zustands</item>
</string-array>
</resources>

@ -195,6 +195,7 @@
<string name="sync_error_title">Synchronization of %s failed</string>
<string name="sync_error_http">HTTP error while %1$s</string>
<string-array name="sync_error_phases">
<item>preparing synchronization</item>
<item>querying capabilities</item>
<item>processing locally deleted entries</item>
<item>preparing locally created entries</item>

Loading…
Cancel
Save