Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 062bfc60 authored by Ricki Hirner's avatar Ricki Hirner
Browse files

Convert RDate/ExDate properties <-> Android RDATE/EXDATE strings more precisely (+ tests)

parent f7cc59eb
Loading
Loading
Loading
Loading
+82 −0
Original line number Diff line number Diff line
/*
 * 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 junit.framework.TestCase;

import net.fortuna.ical4j.model.Date;
import net.fortuna.ical4j.model.DateList;
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 net.fortuna.ical4j.model.property.ExDate;
import net.fortuna.ical4j.model.property.RDate;

import java.text.ParseException;
import java.util.ArrayList;
import java.util.List;

public class DateUtilsTest extends TestCase {
	private static final String tzIdVienna = "Europe/Vienna";

	public void testRecurrenceSetsToAndroidString() throws ParseException {
		// one entry without time zone (implicitly UTC)
		final List<RDate> list = new ArrayList<>(2);
		list.add(new RDate(new DateList("20150101T103010Z,20150102T103020Z", Value.DATE_TIME)));
		assertEquals("20150101T103010Z,20150102T103020Z", DateUtils.recurrenceSetsToAndroidString(list, false));

		// two entries (previous one + this), both with time zone Vienna
		list.add(new RDate(new DateList("20150103T113030,20150704T123040", Value.DATE_TIME)));
		final TimeZone tz = DateUtils.tzRegistry.getTimeZone(tzIdVienna);
		for (RDate rdate : list)
			rdate.setTimeZone(tz);
		assertEquals("20150101T103010Z,20150102T103020Z,20150103T103030Z,20150704T103040Z", DateUtils.recurrenceSetsToAndroidString(list, false));

		// DATEs (without time) have to be converted to <date>T000000Z for Android
		list.clear();
		list.add(new RDate(new DateList("20150101,20150702", Value.DATE)));
		assertEquals("20150101T000000Z,20150702T000000Z", DateUtils.recurrenceSetsToAndroidString(list, true));

		// DATE-TIME (floating time or UTC) recurrences for all-day events have to converted to <date>T000000Z for Android
		list.clear();
		list.add(new RDate(new DateList("20150101T000000,20150702T000000Z", Value.DATE_TIME)));
		assertEquals("20150101T000000Z,20150702T000000Z", DateUtils.recurrenceSetsToAndroidString(list, true));
	}

	public void testAndroidStringToRecurrenceSets() throws ParseException {
		// list of UTC times
		ExDate exDate = (ExDate)DateUtils.androidStringToRecurrenceSet("20150101T103010Z,20150702T103020Z", ExDate.class, false);
		DateList exDates = exDate.getDates();
		assertEquals(Value.DATE_TIME, exDates.getType());
		assertTrue(exDates.isUtc());
		assertEquals(2, exDates.size());
		assertEquals(1420108210000L, exDates.get(0).getTime());
		assertEquals(1435833020000L, exDates.get(1).getTime());

		// list of time zone times
		exDate = (ExDate)DateUtils.androidStringToRecurrenceSet(tzIdVienna + ";20150101T103010,20150702T103020", ExDate.class, false);
		exDates = exDate.getDates();
		assertEquals(Value.DATE_TIME, exDates.getType());
		assertEquals(DateUtils.tzRegistry.getTimeZone(tzIdVienna), exDates.getTimeZone());
		assertEquals(2, exDates.size());
		assertEquals(1420104610000L, exDates.get(0).getTime());
		assertEquals(1435825820000L, exDates.get(1).getTime());

		// list of dates
		exDate = (ExDate)DateUtils.androidStringToRecurrenceSet("20150101T103010Z,20150702T103020Z", ExDate.class, true);
		exDates = exDate.getDates();
		assertEquals(Value.DATE, exDates.getType());
		assertEquals(2, exDates.size());
		assertEquals("20150101", exDates.get(0).toString());
		assertEquals("20150702", exDates.get(1).toString());
	}

}
+6 −21
Original line number Diff line number Diff line
@@ -30,17 +30,22 @@ import net.fortuna.ical4j.model.Dur;
import net.fortuna.ical4j.model.TimeZone;
import net.fortuna.ical4j.model.TimeZoneRegistry;
import net.fortuna.ical4j.model.TimeZoneRegistryFactory;
import net.fortuna.ical4j.model.ValidationException;
import net.fortuna.ical4j.model.component.VAlarm;
import net.fortuna.ical4j.model.parameter.Value;
import net.fortuna.ical4j.model.property.DateListProperty;
import net.fortuna.ical4j.model.property.DtEnd;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.ExDate;
import net.fortuna.ical4j.model.property.RDate;
import net.fortuna.ical4j.util.Dates;

import java.text.ParseException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;

import at.bitfire.davdroid.DateUtils;
import lombok.Cleanup;

public class LocalCalendarTest extends InstrumentationTestCase {
@@ -133,8 +138,7 @@ public class LocalCalendarTest extends InstrumentationTestCase {
	public void testBuildEntry() throws LocalStorageException, ParseException {
		final String vcardName = "testBuildEntry";

		TimeZoneRegistry tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry();
		TimeZone tzVienna = tzRegistry.getTimeZone("Europe/Vienna");
		final TimeZone tzVienna = DateUtils.tzRegistry.getTimeZone("Europe/Vienna");
		assertNotNull(tzVienna);

		// build and write event to calendar provider
@@ -231,23 +235,4 @@ public class LocalCalendarTest extends InstrumentationTestCase {
		}
	}

	public void testRecurrenceSetsToAndroidString() throws ParseException {
		final String tzId = "Europe/Vienna";

		// one entry without time zone
		final List<RDate> list = new ArrayList<>(2);
		list.add(new RDate(new DateList("20150101T103000,20150102T103000", Value.DATE_TIME)));
		assertEquals("20150101T103000,20150102T103000", LocalCalendar.recurrenceSetsToAndroidString(list));

		// two entries with time zone
		list.add(new RDate(new DateList("20150103T103000,20150104T103000", Value.DATE_TIME)));

		final TimeZoneRegistry tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry();
		final TimeZone tz = tzRegistry.getTimeZone(tzId);
		for (RDate rdate : list)
			rdate.setTimeZone(tz);

		assertEquals(tzId + ";20150101T103000,20150102T103000,20150103T103000,20150104T103000", LocalCalendar.recurrenceSetsToAndroidString(list));
	}

}
+114 −3
Original line number Diff line number Diff line
@@ -11,19 +11,43 @@ package at.bitfire.davdroid;
import android.text.format.Time;
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.DefaultTimeZoneRegistryFactory;
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 net.fortuna.ical4j.model.property.ExDate;
import net.fortuna.ical4j.model.property.RDate;
import net.fortuna.ical4j.util.TimeZones;

import org.apache.commons.lang3.StringUtils;

import java.lang.reflect.InvocationTargetException;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.GregorianCalendar;
import java.util.LinkedList;
import java.util.List;
import java.util.SimpleTimeZone;
import java.util.StringTokenizer;

public class DateUtils {
    private final static String TAG = "davdroid.DateUtils";

	private final static TimeZoneRegistry tzRegistry = new DefaultTimeZoneRegistryFactory().createRegistry();
	public final static 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 tzID) {
        String localTZ = null;
@@ -56,8 +80,95 @@ public class DateUtils {
        return localTZ;
    }

	public static TimeZone getTimeZone(String tzID) {
		return tzRegistry.getTimeZone(tzID);

	// 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;
	}

}
+2 −2
Original line number Diff line number Diff line
@@ -321,7 +321,7 @@ public class Event extends iCalendar {
			dtStart = new DtStart(new Date(tsStart));
		} else {
			DateTime start = new DateTime(tsStart);
			start.setTimeZone(tzRegistry.getTimeZone(tzID));
			start.setTimeZone(DateUtils.tzRegistry.getTimeZone(tzID));
			dtStart = new DtStart(start);
		}
	}
@@ -340,7 +340,7 @@ public class Event extends iCalendar {
			dtEnd = new DtEnd(new Date(tsEnd));
		} else {
			DateTime end = new DateTime(tsEnd);
			end.setTimeZone(tzRegistry.getTimeZone(tzID));
			end.setTimeZone(DateUtils.tzRegistry.getTimeZone(tzID));
			dtEnd = new DtEnd(end);
		}
	}
+17 −40
Original line number Diff line number Diff line
@@ -41,6 +41,7 @@ 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.Value;
import net.fortuna.ical4j.model.property.Action;
import net.fortuna.ical4j.model.property.Attendee;
import net.fortuna.ical4j.model.property.DateListProperty;
@@ -61,6 +62,7 @@ import java.net.URISyntaxException;
import java.text.ParseException;
import java.util.LinkedList;
import java.util.List;
import java.util.StringTokenizer;

import at.bitfire.davdroid.DAVUtils;
import at.bitfire.davdroid.DateUtils;
@@ -349,8 +351,7 @@ public class LocalCalendar extends LocalCollection<Event> {

			String strRDate = values.getAsString(Events.RDATE);
			if (!StringUtils.isEmpty(strRDate)) {
				RDate rDate = new RDate();
				rDate.setValue(strRDate);
				RDate rDate = (RDate)DateUtils.androidStringToRecurrenceSet(strRDate, RDate.class, allDay);
				e.getRdates().add(rDate);
			}

@@ -363,9 +364,7 @@ public class LocalCalendar extends LocalCollection<Event> {

			String strExDate = values.getAsString(Events.EXDATE);
			if (!StringUtils.isEmpty(strExDate)) {
				// always empty, see https://code.google.com/p/android/issues/detail?id=172411
				ExDate exDate = new ExDate();
				exDate.setValue(strExDate);
				ExDate exDate = (ExDate)DateUtils.androidStringToRecurrenceSet(strExDate, ExDate.class, allDay);
				e.getExdates().add(exDate);
			}
		} catch (ParseException ex) {
@@ -540,12 +539,20 @@ public class LocalCalendar extends LocalCollection<Event> {
		}
		if (!event.getRdates().isEmpty()) {
			recurring = true;
			builder.withValue(Events.RDATE, recurrenceSetsToAndroidString(event.getRdates()));
			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.getExrule() != null)
			 builder.withValue(Events.EXRULE, event.getExrule().getValue());
		if (!event.getExdates().isEmpty())
			builder.withValue(Events.EXDATE, recurrenceSetsToAndroidString(event.getExdates()));
			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)
@@ -731,34 +738,4 @@ public class LocalCalendar extends LocalCollection<Event> {
		return calendarsURI(account);
	}

	/**
	 * Concatenates, if necessary, multiple RDATE/EXDATE lists and prepares
	 * a formatted string as expected by Android calendar provider
	 * @param dates		one more more lists of RDATE or EXDATE
	 * @return			formatted string for Android calendar provider
	 */
	static String recurrenceSetsToAndroidString(List<? extends DateListProperty> dates) {
		String tzID = null;
		List<String> strDates = new LinkedList<>();

		for (DateListProperty dateList : dates) {
			if (dateList.getTimeZone() != null) {
				String thisTzID = DateUtils.findAndroidTimezoneID(dateList.getTimeZone().getID());
				if (tzID == null)
					tzID = thisTzID;
				else if (!tzID.equals(thisTzID))
					Log.w(TAG, "Multiple EXDATEs/RDATEs with different time zones not supported by Android, using " + tzID + " for all dates");
			}
			strDates.add(dateList.getValue());
		}

		// Android expects this format: "[TZID;]date1,date2,date3"
		String dateStr = "";
		if (tzID != null)
			dateStr += tzID + ";";
		dateStr += StringUtils.join(strDates, ",");

		return dateStr;
	}

}
Loading