From bba9c3616a53b38cc98adc60e405da79b392f034 Mon Sep 17 00:00:00 2001 From: mguessan Date: Fri, 30 Jul 2010 15:09:44 +0000 Subject: [PATCH] Caldav: switch to new VCalendar parser/patcher git-svn-id: http://svn.code.sf.net/p/davmail/code/trunk@1321 3d1905a2-6b24-0410-a738-b14d5a86fcbd --- src/java/davmail/caldav/CaldavConnection.java | 2 +- .../davmail/exchange/ExchangeSession.java | 266 +----------- src/java/davmail/exchange/VCalendar.java | 152 +++++-- src/java/davmail/exchange/VObject.java | 34 +- src/java/davmail/exchange/VProperty.java | 21 +- .../exchange/dav/DavExchangeSession.java | 18 +- .../exchange/TestExchangeSessionCalendar.java | 14 +- .../exchange/TestExchangeSessionEvent.java | 406 ++++-------------- 8 files changed, 269 insertions(+), 644 deletions(-) diff --git a/src/java/davmail/caldav/CaldavConnection.java b/src/java/davmail/caldav/CaldavConnection.java index bf4aa0a9..c27712be 100644 --- a/src/java/davmail/caldav/CaldavConnection.java +++ b/src/java/davmail/caldav/CaldavConnection.java @@ -684,7 +684,7 @@ public class CaldavConnection extends AbstractConnection { appendItemResponse(response, request, item); } } catch (Exception e) { - wireLogger.debug(e); + wireLogger.debug(e.getMessage(), e); DavGatewayTray.warn(new BundleMessage("LOG_ITEM_NOT_AVAILABLE", eventName, href)); notFound.add(href); } diff --git a/src/java/davmail/exchange/ExchangeSession.java b/src/java/davmail/exchange/ExchangeSession.java index 6fc8269c..cc5ee2af 100644 --- a/src/java/davmail/exchange/ExchangeSession.java +++ b/src/java/davmail/exchange/ExchangeSession.java @@ -1867,7 +1867,7 @@ public abstract class ExchangeSession { ByteArrayOutputStream baos = new ByteArrayOutputStream(); bodyPart.getDataHandler().writeTo(baos); baos.close(); - result = fixICS(new String(baos.toByteArray(), "UTF-8"), true); + result = new String(baos.toByteArray(), "UTF-8"); } else { ByteArrayOutputStream baos = new ByteArrayOutputStream(); mimeMessage.writeTo(baos); @@ -1937,265 +1937,19 @@ public abstract class ExchangeSession { } protected String fixICS(String icsBody, boolean fromServer) throws IOException { - // first pass : detect - class AllDayState { - boolean isAllDay; - boolean hasCdoAllDay; - boolean isCdoAllDay; - } dumpIndex++; dumpICS(icsBody, fromServer, false); - // Convert event class from and to iCal - // See https://trac.calendarserver.org/browser/CalendarServer/trunk/doc/Extensions/caldav-privateevents.txt - boolean isAppleiCal = false; - boolean hasAttendee = false; - boolean hasCdoBusyStatus = false; - // detect ics event with empty timezone (all day from Lightning) - boolean hasTimezone = false; - String transp = null; - String validTimezoneId = null; - String eventClass = null; - String organizer = null; - String action = null; - String method = null; - boolean sound = false; - - List allDayStates = new ArrayList(); - AllDayState currentAllDayState = new AllDayState(); - BufferedReader reader = null; - try { - reader = new ICSBufferedReader(new StringReader(icsBody)); - String line; - while ((line = reader.readLine()) != null) { - int index = line.indexOf(':'); - if (index >= 0) { - String key = line.substring(0, index); - String value = line.substring(index + 1); - if ("DTSTART;VALUE=DATE".equals(key)) { - currentAllDayState.isAllDay = true; - } else if ("X-MICROSOFT-CDO-ALLDAYEVENT".equals(key)) { - currentAllDayState.hasCdoAllDay = true; - currentAllDayState.isCdoAllDay = "TRUE".equals(value); - } else if ("END:VEVENT".equals(line)) { - allDayStates.add(currentAllDayState); - currentAllDayState = new AllDayState(); - } else if ("PRODID".equals(key) && line.contains("iCal")) { - // detect iCal created events - isAppleiCal = true; - } else if (isAppleiCal && "X-CALENDARSERVER-ACCESS".equals(key)) { - eventClass = value; - } else if (!isAppleiCal && "CLASS".equals(key)) { - eventClass = value; - } else if ("ACTION".equals(key)) { - action = value; - } else if ("ATTACH;VALUES=URI".equals(key)) { - // This is a marker that this event has an alarm with sound - sound = true; - } else if (key.startsWith("ORGANIZER")) { - if (value.startsWith("MAILTO:")) { - organizer = value.substring(7); - } else { - organizer = value; - } - } else if (key.startsWith("ATTENDEE")) { - hasAttendee = true; - } else if ("TRANSP".equals(key)) { - transp = value; - } else if (line.startsWith("TZID:(GMT") || - // additional test for Outlook created recurring events - line.startsWith("TZID:GMT ")) { - try { - validTimezoneId = ResourceBundle.getBundle("timezones").getString(value); - } catch (MissingResourceException mre) { - LOGGER.warn(new BundleMessage("LOG_INVALID_TIMEZONE", value)); - } - } else if ("X-MICROSOFT-CDO-BUSYSTATUS".equals(key)) { - hasCdoBusyStatus = true; - } else if ("BEGIN:VTIMEZONE".equals(line)) { - hasTimezone = true; - } else if ("METHOD".equals(key)) { - method = value; - } - } - } - } finally { - if (reader != null) { - reader.close(); - } + if (LOGGER.isDebugEnabled() && fromServer) { + LOGGER.debug("Vcalendar body received from server:\n" +icsBody); } - // second pass : fix - int count = 0; - ICSBufferedWriter result = new ICSBufferedWriter(); - try { - reader = new ICSBufferedReader(new StringReader(icsBody)); - String line; - - while ((line = reader.readLine()) != null) { - // remove empty properties - if ("CLASS:".equals(line) || "LOCATION:".equals(line)) { - continue; - } - // fix invalid exchange timezoneid - if (validTimezoneId != null && line.indexOf(";TZID=") >= 0) { - line = fixTimezoneId(line, validTimezoneId); - } - if (!fromServer && "BEGIN:VCALENDAR".equals(line) && method == null) { - result.writeLine(line); - // append missing method - if (method == null) { - result.writeLine("METHOD:PUBLISH"); - } - continue; - } - if (fromServer && line.startsWith("PRODID:") && eventClass != null) { - result.writeLine(line); - // set global calendarserver access for iCal 4 - if ("PRIVATE".equalsIgnoreCase(eventClass)) { - result.writeLine("X-CALENDARSERVER-ACCESS:CONFIDENTIAL"); - } else if ("CONFIDENTIAL".equalsIgnoreCase(eventClass)) { - result.writeLine("X-CALENDARSERVER-ACCESS:PRIVATE"); - } else if (eventClass != null) { - result.writeLine("X-CALENDARSERVER-ACCESS:" + eventClass); - } - continue; - } - if (!fromServer && "BEGIN:VEVENT".equals(line) && !hasTimezone) { - result.write(ExchangeSession.this.getVTimezone().timezoneBody); - hasTimezone = true; - } - if (!fromServer && currentAllDayState.isAllDay && "X-MICROSOFT-CDO-ALLDAYEVENT:FALSE".equals(line)) { - line = "X-MICROSOFT-CDO-ALLDAYEVENT:TRUE"; - } else if (!fromServer && "END:VEVENT".equals(line)) { - if (!hasCdoBusyStatus) { - result.writeLine("X-MICROSOFT-CDO-BUSYSTATUS:" + (!"TRANSPARENT".equals(transp) ? "BUSY" : "FREE")); - } - if (currentAllDayState.isAllDay && !currentAllDayState.hasCdoAllDay) { - result.writeLine("X-MICROSOFT-CDO-ALLDAYEVENT:TRUE"); - } - // add organizer line to all events created in Exchange for active sync - if (organizer == null) { - result.writeLine("ORGANIZER:MAILTO:" + email); - } - if (isAppleiCal) { - if ("CONFIDENTIAL".equalsIgnoreCase(eventClass)) { - result.writeLine("CLASS:PRIVATE"); - } else if ("PRIVATE".equalsIgnoreCase(eventClass)) { - result.writeLine("CLASS:CONFIDENTIAL"); - } else if (eventClass != null) { - result.writeLine("CLASS:" + eventClass); - } - } - } else if (!fromServer && line.startsWith("X-MICROSOFT-CDO-BUSYSTATUS:")) { - line = "X-MICROSOFT-CDO-BUSYSTATUS:" + (!"TRANSPARENT".equals(transp) ? "BUSY" : "FREE"); - } else if (!fromServer && !currentAllDayState.isAllDay && "X-MICROSOFT-CDO-ALLDAYEVENT:TRUE".equals(line)) { - line = "X-MICROSOFT-CDO-ALLDAYEVENT:FALSE"; - } else if (fromServer && currentAllDayState.isCdoAllDay && line.startsWith("DTSTART") && !line.startsWith("DTSTART;VALUE=DATE")) { - line = getAllDayLine(line); - } else if (fromServer && currentAllDayState.isCdoAllDay && line.startsWith("DTEND") && !line.startsWith("DTEND;VALUE=DATE")) { - line = getAllDayLine(line); - } else if (!fromServer && currentAllDayState.isAllDay && line.startsWith("DTSTART") && line.startsWith("DTSTART;VALUE=DATE")) { - line = "DTSTART;TZID=\"" + ExchangeSession.this.getVTimezone().timezoneId + "\":" + line.substring(19) + "T000000"; - } else if (!fromServer && currentAllDayState.isAllDay && line.startsWith("DTEND") && line.startsWith("DTEND;VALUE=DATE")) { - line = "DTEND;TZID=\"" + ExchangeSession.this.getVTimezone().timezoneId + "\":" + line.substring(17) + "T000000"; - } else if (line.startsWith("TZID:") && validTimezoneId != null) { - line = "TZID:" + validTimezoneId; - } else if ("BEGIN:VEVENT".equals(line)) { - currentAllDayState = allDayStates.get(count++); - // remove calendarserver access - } else if (line.startsWith("X-CALENDARSERVER-ACCESS:")) { - continue; - } else if (line.startsWith("EXDATE;TZID=") || line.startsWith("EXDATE:")) { - // Apple iCal doesn't support EXDATE with multiple exceptions - // on one line. Split into multiple EXDATE entries (which is - // also legal according to the caldav standard). - splitExDate(result, line); - continue; - } else if (line.startsWith("X-ENTOURAGE_UUID:")) { - // Apple iCal doesn't understand this key, and it's entourage - // specific (i.e. not needed by any caldav client): strip it out - continue; - } else if (fromServer && line.startsWith("ATTENDEE;") - && (line.indexOf(email) >= 0)) { - // If this is coming from the server, strip out RSVP for this - // user as an attendee where the partstat is something other - // than PARTSTAT=NEEDS-ACTION since the RSVP confuses iCal4 into - // thinking the attendee has not replied - - int rsvpSuffix = line.indexOf("RSVP=TRUE;"); - int rsvpPrefix = line.indexOf(";RSVP=TRUE"); - - if (((rsvpSuffix >= 0) || (rsvpPrefix >= 0)) - && (line.indexOf("PARTSTAT=") >= 0) - && (line.indexOf("PARTSTAT=NEEDS-ACTION") < 0)) { - - // Strip out the "RSVP" line from the calendar entry - if (rsvpSuffix >= 0) { - line = line.substring(0, rsvpSuffix) + line.substring(rsvpSuffix + 10); - } else { - line = line.substring(0, rsvpPrefix) + line.substring(rsvpPrefix + 10); - } - - } - } else if (line.startsWith("ACTION:")) { - if (fromServer && "DISPLAY".equals(action) - // convert DISPLAY to AUDIO only if user defined an alarm sound - && Settings.getProperty("davmail.caldavAlarmSound") != null) { - // Convert alarm to audio for iCal - result.writeLine("ACTION:AUDIO"); - - if (!sound) { - // Add defined sound into the audio alarm - result.writeLine("ATTACH;VALUE=URI:" + Settings.getProperty("davmail.caldavAlarmSound")); - } - - continue; - } else if (!fromServer && "AUDIO".equals(action)) { - // Use the alarm action that exchange (and blackberry) understand - // (exchange and blackberry don't understand audio actions) - - result.writeLine("ACTION:DISPLAY"); - continue; - } - - // Don't recognize this type of action: pass it through - - } else if (line.startsWith("CLASS:")) { - if (!fromServer && isAppleiCal) { - continue; - } else { - // still set calendarserver access inside event for iCal 3 - if ("PRIVATE".equalsIgnoreCase(eventClass)) { - result.writeLine("X-CALENDARSERVER-ACCESS:CONFIDENTIAL"); - } else if ("CONFIDENTIAL".equalsIgnoreCase(eventClass)) { - result.writeLine("X-CALENDARSERVER-ACCESS:PRIVATE"); - } else { - result.writeLine("X-CALENDARSERVER-ACCESS:" + eventClass); - } - } - // remove organizer line if user is organizer for iPhone - } else if (fromServer && line.startsWith("ORGANIZER") && !hasAttendee) { - continue; - } else if (organizer != null && line.startsWith("ATTENDEE") && line.contains(organizer)) { - // Ignore organizer as attendee - continue; - } else if (!fromServer && line.startsWith("ATTENDEE")) { - line = replaceIcal4Principal(line); - } - - result.writeLine(line); - } - } finally { - reader.close(); - } - String resultString = result.toString(); - - /* new experimental code - VCalendar vCalendar = new VCalendar(icsBody, getEmail()); + VCalendar vCalendar = new VCalendar(icsBody, getEmail(), getVTimezone()); vCalendar.fixVCalendar(fromServer); - resultString = vCalendar.toString(); - */ + String resultString = vCalendar.toString(); + if (LOGGER.isDebugEnabled() && !fromServer) { + LOGGER.debug("Fixed Vcalendar body to server:\n" +resultString); + } dumpICS(resultString, fromServer, true); return resultString; @@ -3481,14 +3235,14 @@ public abstract class ExchangeSession { } - protected VTimezone vTimezone; + protected VObject vTimezone; /** * Load and return current user OWA timezone. * * @return current timezone */ - public VTimezone getVTimezone() { + public VObject getVTimezone() { if (vTimezone == null) { // need to load Timezone info from OWA loadVtimezone(); diff --git a/src/java/davmail/exchange/VCalendar.java b/src/java/davmail/exchange/VCalendar.java index 4307a33d..5d6266f8 100644 --- a/src/java/davmail/exchange/VCalendar.java +++ b/src/java/davmail/exchange/VCalendar.java @@ -19,6 +19,7 @@ package davmail.exchange; import davmail.Settings; +import org.apache.log4j.Logger; import java.io.BufferedReader; import java.io.IOException; @@ -28,6 +29,7 @@ import java.io.StringReader; * VCalendar object. */ public class VCalendar extends VObject { + protected static final Logger LOGGER = Logger.getLogger(VCalendar.class); protected VObject firstVevent; protected VObject vTimezone; protected String email; @@ -35,27 +37,34 @@ public class VCalendar extends VObject { /** * Create VCalendar object from reader; * - * @param reader stream reader - * @param email current user email + * @param reader stream reader + * @param email current user email + * @param vTimezone user OWA timezone * @throws IOException on error */ - public VCalendar(BufferedReader reader, String email) throws IOException { + public VCalendar(BufferedReader reader, String email, VObject vTimezone) throws IOException { super(reader); if (!"VCALENDAR".equals(type)) { throw new IOException("Invalid type: " + type); } this.email = email; + // set OWA timezone information + if (this.vTimezone == null) { + this.vObjects.add(0, vTimezone); + this.vTimezone = vTimezone; + } } /** - * Create VCalendar object from reader; + * Create VCalendar object from string; * * @param vCalendarBody item body * @param email current user email + * @param vTimezone user OWA timezone * @throws IOException on error */ - public VCalendar(String vCalendarBody, String email) throws IOException { - this(new ICSBufferedReader(new StringReader(vCalendarBody)), email); + public VCalendar(String vCalendarBody, String email, VObject vTimezone) throws IOException { + this(new ICSBufferedReader(new StringReader(vCalendarBody)), email, vTimezone); } @Override @@ -124,7 +133,24 @@ public class VCalendar extends VObject { } else if (vObject.getPropertyValue("X-CALENDARSERVER-ACCESS") != null) { vObject.setPropertyValue("CLASS", getEventClass(vObject.getPropertyValue("X-CALENDARSERVER-ACCESS"))); } - if (!fromServer) { + if (fromServer) { + // remove organizer line for event without attendees for iPhone + if (getProperty("ATTENDEE") == null) { + vObject.setPropertyValue("ORGANIZER", null); + } + // detect allday and update date properties + if (isCdoAllDay(vObject)) { + setClientAllday(vObject.getProperty("DTSTART")); + setClientAllday(vObject.getProperty("DTEND")); + } + vObject.setPropertyValue("TRANSP", + !"FREE".equals(vObject.getPropertyValue("X-MICROSOFT-CDO-BUSYSTATUS")) ? "OPAQUE" : "TRANSPARENT"); + + // TODO splitExDate + // Apple iCal doesn't understand this key, and it's entourage + // specific (i.e. not needed by any caldav client): strip it out + vObject.removeProperty("X-ENTOURAGE_UUID"); + } else { // add organizer line to all events created in Exchange for active sync if (vObject.getPropertyValue("ORGANIZER") == null) { vObject.setPropertyValue("ORGANIZER", "MAILTO:" + email); @@ -133,46 +159,78 @@ public class VCalendar extends VObject { vObject.setPropertyValue("X-MICROSOFT-CDO-ALLDAYEVENT", isAllDay(vObject) ? "TRUE" : "FALSE"); vObject.setPropertyValue("X-MICROSOFT-CDO-BUSYSTATUS", !"TRANSPARENT".equals(vObject.getPropertyValue("TRANSP")) ? "BUSY" : "FREE"); - - } else { - // remove organizer line for event without attendees for iPhone - if (getProperty("ATTENDEE") == null) { - vObject.setPropertyValue("ORGANIZER", null); + + if (isAllDay(vObject)) { + // convert date values to outlook compatible values + setServerAllday(vObject.getProperty("DTSTART")); + setServerAllday(vObject.getProperty("DTEND")); } - // TODO: handle transparent ? } fixAttendees(vObject, fromServer); // TODO handle BUSYSTATUS - + fixAlarm(vObject, fromServer); } } } - private void fixAlarm(VObject vObject, boolean fromServer) { - for (VObject vAlarm : vObject.vObjects) { - if ("VALARM".equals(vAlarm.type)) { - String action = vAlarm.getPropertyValue("ACTION"); - if (fromServer && "DISPLAY".equals(action) - // convert DISPLAY to AUDIO only if user defined an alarm sound - && Settings.getProperty("davmail.caldavAlarmSound") != null) { - // Convert alarm to audio for iCal - vAlarm.setPropertyValue("ACTION", "AUDIO"); + private void setServerAllday(VProperty property) { + // set TZID param + if (!property.hasParam("TZID")) { + property.addParam("TZID", vTimezone.getPropertyValue("TZID")); + } + // remove VALUE + property.removeParam("VALUE"); + String value = property.getValue(); + if (value.length() != 8) { + LOGGER.warn("Invalid date value in allday event: " + value); + } + property.setValue(property.getValue() + "T000000"); + } - if (vAlarm.getPropertyValue("ATTACH") == null) { - // Add defined sound into the audio alarm - VProperty vProperty = new VProperty("ATTACH", Settings.getProperty("davmail.caldavAlarmSound")); - vProperty.addParam("VALUE", "URI"); - vAlarm.addProperty(vProperty); + protected void setClientAllday(VProperty property) { + // set VALUE=DATE param + if (!property.hasParam("VALUE")) { + property.addParam("VALUE", "DATE"); + } + // remove TZID + property.removeParam("TZID"); + String value = property.getValue(); + int tIndex = value.indexOf('T'); + if (tIndex >= 0) { + value = value.substring(0, tIndex); + } else { + LOGGER.warn("Invalid date value in allday event: " + value); + } + property.setValue(value); + } + + protected void fixAlarm(VObject vObject, boolean fromServer) { + if (vObject.vObjects != null) { + for (VObject vAlarm : vObject.vObjects) { + if ("VALARM".equals(vAlarm.type)) { + String action = vAlarm.getPropertyValue("ACTION"); + if (fromServer && "DISPLAY".equals(action) + // convert DISPLAY to AUDIO only if user defined an alarm sound + && Settings.getProperty("davmail.caldavAlarmSound") != null) { + // Convert alarm to audio for iCal + vAlarm.setPropertyValue("ACTION", "AUDIO"); + + if (vAlarm.getPropertyValue("ATTACH") == null) { + // Add defined sound into the audio alarm + VProperty vProperty = new VProperty("ATTACH", Settings.getProperty("davmail.caldavAlarmSound")); + vProperty.addParam("VALUE", "URI"); + vAlarm.addProperty(vProperty); + } + + } else if (!fromServer && "AUDIO".equals(action)) { + // Use the alarm action that exchange (and blackberry) understand + // (exchange and blackberry don't understand audio actions) + vAlarm.setPropertyValue("ACTION", "DISPLAY"); } - - } else if (!fromServer && "AUDIO".equals(action)) { - // Use the alarm action that exchange (and blackberry) understand - // (exchange and blackberry don't understand audio actions) - vAlarm.setPropertyValue("ACTION", "DISPLAY"); } } } @@ -193,10 +251,21 @@ public class VCalendar extends VObject { } private void fixAttendees(VObject vObject, boolean fromServer) { - if (!fromServer) { - if (vObject.properties != null) { - for (VProperty property : vObject.properties) { - if ("ATTENDEE".equalsIgnoreCase(property.getKey())) { + if (vObject.properties != null) { + for (VProperty property : vObject.properties) { + if ("ATTENDEE".equalsIgnoreCase(property.getKey())) { + if (fromServer) { + // If this is coming from the server, strip out RSVP for this + // user as an attendee where the partstat is something other + // than PARTSTAT=NEEDS-ACTION since the RSVP confuses iCal4 into + // thinking the attendee has not replied + if (isCurrentUser(property) && property.hasParam("RSVP", "TRUE")) { + VProperty.Param partstat = property.getParam("PARTSTAT"); + if (partstat == null || !"NEEDS-ACTION".equals(partstat.getValue())) { + property.removeParam("RSVP"); + } + } + } else { property.setValue(replaceIcal4Principal(property.getValue())); // ignore attendee as organizer @@ -207,14 +276,17 @@ public class VCalendar extends VObject { } } - } else { - // TODO patch RSVP } } + private boolean isCurrentUser(VProperty property) { + return property.getValue().equalsIgnoreCase("mailto:" + email); + } + /** * Convert X-CALENDARSERVER-ACCESS to CLASS. + * see http://svn.calendarserver.org/repository/calendarserver/CalendarServer/trunk/doc/Extensions/caldav-privateevents.txt * * @return CLASS value */ @@ -230,7 +302,7 @@ public class VCalendar extends VObject { /** * Convert CLASS to X-CALENDARSERVER-ACCESS. - * + * see http://svn.calendarserver.org/repository/calendarserver/CalendarServer/trunk/doc/Extensions/caldav-privateevents.txt * * @return X-CALENDARSERVER-ACCESS value */ protected String getCalendarServerAccess() { diff --git a/src/java/davmail/exchange/VObject.java b/src/java/davmail/exchange/VObject.java index 661b3a00..32903aaa 100644 --- a/src/java/davmail/exchange/VObject.java +++ b/src/java/davmail/exchange/VObject.java @@ -20,6 +20,7 @@ package davmail.exchange; import java.io.BufferedReader; import java.io.IOException; +import java.io.StringReader; import java.util.ArrayList; import java.util.List; @@ -73,6 +74,17 @@ public class VObject { this(new VProperty(reader.readLine()), reader); } + /** + * Create VCalendar object from string; + * + * @param itemBody item body + * @throws IOException on error + */ + public VObject(String itemBody) throws IOException { + this(new ICSBufferedReader(new StringReader(itemBody))); + } + + protected void handleLine(String line, BufferedReader reader) throws IOException { VProperty property = new VProperty(line); // inner object @@ -158,13 +170,25 @@ public class VObject { } public void setPropertyValue(String name, String value) { - VProperty property = getProperty(name); - if (property == null) { - property = new VProperty(name, value); - addProperty(property); + if (value == null) { + removeProperty(name); } else { - property.setValue(value); + VProperty property = getProperty(name); + if (property == null) { + property = new VProperty(name, value); + addProperty(property); + } else { + property.setValue(value); + } } + } + public void removeProperty(String name) { + if (vObjects != null) { + VProperty property = getProperty(name); + if (property != null) { + vObjects.remove(property); + } + } } } diff --git a/src/java/davmail/exchange/VProperty.java b/src/java/davmail/exchange/VProperty.java index d10e2824..2c991bb9 100644 --- a/src/java/davmail/exchange/VProperty.java +++ b/src/java/davmail/exchange/VProperty.java @@ -39,6 +39,14 @@ public class VProperty { } values.addAll(paramValues); } + + public String getValue() { + if (values != null && !values.isEmpty()) { + return values.get(0); + } else { + return null; + } + } } protected String key; @@ -189,6 +197,15 @@ public class VProperty { return params != null && getParam(paramName) != null; } + public void removeParam(String paramName) { + if (params != null) { + Param param = getParam(paramName); + if (param != null) { + params.remove(param); + } + } + } + protected boolean containsIgnoreCase(List stringCollection, String value) { for (String collectionValue : stringCollection) { if (value.equalsIgnoreCase(collectionValue)) { @@ -199,7 +216,7 @@ public class VProperty { } protected void addParam(String paramName) { - addParam(paramName, (String)null); + addParam(paramName, (String) null); } protected void addParam(String paramName, String paramValue) { @@ -330,7 +347,7 @@ public class VProperty { } protected void appendParamValue(StringBuilder buffer, String value) { - if (Math.max(value.indexOf(';'), value.indexOf(',')) >= 0) { + if (value.indexOf(';') >= 0 || value.indexOf(',') >= 0 || value.indexOf('(') >= 0 || value.indexOf('/') >= 0) { buffer.append('"').append(value).append('"'); } else { buffer.append(value); diff --git a/src/java/davmail/exchange/dav/DavExchangeSession.java b/src/java/davmail/exchange/dav/DavExchangeSession.java index 4f434566..778139f5 100644 --- a/src/java/davmail/exchange/dav/DavExchangeSession.java +++ b/src/java/davmail/exchange/dav/DavExchangeSession.java @@ -24,6 +24,7 @@ import davmail.exception.DavMailAuthenticationException; import davmail.exception.DavMailException; import davmail.exception.HttpNotFoundException; import davmail.exchange.ExchangeSession; +import davmail.exchange.VObject; import davmail.http.DavGatewayHttpClientFacade; import davmail.ui.tray.DavGatewayTray; import davmail.util.IOUtil; @@ -1377,7 +1378,6 @@ public class DavExchangeSession extends ExchangeSession { @Override protected void loadVtimezone() { try { - VTimezone userTimezone = new VTimezone(); // create temporary folder String folderPath = getFolderPath("davmailtemp"); createCalendarFolder(folderPath, null); @@ -1403,14 +1403,14 @@ public class DavExchangeSession extends ExchangeSession { propertyList.add(Field.createDavProperty("instancetype", "0")); // get forced timezone id from settings - userTimezone.timezoneId = Settings.getProperty("davmail.timezoneId"); - if (userTimezone.timezoneId == null) { + String timezoneId = Settings.getProperty("davmail.timezoneId"); + if (timezoneId == null) { // get timezoneid from OWA settings - userTimezone.timezoneId = getTimezoneIdFromExchange(); + timezoneId = getTimezoneIdFromExchange(); } // without a timezoneId, use Exchange timezone - if (userTimezone.timezoneId != null) { - propertyList.add(Field.createDavProperty("timezoneid", userTimezone.timezoneId)); + if (timezoneId != null) { + propertyList.add(Field.createDavProperty("timezoneid", timezoneId)); } String patchMethodUrl = URIUtil.encodePath(folderPath) + '/' + UUID.randomUUID().toString() + ".EML"; PropPatchMethod patchMethod = new PropPatchMethod(URIUtil.encodePath(patchMethodUrl), propertyList); @@ -1429,10 +1429,9 @@ public class DavExchangeSession extends ExchangeSession { getMethod.setRequestHeader("Translate", "f"); try { httpClient.executeMethod(getMethod); - userTimezone.timezoneBody = "BEGIN:VTIMEZONE" + + this.vTimezone = new VObject("BEGIN:VTIMEZONE" + StringUtil.getToken(getMethod.getResponseBodyAsString(), "BEGIN:VTIMEZONE", "END:VTIMEZONE") + - "END:VTIMEZONE\r\n"; - userTimezone.timezoneId = StringUtil.getToken(userTimezone.timezoneBody, "TZID:", "\r\n"); + "END:VTIMEZONE\r\n"); } finally { getMethod.releaseConnection(); } @@ -1440,7 +1439,6 @@ public class DavExchangeSession extends ExchangeSession { // delete temporary folder deleteFolder("davmailtemp"); - this.vTimezone = userTimezone; } catch (IOException e) { LOGGER.warn("Unable to get VTIMEZONE info: " + e, e); } diff --git a/src/test/davmail/exchange/TestExchangeSessionCalendar.java b/src/test/davmail/exchange/TestExchangeSessionCalendar.java index 666154ae..4dc2513f 100644 --- a/src/test/davmail/exchange/TestExchangeSessionCalendar.java +++ b/src/test/davmail/exchange/TestExchangeSessionCalendar.java @@ -33,9 +33,9 @@ import java.util.*; public class TestExchangeSessionCalendar extends AbstractExchangeSessionTestCase { public void testGetVtimezone() { - ExchangeSession.VTimezone timezone = session.getVTimezone(); - assertNotNull(timezone.timezoneId); - assertNotNull(timezone.timezoneBody); + VObject timezone = session.getVTimezone(); + assertNotNull(timezone); + assertNotNull(timezone.getPropertyValue("TZID")); } public void testDumpVtimezones() throws IOException { @@ -63,10 +63,10 @@ public class TestExchangeSessionCalendar extends AbstractExchangeSessionTestCase }; for (int i = 1; i < 100; i++) { Settings.setProperty("davmail.timezoneId", String.valueOf(i)); - ExchangeSession.VTimezone timezone = session.getVTimezone(); - if (timezone.timezoneId != null) { - properties.put(timezone.timezoneId.replaceAll("\\\\", ""), String.valueOf(i)); - System.out.println(timezone.timezoneId + '=' + i); + VObject timezone = session.getVTimezone(); + if (timezone.getProperty("TZID") != null) { + properties.put(timezone.getPropertyValue("TZID").replaceAll("\\\\", ""), String.valueOf(i)); + System.out.println(timezone.getPropertyValue("TZID") + '=' + i); } session.vTimezone = null; } diff --git a/src/test/davmail/exchange/TestExchangeSessionEvent.java b/src/test/davmail/exchange/TestExchangeSessionEvent.java index 4f608a7f..2f7a0727 100644 --- a/src/test/davmail/exchange/TestExchangeSessionEvent.java +++ b/src/test/davmail/exchange/TestExchangeSessionEvent.java @@ -18,344 +18,45 @@ */ package davmail.exchange; -import davmail.Settings; -import davmail.exception.DavMailException; -import davmail.util.StringUtil; import junit.framework.TestCase; -import java.io.BufferedReader; import java.io.IOException; import java.io.StringReader; -import java.util.ArrayList; -import java.util.List; -import java.util.MissingResourceException; -import java.util.ResourceBundle; /** * Test ExchangeSession event conversion. */ +@SuppressWarnings({"UseOfSystemOutOrSystemErr"}) public class TestExchangeSessionEvent extends TestCase { - String email = "user@company.com"; + static String email = "user@company.com"; + static VObject vTimeZone; - /* - X-CALENDARSERVER-ACCESS conversion - see http://svn.calendarserver.org/repository/calendarserver/CalendarServer/trunk/doc/Extensions/caldav-privateevents.txt - - X-CALENDARSERVER-ACCESS -> CLASS - NONE -> NONE - PUBLIC -> PUBLIC - PRIVATE -> CONFIDENTIAL - CONFIDENTIAL -> PRIVATE - RESTRICTED -> PRIVATE - - CLASS -> X-CALENDARSERVER-ACCESS - NONE -> NONE - PUBLIC -> PUBLIC - PRIVATE -> CONFIDENTIAL - CONFIDENTIAL -> PRIVATE - - iCal 3 sends X-CALENDARSERVER-ACCESS inside VEVENT, iCal 4 uses global X-CALENDARSERVER-ACCESS - */ - - protected String getAllDayLine(String line) throws IOException { - int valueIndex = line.lastIndexOf(':'); - int valueEndIndex = line.lastIndexOf('T'); - if (valueIndex < 0 || valueEndIndex < 0) { - throw new DavMailException("EXCEPTION_INVALID_ICS_LINE", line); - } - int keyIndex = line.indexOf(';'); - if (keyIndex == -1) { - keyIndex = valueIndex; - } - String dateValue = line.substring(valueIndex + 1, valueEndIndex); - String key = line.substring(0, Math.min(keyIndex, valueIndex)); - return key + ";VALUE=DATE:" + dateValue; - } - - protected String fixTimezoneId(String line, String validTimezoneId) { - return StringUtil.replaceToken(line, "TZID=", ":", validTimezoneId); - } - - protected String replaceIcal4Principal(String value) { - if (value.contains("/principals/__uuids__/")) { - return value.replaceAll("/principals/__uuids__/([^/]*)__AT__([^/]*)/", "mailto:$1@$2"); - } else { - return value; - } - } - - protected void splitExDate(ICSBufferedWriter result, String line) { - int cur = line.lastIndexOf(':') + 1; - String start = line.substring(0, cur); - - for (int next = line.indexOf(',', cur); next != -1; next = line.indexOf(',', cur)) { - String val = line.substring(cur, next); - result.writeLine(start + val); - - cur = next + 1; - } - - result.writeLine(start + line.substring(cur)); - } - - protected String oldfixICS(String icsBody, boolean fromServer) throws IOException { - // first pass : detect - class AllDayState { - boolean isAllDay; - boolean hasCdoAllDay; - boolean isCdoAllDay; - } - - // Convert event class from and to iCal - // See https://trac.calendarserver.org/browser/CalendarServer/trunk/doc/Extensions/caldav-privateevents.txt - boolean isAppleiCal = false; - boolean hasAttendee = false; - boolean hasCdoBusyStatus = false; - // detect ics event with empty timezone (all day from Lightning) - boolean hasTimezone = false; - String transp = null; - String validTimezoneId = null; - String eventClass = null; - String organizer = null; - String action = null; - String method = null; - boolean sound = false; - - List allDayStates = new ArrayList(); - AllDayState currentAllDayState = new AllDayState(); - BufferedReader reader = null; + static { try { - reader = new ICSBufferedReader(new StringReader(icsBody)); - String line; - while ((line = reader.readLine()) != null) { - int index = line.indexOf(':'); - if (index >= 0) { - String key = line.substring(0, index); - String value = line.substring(index + 1); - if ("DTSTART;VALUE=DATE".equals(key)) { - currentAllDayState.isAllDay = true; - } else if ("X-MICROSOFT-CDO-ALLDAYEVENT".equals(key)) { - currentAllDayState.hasCdoAllDay = true; - currentAllDayState.isCdoAllDay = "TRUE".equals(value); - } else if ("END:VEVENT".equals(line)) { - allDayStates.add(currentAllDayState); - currentAllDayState = new AllDayState(); - } else if ("PRODID".equals(key) && line.contains("iCal")) { - // detect iCal created events - isAppleiCal = true; - } else if (isAppleiCal && "X-CALENDARSERVER-ACCESS".equals(key)) { - eventClass = value; - } else if (!isAppleiCal && "CLASS".equals(key)) { - eventClass = value; - } else if ("ACTION".equals(key)) { - action = value; - } else if ("ATTACH;VALUES=URI".equals(key)) { - // This is a marker that this event has an alarm with sound - sound = true; - } else if (key.startsWith("ORGANIZER")) { - if (value.startsWith("MAILTO:")) { - organizer = value.substring(7); - } else { - organizer = value; - } - } else if (key.startsWith("ATTENDEE")) { - hasAttendee = true; - } else if ("TRANSP".equals(key)) { - transp = value; - } else if (line.startsWith("TZID:(GMT") || - // additional test for Outlook created recurring events - line.startsWith("TZID:GMT ")) { - try { - validTimezoneId = ResourceBundle.getBundle("timezones").getString(value); - } catch (MissingResourceException mre) { - //LOGGER.warn(new BundleMessage("LOG_INVALID_TIMEZONE", value)); - } - } else if ("X-MICROSOFT-CDO-BUSYSTATUS".equals(key)) { - hasCdoBusyStatus = true; - } else if ("BEGIN:VTIMEZONE".equals(line)) { - hasTimezone = true; - } else if ("METHOD".equals(key)) { - method = value; - } - } - } - } finally { - if (reader != null) { - reader.close(); - } + vTimeZone = new VObject(new ICSBufferedReader(new StringReader("BEGIN:VTIMEZONE\n" + + "TZID:(GMT+01.00) Paris/Madrid/Brussels/Copenhagen\n" + + "X-MICROSOFT-CDO-TZID:3\n" + + "BEGIN:STANDARD\n" + + "DTSTART:16010101T030000\n" + + "TZOFFSETFROM:+0200\n" + + "TZOFFSETTO:+0100\n" + + "RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTH=10;BYDAY=-1SU\n" + + "END:STANDARD\n" + + "BEGIN:DAYLIGHT\n" + + "DTSTART:16010101T020000\n" + + "TZOFFSETFROM:+0100\n" + + "TZOFFSETTO:+0200\n" + + "RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTH=3;BYDAY=-1SU\n" + + "END:DAYLIGHT\n" + + "END:VTIMEZONE"))); + } catch (IOException e) { + e.printStackTrace(); } - // second pass : fix - int count = 0; - ICSBufferedWriter result = new ICSBufferedWriter(); - try { - reader = new ICSBufferedReader(new StringReader(icsBody)); - String line; - - while ((line = reader.readLine()) != null) { - // remove empty properties - if ("CLASS:".equals(line) || "LOCATION:".equals(line)) { - continue; - } - // fix invalid exchange timezoneid - if (validTimezoneId != null && line.indexOf(";TZID=") >= 0) { - line = fixTimezoneId(line, validTimezoneId); - } - if (!fromServer && "BEGIN:VCALENDAR".equals(line) && method == null) { - result.writeLine(line); - // append missing method - if (method == null) { - result.writeLine("METHOD:PUBLISH"); - } - continue; - } - if (fromServer && line.startsWith("PRODID:") && eventClass != null) { - result.writeLine(line); - // set global calendarserver access for iCal 4 - if ("PRIVATE".equalsIgnoreCase(eventClass)) { - result.writeLine("X-CALENDARSERVER-ACCESS:CONFIDENTIAL"); - } else if ("CONFIDENTIAL".equalsIgnoreCase(eventClass)) { - result.writeLine("X-CALENDARSERVER-ACCESS:PRIVATE"); - } else if (eventClass != null) { - result.writeLine("X-CALENDARSERVER-ACCESS:" + eventClass); - } - continue; - } - if (!fromServer && "BEGIN:VEVENT".equals(line) && !hasTimezone) { - result.write("BEGIN:VTIMEZONE\nFAKE:FAKE\nEND:VTIMEZONE\n"); - hasTimezone = true; - } - if (!fromServer && currentAllDayState.isAllDay && "X-MICROSOFT-CDO-ALLDAYEVENT:FALSE".equals(line)) { - line = "X-MICROSOFT-CDO-ALLDAYEVENT:TRUE"; - } else if (!fromServer && "END:VEVENT".equals(line)) { - if (!hasCdoBusyStatus) { - result.writeLine("X-MICROSOFT-CDO-BUSYSTATUS:" + (!"TRANSPARENT".equals(transp) ? "BUSY" : "FREE")); - } - if (currentAllDayState.isAllDay && !currentAllDayState.hasCdoAllDay) { - result.writeLine("X-MICROSOFT-CDO-ALLDAYEVENT:TRUE"); - } - // add organizer line to all events created in Exchange for active sync - if (organizer == null) { - result.writeLine("ORGANIZER:MAILTO:" + email); - } - if (isAppleiCal) { - if ("CONFIDENTIAL".equalsIgnoreCase(eventClass)) { - result.writeLine("CLASS:PRIVATE"); - } else if ("PRIVATE".equalsIgnoreCase(eventClass)) { - result.writeLine("CLASS:CONFIDENTIAL"); - } else if (eventClass != null) { - result.writeLine("CLASS:" + eventClass); - } - } - } else if (!fromServer && line.startsWith("X-MICROSOFT-CDO-BUSYSTATUS:")) { - line = "X-MICROSOFT-CDO-BUSYSTATUS:" + (!"TRANSPARENT".equals(transp) ? "BUSY" : "FREE"); - } else if (!fromServer && !currentAllDayState.isAllDay && "X-MICROSOFT-CDO-ALLDAYEVENT:TRUE".equals(line)) { - line = "X-MICROSOFT-CDO-ALLDAYEVENT:FALSE"; - } else if (fromServer && currentAllDayState.isCdoAllDay && line.startsWith("DTSTART") && !line.startsWith("DTSTART;VALUE=DATE")) { - line = getAllDayLine(line); - } else if (fromServer && currentAllDayState.isCdoAllDay && line.startsWith("DTEND") && !line.startsWith("DTEND;VALUE=DATE")) { - line = getAllDayLine(line); - } else if (!fromServer && currentAllDayState.isAllDay && line.startsWith("DTSTART") && line.startsWith("DTSTART;VALUE=DATE")) { - line = "DTSTART;TZID=\"" + "OWATZID" + "\":" + line.substring(19) + "T000000"; - } else if (!fromServer && currentAllDayState.isAllDay && line.startsWith("DTEND") && line.startsWith("DTEND;VALUE=DATE")) { - line = "DTEND;TZID=\"" + "OWATZID" + "\":" + line.substring(17) + "T000000"; - } else if (line.startsWith("TZID:") && validTimezoneId != null) { - line = "TZID:" + validTimezoneId; - } else if ("BEGIN:VEVENT".equals(line)) { - currentAllDayState = allDayStates.get(count++); - // remove calendarserver access - } else if (line.startsWith("X-CALENDARSERVER-ACCESS:")) { - continue; - } else if (line.startsWith("EXDATE;TZID=") || line.startsWith("EXDATE:")) { - // Apple iCal doesn't support EXDATE with multiple exceptions - // on one line. Split into multiple EXDATE entries (which is - // also legal according to the caldav standard). - splitExDate(result, line); - continue; - } else if (line.startsWith("X-ENTOURAGE_UUID:")) { - // Apple iCal doesn't understand this key, and it's entourage - // specific (i.e. not needed by any caldav client): strip it out - continue; - } else if (fromServer && line.startsWith("ATTENDEE;") - && (line.indexOf(email) >= 0)) { - // If this is coming from the server, strip out RSVP for this - // user as an attendee where the partstat is something other - // than PARTSTAT=NEEDS-ACTION since the RSVP confuses iCal4 into - // thinking the attendee has not replied - - int rsvpSuffix = line.indexOf("RSVP=TRUE;"); - int rsvpPrefix = line.indexOf(";RSVP=TRUE"); - - if (((rsvpSuffix >= 0) || (rsvpPrefix >= 0)) - && (line.indexOf("PARTSTAT=") >= 0) - && (line.indexOf("PARTSTAT=NEEDS-ACTION") < 0)) { - - // Strip out the "RSVP" line from the calendar entry - if (rsvpSuffix >= 0) { - line = line.substring(0, rsvpSuffix) + line.substring(rsvpSuffix + 10); - } else { - line = line.substring(0, rsvpPrefix) + line.substring(rsvpPrefix + 10); - } - - } - } else if (line.startsWith("ACTION:")) { - if (fromServer && "DISPLAY".equals(action) - // convert DISPLAY to AUDIO only if user defined an alarm sound - && Settings.getProperty("davmail.caldavAlarmSound") != null) { - // Convert alarm to audio for iCal - result.writeLine("ACTION:AUDIO"); - - if (!sound) { - // Add defined sound into the audio alarm - result.writeLine("ATTACH;VALUE=URI:" + Settings.getProperty("davmail.caldavAlarmSound")); - } - - continue; - } else if (!fromServer && "AUDIO".equals(action)) { - // Use the alarm action that exchange (and blackberry) understand - // (exchange and blackberry don't understand audio actions) - - result.writeLine("ACTION:DISPLAY"); - continue; - } - - // Don't recognize this type of action: pass it through - - } else if (line.startsWith("CLASS:")) { - if (!fromServer && isAppleiCal) { - continue; - } else { - // still set calendarserver access inside event for iCal 3 - if ("PRIVATE".equalsIgnoreCase(eventClass)) { - result.writeLine("X-CALENDARSERVER-ACCESS:CONFIDENTIAL"); - } else if ("CONFIDENTIAL".equalsIgnoreCase(eventClass)) { - result.writeLine("X-CALENDARSERVER-ACCESS:PRIVATE"); - } else { - result.writeLine("X-CALENDARSERVER-ACCESS:" + eventClass); - } - } - // remove organizer line if user is organizer for iPhone - } else if (fromServer && line.startsWith("ORGANIZER") && !hasAttendee) { - continue; - } else if (organizer != null && line.startsWith("ATTENDEE") && line.contains(organizer)) { - // Ignore organizer as attendee - continue; - } else if (!fromServer && line.startsWith("ATTENDEE")) { - line = replaceIcal4Principal(line); - } - - result.writeLine(line); - } - } finally { - reader.close(); - } - - String resultString = result.toString(); - - return resultString; } + protected String fixICS(String icsBody, boolean fromServer) throws IOException { - VCalendar vCalendar = new VCalendar(icsBody, email); + VCalendar vCalendar = new VCalendar(icsBody, email, vTimeZone); vCalendar.fixVCalendar(fromServer); return vCalendar.toString(); } @@ -491,4 +192,63 @@ public class TestExchangeSessionEvent extends TestCase { System.out.println(toServer); assertTrue(toServer.contains("ACTION:DISPLAY")); } + + public void testReceiveAllDay() throws IOException { + String itemBody = "BEGIN:VCALENDAR\n" + + vTimeZone + + "BEGIN:VEVENT\n" + + "DTSTART;TZID=\"(GMT+01.00) Paris/Madrid/Brussels/Copenhagen\":20100615T000000\n" + + "DTEND;TZID=\"(GMT+01.00) Paris/Madrid/Brussels/Copenhagen\":20100616T000000\n" + + "X-MICROSOFT-CDO-ALLDAYEVENT:TRUE\n" + + "END:VEVENT\n" + + "END:VCALENDAR"; + String toClient = fixICS(itemBody, true); + System.out.println(toClient); + // OWA created allday events have the X-MICROSOFT-CDO-ALLDAYEVENT set to true and always 000000 in event time + // just remove the TZID, add VALUE=DATE param and set a date only value + assertTrue(toClient.contains("DTSTART;VALUE=DATE:20100615")); + assertTrue(toClient.contains("DTEND;VALUE=DATE:20100616")); + } + + public void testSendAllDay() throws IOException { + String itemBody = "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "DTSTART;VALUE=DATE:20100615\n" + + "DTEND;VALUE=DATE:20100616\n" + + "END:VEVENT\n" + + "END:VCALENDAR"; + String toServer = fixICS(itemBody, false); + System.out.println(toServer); + // Client created allday event have no timezone and no time information in date values + // first set the X-MICROSOFT-CDO-ALLDAYEVENT flag for OWA + assertTrue(toServer.contains("X-MICROSOFT-CDO-ALLDAYEVENT:TRUE")); + // then patch TZID for Outlook (need to retrieve OWA TZID + assertTrue(toServer.contains("BEGIN:VTIMEZONE")); + assertTrue(toServer.contains("TZID:" + vTimeZone.getPropertyValue("TZID"))); + assertTrue(toServer.contains("DTSTART;TZID=\"" + vTimeZone.getPropertyValue("TZID") + "\":20100615T000000")); + assertTrue(toServer.contains("DTEND;TZID=\"" + vTimeZone.getPropertyValue("TZID") + "\":20100616T000000")); + } + + public void testRsvp() throws IOException { + String itemBody = "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "ATTENDEE;PARTSTAT=ACCEPTED;RSVP=TRUE:MAILTO:" + email + "\n" + + "END:VEVENT\n" + + "END:VCALENDAR"; + String toClient = fixICS(itemBody, true); + System.out.println(toClient); + assertTrue(toClient.contains("ATTENDEE;PARTSTAT=ACCEPTED:MAILTO:" + email)); + } + + public void testExdate() throws IOException { + String itemBody = "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "EXDATE;TZID=\"Europe/Paris\":20100809T150000,20100823T150000\n" + + "END:VEVENT\n" + + "END:VCALENDAR"; + String toClient = fixICS(itemBody, true); + System.out.println(toClient); + assertTrue(toClient.contains("EXDATE;TZID=\"Europe/Paris\":20100823T150000")); + + } }