/** * Copyright (c) 2012 Todoroo Inc * * See the file "LICENSE" for the full license governing this code. */ package com.todoroo.astrid.repeats; import java.text.ParseException; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.TimeZone; import android.content.BroadcastReceiver; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import com.google.ical.iter.RecurrenceIterator; import com.google.ical.iter.RecurrenceIteratorFactory; import com.google.ical.values.DateTimeValueImpl; import com.google.ical.values.DateValue; import com.google.ical.values.DateValueImpl; import com.google.ical.values.Frequency; import com.google.ical.values.RRule; import com.google.ical.values.WeekdayNum; import com.todoroo.andlib.service.Autowired; import com.todoroo.andlib.service.ContextManager; import com.todoroo.andlib.service.DependencyInjectionService; import com.todoroo.andlib.utility.DateUtilities; import com.todoroo.astrid.actfm.sync.ActFmPreferenceService; import com.todoroo.astrid.api.AstridApiConstants; import com.todoroo.astrid.core.PluginServices; import com.todoroo.astrid.data.Task; import com.todoroo.astrid.gcal.GCalHelper; import com.todoroo.astrid.service.StatisticsConstants; import com.todoroo.astrid.service.StatisticsService; import com.todoroo.astrid.service.TaskService; import com.todoroo.astrid.utility.Flags; public class RepeatTaskCompleteListener extends BroadcastReceiver { @Autowired ActFmPreferenceService actFmPreferenceService; @Override public void onReceive(Context context, Intent intent) { ContextManager.setContext(context); DependencyInjectionService.getInstance().inject(this); long taskId = intent.getLongExtra(AstridApiConstants.EXTRAS_TASK_ID, -1); if(taskId == -1) return; Task task = PluginServices.getTaskService().fetchById(taskId, Task.PROPERTIES); if(task == null || !task.isCompleted()) return; String recurrence = task.sanitizedRecurrence(); boolean repeatAfterCompletion = task.repeatAfterCompletion(); if(recurrence != null && recurrence.length() > 0) { long newDueDate; try { newDueDate = computeNextDueDate(task, recurrence, repeatAfterCompletion); if(newDueDate == -1) return; } catch (ParseException e) { PluginServices.getExceptionService().reportError("repeat-parse", e); //$NON-NLS-1$ return; } StatisticsService.reportEvent(StatisticsConstants.V2_TASK_REPEAT); long oldDueDate = task.getValue(Task.DUE_DATE); long repeatUntil = task.getValue(Task.REPEAT_UNTIL); boolean repeatFinished = repeatUntil > 0 && newDueDate >= repeatUntil; if (repeatFinished) { Intent repeatFinishedIntent = new Intent(AstridApiConstants.BROADCAST_EVENT_TASK_REPEAT_FINISHED); repeatFinishedIntent.putExtra(AstridApiConstants.EXTRAS_TASK_ID, task.getId()); repeatFinishedIntent.putExtra(AstridApiConstants.EXTRAS_OLD_DUE_DATE, oldDueDate); repeatFinishedIntent.putExtra(AstridApiConstants.EXTRAS_NEW_DUE_DATE, newDueDate); context.sendOrderedBroadcast(repeatFinishedIntent, null); return; } rescheduleTask(task, newDueDate); // send a broadcast Intent broadcastIntent = new Intent(AstridApiConstants.BROADCAST_EVENT_TASK_REPEATED); broadcastIntent.putExtra(AstridApiConstants.EXTRAS_TASK_ID, task.getId()); broadcastIntent.putExtra(AstridApiConstants.EXTRAS_OLD_DUE_DATE, oldDueDate); broadcastIntent.putExtra(AstridApiConstants.EXTRAS_NEW_DUE_DATE, newDueDate); context.sendOrderedBroadcast(broadcastIntent, null); Flags.set(Flags.REFRESH); return; } } public static void rescheduleTask(Task task, long newDueDate) { long hideUntil = task.getValue(Task.HIDE_UNTIL); if(hideUntil > 0 && task.getValue(Task.DUE_DATE) > 0) { hideUntil += newDueDate - task.getValue(Task.DUE_DATE); } task.setValue(Task.COMPLETION_DATE, 0L); task.setValue(Task.DUE_DATE, newDueDate); task.setValue(Task.HIDE_UNTIL, hideUntil); task.putTransitory(TaskService.TRANS_REPEAT_COMPLETE, true); ContentResolver cr = ContextManager.getContext().getContentResolver(); GCalHelper.rescheduleRepeatingTask(task, cr); PluginServices.getTaskService().save(task); } /** Compute next due date */ public static long computeNextDueDate(Task task, String recurrence, boolean repeatAfterCompletion) throws ParseException { RRule rrule = initRRule(recurrence); // initialize startDateAsDV Date original = setUpStartDate(task, repeatAfterCompletion, rrule.getFreq()); DateValue startDateAsDV = setUpStartDateAsDV(task, original); if(rrule.getFreq() == Frequency.HOURLY || rrule.getFreq() == Frequency.MINUTELY) return handleSubdayRepeat(original, rrule); else if(rrule.getFreq() == Frequency.WEEKLY && rrule.getByDay().size() > 0 && repeatAfterCompletion) return handleWeeklyRepeatAfterComplete(rrule, original, task.hasDueTime()); else if (rrule.getFreq() == Frequency.MONTHLY) return handleMonthlyRepeat(original, startDateAsDV, task.hasDueTime(), rrule); else return invokeRecurrence(rrule, original, startDateAsDV); } private static long handleWeeklyRepeatAfterComplete(RRule rrule, Date original, boolean hasDueTime) { List<WeekdayNum> byDay = rrule.getByDay(); long newDate = original.getTime(); newDate += DateUtilities.ONE_WEEK * (rrule.getInterval() - 1); Calendar date = Calendar.getInstance(); date.setTimeInMillis(newDate); Collections.sort(byDay, weekdayCompare); WeekdayNum next = findNextWeekday(byDay, date); do { date.add(Calendar.DATE, 1); } while (date.get(Calendar.DAY_OF_WEEK) != next.wday.javaDayNum); long time = date.getTimeInMillis(); if(hasDueTime) return Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, time); else return Task.createDueDate(Task.URGENCY_SPECIFIC_DAY, time); } private static long handleMonthlyRepeat(Date original, DateValue startDateAsDV, boolean hasDueTime, RRule rrule) { if (DateUtilities.isEndOfMonth(original)) { Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(original.getTime()); int interval = rrule.getInterval(); cal.add(Calendar.MONTH, interval); cal.set(Calendar.DATE, cal.getActualMaximum(Calendar.DATE)); long time = cal.getTimeInMillis(); if (hasDueTime) return Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, time); else return Task.createDueDate(Task.URGENCY_SPECIFIC_DAY, time); } else { return invokeRecurrence(rrule, original, startDateAsDV); } } private static Comparator<WeekdayNum> weekdayCompare = new Comparator<WeekdayNum>() { @Override public int compare(WeekdayNum object1, WeekdayNum object2) { return object1.wday.javaDayNum - object2.wday.javaDayNum; } }; private static WeekdayNum findNextWeekday(List<WeekdayNum> byDay, Calendar date) { WeekdayNum next = byDay.get(0); for (int i = 0; i < byDay.size(); i++) { WeekdayNum weekday = byDay.get(i); if (weekday.wday.javaDayNum > date.get(Calendar.DAY_OF_WEEK)) { return weekday; } } return next; } private static long invokeRecurrence(RRule rrule, Date original, DateValue startDateAsDV) { long newDueDate = -1; RecurrenceIterator iterator = RecurrenceIteratorFactory.createRecurrenceIterator(rrule, startDateAsDV, TimeZone.getDefault()); DateValue nextDate = startDateAsDV; for(int i = 0; i < 10; i++) { // ten tries then we give up if(!iterator.hasNext()) return -1; nextDate = iterator.next(); if(nextDate.compareTo(startDateAsDV) == 0) continue; newDueDate = buildNewDueDate(original, nextDate); // detect if we finished if(newDueDate > original.getTime()) break; } return newDueDate; } /** Compute long due date from DateValue */ private static long buildNewDueDate(Date original, DateValue nextDate) { long newDueDate; if(nextDate instanceof DateTimeValueImpl) { DateTimeValueImpl newDateTime = (DateTimeValueImpl)nextDate; Date date = new Date(Date.UTC(newDateTime.year() - 1900, newDateTime.month() - 1, newDateTime.day(), newDateTime.hour(), newDateTime.minute(), newDateTime.second())); // time may be inaccurate due to DST, force time to be same date.setHours(original.getHours()); date.setMinutes(original.getMinutes()); newDueDate = Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, date.getTime()); } else { newDueDate = Task.createDueDate(Task.URGENCY_SPECIFIC_DAY, new Date(nextDate.year() - 1900, nextDate.month() - 1, nextDate.day()).getTime()); } return newDueDate; } /** Initialize RRule instance */ private static RRule initRRule(String recurrence) throws ParseException { RRule rrule = new RRule(recurrence); // handle the iCalendar "byDay" field differently depending on if // we are weekly or otherwise if(rrule.getFreq() != Frequency.WEEKLY) rrule.setByDay(Collections.EMPTY_LIST); return rrule; } /** Set up repeat start date * @param frequency */ private static Date setUpStartDate(Task task, boolean repeatAfterCompletion, Frequency frequency) { Date startDate = new Date(); if(task.hasDueDate()) { Date dueDate = new Date(task.getValue(Task.DUE_DATE)); if(repeatAfterCompletion) startDate = new Date(task.getValue(Task.COMPLETION_DATE)); else startDate = dueDate; if(task.hasDueTime() && frequency != Frequency.HOURLY && frequency != Frequency.MINUTELY) { startDate.setHours(dueDate.getHours()); startDate.setMinutes(dueDate.getMinutes()); startDate.setSeconds(dueDate.getSeconds()); } } return startDate; } private static DateValue setUpStartDateAsDV(Task task, Date startDate) { if(task.hasDueTime()) return new DateTimeValueImpl(startDate.getYear() + 1900, startDate.getMonth() + 1, startDate.getDate(), startDate.getHours(), startDate.getMinutes(), startDate.getSeconds()); else return new DateValueImpl(startDate.getYear() + 1900, startDate.getMonth() + 1, startDate.getDate()); } private static long handleSubdayRepeat(Date startDate, RRule rrule) { long millis; switch(rrule.getFreq()) { case HOURLY: millis = DateUtilities.ONE_HOUR; break; case MINUTELY: millis = DateUtilities.ONE_MINUTE; break; default: throw new RuntimeException("Error handing subday repeat: " + rrule.getFreq()); //$NON-NLS-1$ } long newDueDate = startDate.getTime() + millis * rrule.getInterval(); return Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, newDueDate); } }