CalendarUtilities.java revision 4577f71f76c94dc9fcb06efd2656970925dd3f6a
1/* 2 * Copyright (C) 2010 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.exchange.utility; 18 19import com.android.email.Email; 20import com.android.email.R; 21import com.android.email.mail.Address; 22import com.android.email.provider.EmailContent; 23import com.android.email.provider.EmailContent.Account; 24import com.android.email.provider.EmailContent.Attachment; 25import com.android.email.provider.EmailContent.Mailbox; 26import com.android.email.provider.EmailContent.Message; 27import com.android.exchange.Eas; 28import com.android.exchange.EasSyncService; 29import com.android.exchange.adapter.Serializer; 30import com.android.exchange.adapter.Tags; 31 32import android.content.ContentResolver; 33import android.content.ContentUris; 34import android.content.ContentValues; 35import android.content.Context; 36import android.content.Entity; 37import android.content.EntityIterator; 38import android.content.Entity.NamedContentValues; 39import android.net.Uri; 40import android.os.RemoteException; 41import android.provider.Calendar.Attendees; 42import android.provider.Calendar.Calendars; 43import android.provider.Calendar.Events; 44import android.provider.Calendar.EventsEntity; 45import android.provider.Calendar.Reminders; 46import android.text.format.Time; 47import android.util.Log; 48import android.util.base64.Base64; 49 50import java.io.IOException; 51import java.text.ParseException; 52import java.util.ArrayList; 53import java.util.Calendar; 54import java.util.Date; 55import java.util.GregorianCalendar; 56import java.util.HashMap; 57import java.util.TimeZone; 58 59public class CalendarUtilities { 60 // NOTE: Most definitions in this class are have package visibility for testing purposes 61 private static final String TAG = "CalendarUtility"; 62 63 // Time related convenience constants, in milliseconds 64 static final int SECONDS = 1000; 65 static final int MINUTES = SECONDS*60; 66 static final int HOURS = MINUTES*60; 67 static final long DAYS = HOURS*24; 68 69 // NOTE All Microsoft data structures are little endian 70 71 // The following constants relate to standard Microsoft data sizes 72 // For documentation, see http://msdn.microsoft.com/en-us/library/aa505945.aspx 73 static final int MSFT_LONG_SIZE = 4; 74 static final int MSFT_WCHAR_SIZE = 2; 75 static final int MSFT_WORD_SIZE = 2; 76 77 // The following constants relate to Microsoft's SYSTEMTIME structure 78 // For documentation, see: http://msdn.microsoft.com/en-us/library/ms724950(VS.85).aspx?ppud=4 79 80 static final int MSFT_SYSTEMTIME_YEAR = 0 * MSFT_WORD_SIZE; 81 static final int MSFT_SYSTEMTIME_MONTH = 1 * MSFT_WORD_SIZE; 82 static final int MSFT_SYSTEMTIME_DAY_OF_WEEK = 2 * MSFT_WORD_SIZE; 83 static final int MSFT_SYSTEMTIME_DAY = 3 * MSFT_WORD_SIZE; 84 static final int MSFT_SYSTEMTIME_HOUR = 4 * MSFT_WORD_SIZE; 85 static final int MSFT_SYSTEMTIME_MINUTE = 5 * MSFT_WORD_SIZE; 86 //static final int MSFT_SYSTEMTIME_SECONDS = 6 * MSFT_WORD_SIZE; 87 //static final int MSFT_SYSTEMTIME_MILLIS = 7 * MSFT_WORD_SIZE; 88 static final int MSFT_SYSTEMTIME_SIZE = 8*MSFT_WORD_SIZE; 89 90 // The following constants relate to Microsoft's TIME_ZONE_INFORMATION structure 91 // For documentation, see http://msdn.microsoft.com/en-us/library/ms725481(VS.85).aspx 92 static final int MSFT_TIME_ZONE_BIAS_OFFSET = 0; 93 static final int MSFT_TIME_ZONE_STANDARD_NAME_OFFSET = 94 MSFT_TIME_ZONE_BIAS_OFFSET + MSFT_LONG_SIZE; 95 static final int MSFT_TIME_ZONE_STANDARD_DATE_OFFSET = 96 MSFT_TIME_ZONE_STANDARD_NAME_OFFSET + (MSFT_WCHAR_SIZE*32); 97 static final int MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET = 98 MSFT_TIME_ZONE_STANDARD_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE; 99 static final int MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET = 100 MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET + MSFT_LONG_SIZE; 101 static final int MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET = 102 MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET + (MSFT_WCHAR_SIZE*32); 103 static final int MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET = 104 MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE; 105 static final int MSFT_TIME_ZONE_SIZE = 106 MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET + MSFT_LONG_SIZE; 107 108 // TimeZone cache; we parse/decode as little as possible, because the process is quite slow 109 private static HashMap<String, TimeZone> sTimeZoneCache = new HashMap<String, TimeZone>(); 110 // TZI string cache; we keep around our encoded TimeZoneInformation strings 111 private static HashMap<TimeZone, String> sTziStringCache = new HashMap<TimeZone, String>(); 112 113 // There is no type 4 (thus, the "") 114 static final String[] sTypeToFreq = 115 new String[] {"DAILY", "WEEKLY", "MONTHLY", "MONTHLY", "", "YEARLY", "YEARLY"}; 116 117 static final String[] sDayTokens = 118 new String[] {"SU", "MO", "TU", "WE", "TH", "FR", "SA"}; 119 120 static final String[] sTwoCharacterNumbers = 121 new String[] {"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"}; 122 123 static final int sCurrentYear = new GregorianCalendar().get(Calendar.YEAR); 124 static final TimeZone sGmtTimeZone = TimeZone.getTimeZone("GMT"); 125 126 private static final String ICALENDAR_ATTENDEE = "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT="; 127 private static final String ICALENDAR_ATTENDEE_INVITE = 128 ICALENDAR_ATTENDEE + "NEEDS-ACTION;RSVP=TRUE"; 129 private static final String ICALENDAR_ATTENDEE_ACCEPT = ICALENDAR_ATTENDEE + "ACCEPTED"; 130 private static final String ICALENDAR_ATTENDEE_DECLINE = ICALENDAR_ATTENDEE + "DECLINED"; 131 private static final String ICALENDAR_ATTENDEE_TENTATIVE = ICALENDAR_ATTENDEE + "TENTATIVE"; 132 133 // Return a 4-byte long from a byte array (little endian) 134 static int getLong(byte[] bytes, int offset) { 135 return (bytes[offset++] & 0xFF) | ((bytes[offset++] & 0xFF) << 8) | 136 ((bytes[offset++] & 0xFF) << 16) | ((bytes[offset] & 0xFF) << 24); 137 } 138 139 // Put a 4-byte long into a byte array (little endian) 140 static void setLong(byte[] bytes, int offset, int value) { 141 bytes[offset++] = (byte) (value & 0xFF); 142 bytes[offset++] = (byte) ((value >> 8) & 0xFF); 143 bytes[offset++] = (byte) ((value >> 16) & 0xFF); 144 bytes[offset] = (byte) ((value >> 24) & 0xFF); 145 } 146 147 // Return a 2-byte word from a byte array (little endian) 148 static int getWord(byte[] bytes, int offset) { 149 return (bytes[offset++] & 0xFF) | ((bytes[offset] & 0xFF) << 8); 150 } 151 152 // Put a 2-byte word into a byte array (little endian) 153 static void setWord(byte[] bytes, int offset, int value) { 154 bytes[offset++] = (byte) (value & 0xFF); 155 bytes[offset] = (byte) ((value >> 8) & 0xFF); 156 } 157 158 // Internal structure for storing a time zone date from a SYSTEMTIME structure 159 // This date represents either the start or the end time for DST 160 static class TimeZoneDate { 161 String year; 162 int month; 163 int dayOfWeek; 164 int day; 165 int time; 166 int hour; 167 int minute; 168 } 169 170 static void putRuleIntoTimeZoneInformation(byte[] bytes, int offset, RRule rrule, int hour, 171 int minute) { 172 // MSFT months are 1 based, same as RRule 173 setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, rrule.month); 174 // MSFT day of week starts w/ Sunday = 0; RRule starts w/ Sunday = 1 175 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, rrule.dayOfWeek - 1); 176 // 5 means "last" in MSFT land; for RRule, it's -1 177 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, rrule.week < 0 ? 5 : rrule.week); 178 // Turn hours/minutes into ms from midnight (per TimeZone) 179 setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, hour); 180 setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, minute); 181 } 182 183 // Write SYSTEMTIME data into a byte array (this will either be for the standard or daylight 184 // transition) 185 static void putTimeInMillisIntoSystemTime(byte[] bytes, int offset, long millis) { 186 GregorianCalendar cal = new GregorianCalendar(TimeZone.getDefault()); 187 // Round to the next highest minute; we always write seconds as zero 188 cal.setTimeInMillis(millis + 30*SECONDS); 189 190 // MSFT months are 1 based; TimeZone is 0 based 191 setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, cal.get(Calendar.MONTH) + 1); 192 // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1 193 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, cal.get(Calendar.DAY_OF_WEEK) - 1); 194 195 // Get the "day" in TimeZone format 196 int wom = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH); 197 // 5 means "last" in MSFT land; for TimeZone, it's -1 198 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, wom < 0 ? 5 : wom); 199 200 // Turn hours/minutes into ms from midnight (per TimeZone) 201 setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, cal.get(Calendar.HOUR)); 202 setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, cal.get(Calendar.MINUTE)); 203 } 204 205 // Build a TimeZoneDate structure from a SYSTEMTIME within a byte array at a given offset 206 static TimeZoneDate getTimeZoneDateFromSystemTime(byte[] bytes, int offset) { 207 TimeZoneDate tzd = new TimeZoneDate(); 208 209 // MSFT year is an int; TimeZone is a String 210 int num = getWord(bytes, offset + MSFT_SYSTEMTIME_YEAR); 211 tzd.year = Integer.toString(num); 212 213 // MSFT month = 0 means no daylight time 214 // MSFT months are 1 based; TimeZone is 0 based 215 num = getWord(bytes, offset + MSFT_SYSTEMTIME_MONTH); 216 if (num == 0) { 217 return null; 218 } else { 219 tzd.month = num -1; 220 } 221 222 // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1 223 tzd.dayOfWeek = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK) + 1; 224 225 // Get the "day" in TimeZone format 226 num = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY); 227 // 5 means "last" in MSFT land; for TimeZone, it's -1 228 if (num == 5) { 229 tzd.day = -1; 230 } else { 231 tzd.day = num; 232 } 233 234 // Turn hours/minutes into ms from midnight (per TimeZone) 235 int hour = getWord(bytes, offset + MSFT_SYSTEMTIME_HOUR); 236 tzd.hour = hour; 237 int minute = getWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE); 238 tzd.minute = minute; 239 tzd.time = (hour*HOURS) + (minute*MINUTES); 240 241 return tzd; 242 } 243 244 /** 245 * Build a GregorianCalendar, based on a time zone and TimeZoneDate. 246 * @param timeZone the time zone we're checking 247 * @param tzd the TimeZoneDate we're interested in 248 * @return a GregorianCalendar with the given time zone and date 249 */ 250 static GregorianCalendar getCheckCalendar(TimeZone timeZone, TimeZoneDate tzd) { 251 GregorianCalendar testCalendar = new GregorianCalendar(timeZone); 252 testCalendar.set(GregorianCalendar.YEAR, sCurrentYear); 253 testCalendar.set(GregorianCalendar.MONTH, tzd.month); 254 testCalendar.set(GregorianCalendar.DAY_OF_WEEK, tzd.dayOfWeek); 255 testCalendar.set(GregorianCalendar.DAY_OF_WEEK_IN_MONTH, tzd.day); 256 testCalendar.set(GregorianCalendar.HOUR_OF_DAY, tzd.hour); 257 testCalendar.set(GregorianCalendar.MINUTE, tzd.minute); 258 return testCalendar; 259 } 260 261 /** 262 * Return a GregorianCalendar representing the first standard/daylight transition between a 263 * start time and an end time in the given time zone 264 * @param tz a TimeZone the time zone in which we're looking for transitions 265 * @param startTime the start time for the test 266 * @param endTime the end time for the test 267 * @param startInDaylightTime whether daylight time is in effect at the startTime 268 * @return a GregorianCalendar representing the transition or null if none 269 */ 270 static /*package*/ GregorianCalendar findTransitionDate(TimeZone tz, long startTime, long endTime, 271 boolean startInDaylightTime) { 272 long startingEndTime = endTime; 273 Date date = null; 274 // We'll keep splitting the difference until we're within a minute 275 while ((endTime - startTime) > MINUTES) { 276 long checkTime = ((startTime + endTime) / 2) + 1; 277 date = new Date(checkTime); 278 if (tz.inDaylightTime(date) != startInDaylightTime) { 279 endTime = checkTime; 280 } else { 281 startTime = checkTime; 282 } 283 } 284 285 // If these are the same, we're really messed up; return null 286 if (endTime == startingEndTime) { 287 return null; 288 } 289 290 // Set up our calendar 291 GregorianCalendar calendar = new GregorianCalendar(tz); 292 calendar.setTimeInMillis(startInDaylightTime ? startTime : endTime); 293 int min = calendar.get(Calendar.MINUTE); 294 if (min == 59) { 295 // If we're at XX:59:ZZ, round up to the next minute 296 calendar.add(Calendar.SECOND, 60 - calendar.get(Calendar.SECOND)); 297 } else if (min == 0) { 298 // If we're at XX:00:ZZ, round down to the minute 299 calendar.add(Calendar.SECOND, -calendar.get(Calendar.SECOND)); 300 } 301 return calendar; 302 } 303 304 /** 305 * Return a Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone 306 * that might be found in an Event; use cached result, if possible 307 * @param tz the TimeZone 308 * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element 309 */ 310 static public String timeZoneToTziString(TimeZone tz) { 311 String tziString = sTziStringCache.get(tz); 312 if (tziString != null) { 313 if (Eas.USER_LOG) { 314 Log.d(TAG, "TZI string for " + tz.getDisplayName() + " found in cache."); 315 } 316 return tziString; 317 } 318 tziString = timeZoneToTziStringImpl(tz); 319 sTziStringCache.put(tz, tziString); 320 return tziString; 321 } 322 323 /** 324 * A class for storing RRULE information. The RRULE members can be accessed individually or 325 * an RRULE string can be created with toString() 326 */ 327 static class RRule { 328 static final int RRULE_NONE = 0; 329 static final int RRULE_DAY_WEEK = 1; 330 static final int RRULE_DATE = 2; 331 332 int type; 333 int dayOfWeek; 334 int week; 335 int month; 336 int date; 337 338 /** 339 * Create an RRULE based on month and date 340 * @param _month the month (1 = JAN, 12 = DEC) 341 * @param _date the date in the month (1-31) 342 */ 343 RRule(int _month, int _date) { 344 type = RRULE_DATE; 345 month = _month; 346 date = _date; 347 } 348 349 /** 350 * Create an RRULE based on month, day of week, and week # 351 * @param _month the month (1 = JAN, 12 = DEC) 352 * @param _dayOfWeek the day of the week (1 = SU, 7 = SA) 353 * @param _week the week in the month (1-5 or -1 for last) 354 */ 355 RRule(int _month, int _dayOfWeek, int _week) { 356 type = RRULE_DAY_WEEK; 357 month = _month; 358 dayOfWeek = _dayOfWeek; 359 week = _week; 360 } 361 362 @Override 363 public String toString() { 364 if (type == RRULE_DAY_WEEK) { 365 return "FREQ=YEARLY;BYMONTH=" + month + ";BYDAY=" + week + 366 sDayTokens[dayOfWeek - 1]; 367 } else { 368 return "FREQ=YEARLY;BYMONTH=" + month + ";BYMONTHDAY=" + date; 369 } 370 } 371 } 372 373 /** 374 * Generate an RRULE string for an array of GregorianCalendars, if possible. For now, we are 375 * only looking for rules based on the same date in a month or a specific instance of a day of 376 * the week in a month (e.g. 2nd Tuesday or last Friday). Indeed, these are the only kinds of 377 * rules used in the current tzinfo database. 378 * @param calendars an array of GregorianCalendar, set to a series of transition times in 379 * consecutive years starting with the current year 380 * @return an RRULE or null if none could be inferred from the calendars 381 */ 382 static private RRule inferRRuleFromCalendars(GregorianCalendar[] calendars) { 383 // Let's see if we can make a rule about these 384 GregorianCalendar calendar = calendars[0]; 385 if (calendar == null) return null; 386 int month = calendar.get(Calendar.MONTH); 387 int date = calendar.get(Calendar.DAY_OF_MONTH); 388 int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); 389 int week = calendar.get(Calendar.DAY_OF_WEEK_IN_MONTH); 390 int maxWeek = calendar.getActualMaximum(Calendar.DAY_OF_WEEK_IN_MONTH); 391 boolean dateRule = false; 392 boolean dayOfWeekRule = false; 393 for (int i = 1; i < calendars.length; i++) { 394 GregorianCalendar cal = calendars[i]; 395 if (cal == null) return null; 396 // If it's not the same month, there's no rule 397 if (cal.get(Calendar.MONTH) != month) { 398 return null; 399 } else if (dayOfWeek == cal.get(Calendar.DAY_OF_WEEK)) { 400 // Ok, it seems to be the same day of the week 401 if (dateRule) { 402 return null; 403 } 404 dayOfWeekRule = true; 405 int thisWeek = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH); 406 if (week != thisWeek) { 407 if (week < 0 || week == maxWeek) { 408 int thisMaxWeek = cal.getActualMaximum(Calendar.DAY_OF_WEEK_IN_MONTH); 409 if (thisWeek == thisMaxWeek) { 410 // We'll use -1 (i.e. last) week 411 week = -1; 412 continue; 413 } 414 } 415 return null; 416 } 417 } else if (date == cal.get(Calendar.DAY_OF_MONTH)) { 418 // Maybe the same day of the month? 419 if (dayOfWeekRule) { 420 return null; 421 } 422 dateRule = true; 423 } else { 424 return null; 425 } 426 } 427 428 if (dateRule) { 429 return new RRule(month + 1, date); 430 } 431 // sDayTokens is 0 based (SU = 0); Calendar days of week are 1 based (SU = 1) 432 // iCalendar months are 1 based; Calendar months are 0 based 433 // So we adjust these when building the string 434 return new RRule(month + 1, dayOfWeek, week); 435 } 436 437 /** 438 * Generate an rfc2445 utcOffset from minutes offset from GMT 439 * These look like +0800 or -0100 440 * @param offsetMinutes minutes offset from GMT (east is positive, west is negative 441 * @return a utcOffset 442 */ 443 static /*package*/ String utcOffsetString(int offsetMinutes) { 444 StringBuilder sb = new StringBuilder(); 445 int hours = offsetMinutes / 60; 446 if (hours < 0) { 447 sb.append('-'); 448 hours = 0 - hours; 449 } else { 450 sb.append('+'); 451 } 452 int minutes = offsetMinutes % 60; 453 if (hours < 10) { 454 sb.append('0'); 455 } 456 sb.append(hours); 457 if (minutes < 10) { 458 sb.append('0'); 459 } 460 sb.append(minutes); 461 return sb.toString(); 462 } 463 464 /** 465 * Fill the passed in GregorianCalendars arrays with DST transition information for this and 466 * the following years (based on the length of the arrays) 467 * @param tz the time zone 468 * @param toDaylightCalendars an array of GregorianCalendars, one for each year, representing 469 * the transition to daylight time 470 * @param toStandardCalendars an array of GregorianCalendars, one for each year, representing 471 * the transition to standard time 472 * @return true if transitions could be found for all years, false otherwise 473 */ 474 static boolean getDSTCalendars(TimeZone tz, GregorianCalendar[] toDaylightCalendars, 475 GregorianCalendar[] toStandardCalendars) { 476 // We'll use the length of the arrays to determine how many years to check 477 int maxYears = toDaylightCalendars.length; 478 if (toStandardCalendars.length != maxYears) { 479 return false; 480 } 481 // Get the transitions for this year and the next few years 482 for (int i = 0; i < maxYears; i++) { 483 GregorianCalendar cal = new GregorianCalendar(tz); 484 cal.set(sCurrentYear + i, Calendar.JANUARY, 1, 0, 0, 0); 485 long startTime = cal.getTimeInMillis(); 486 // Calculate end of year; no need to be insanely precise 487 long endOfYearTime = startTime + (365*DAYS) + (DAYS>>2); 488 Date date = new Date(startTime); 489 boolean startInDaylightTime = tz.inDaylightTime(date); 490 // Find the first transition, and store 491 cal = findTransitionDate(tz, startTime, endOfYearTime, startInDaylightTime); 492 if (cal == null) { 493 return false; 494 } else if (startInDaylightTime) { 495 toStandardCalendars[i] = cal; 496 } else { 497 toDaylightCalendars[i] = cal; 498 } 499 // Find the second transition, and store 500 cal = findTransitionDate(tz, startTime, endOfYearTime, !startInDaylightTime); 501 if (cal == null) { 502 return false; 503 } else if (startInDaylightTime) { 504 toDaylightCalendars[i] = cal; 505 } else { 506 toStandardCalendars[i] = cal; 507 } 508 } 509 return true; 510 } 511 512 /** 513 * Write out the STANDARD block of VTIMEZONE and end the VTIMEZONE 514 * @param writer the SimpleIcsWriter we're using 515 * @param tz the time zone 516 * @param offsetString the offset string in VTIMEZONE format (e.g. +0800) 517 * @throws IOException 518 */ 519 static private void writeNoDST(SimpleIcsWriter writer, TimeZone tz, String offsetString) 520 throws IOException { 521 writer.writeTag("BEGIN", "STANDARD"); 522 writer.writeTag("TZOFFSETFROM", offsetString); 523 writer.writeTag("TZOFFSETTO", offsetString); 524 // Might as well use start of epoch for start date 525 writer.writeTag("DTSTART", millisToEasDateTime(0L)); 526 writer.writeTag("END", "STANDARD"); 527 writer.writeTag("END", "VTIMEZONE"); 528 } 529 530 /** Write a VTIMEZONE block for a given TimeZone into a SimpleIcsWriter 531 * @param tz the TimeZone to be used in the conversion 532 * @param writer the SimpleIcsWriter to be used 533 * @throws IOException 534 */ 535 static public void timeZoneToVTimezone(TimeZone tz, SimpleIcsWriter writer) 536 throws IOException { 537 // We'll use these regardless of whether there's DST in this time zone or not 538 int rawOffsetMinutes = tz.getRawOffset() / MINUTES; 539 String standardOffsetString = utcOffsetString(rawOffsetMinutes); 540 541 // Preamble for all of our VTIMEZONEs 542 writer.writeTag("BEGIN", "VTIMEZONE"); 543 writer.writeTag("TZID", tz.getID()); 544 writer.writeTag("X-LIC-LOCATION", tz.getDisplayName()); 545 546 // Simplest case is no daylight time 547 if (!tz.useDaylightTime()) { 548 writeNoDST(writer, tz, standardOffsetString); 549 return; 550 } 551 552 int maxYears = 3; 553 GregorianCalendar[] toDaylightCalendars = new GregorianCalendar[maxYears]; 554 GregorianCalendar[] toStandardCalendars = new GregorianCalendar[maxYears]; 555 if (!getDSTCalendars(tz, toDaylightCalendars, toStandardCalendars)) { 556 writeNoDST(writer, tz, standardOffsetString); 557 return; 558 } 559 // Try to find a rule to cover these yeras 560 RRule daylightRule = inferRRuleFromCalendars(toDaylightCalendars); 561 RRule standardRule = inferRRuleFromCalendars(toStandardCalendars); 562 String daylightOffsetString = 563 utcOffsetString(rawOffsetMinutes + (tz.getDSTSavings() / MINUTES)); 564 // We'll use RRULE's if we found both 565 // Otherwise we write the first as DTSTART and the others as RDATE 566 boolean hasRule = daylightRule != null && standardRule != null; 567 568 // Write the DAYLIGHT block 569 writer.writeTag("BEGIN", "DAYLIGHT"); 570 writer.writeTag("TZOFFSETFROM", standardOffsetString); 571 writer.writeTag("TZOFFSETTO", daylightOffsetString); 572 writer.writeTag("DTSTART", 573 millisToVCalendarTime(toDaylightCalendars[0].getTimeInMillis(), tz, true)); 574 if (hasRule) { 575 writer.writeTag("RRULE", daylightRule.toString()); 576 } else { 577 for (int i = 1; i < maxYears; i++) { 578 writer.writeTag("RDATE", millisToVCalendarTime( 579 toDaylightCalendars[i].getTimeInMillis(), tz, true)); 580 } 581 } 582 writer.writeTag("END", "DAYLIGHT"); 583 // Write the STANDARD block 584 writer.writeTag("BEGIN", "STANDARD"); 585 writer.writeTag("TZOFFSETFROM", daylightOffsetString); 586 writer.writeTag("TZOFFSETTO", standardOffsetString); 587 writer.writeTag("DTSTART", 588 millisToVCalendarTime(toStandardCalendars[0].getTimeInMillis(), tz, false)); 589 if (hasRule) { 590 writer.writeTag("RRULE", standardRule.toString()); 591 } else { 592 for (int i = 1; i < maxYears; i++) { 593 writer.writeTag("RDATE", millisToVCalendarTime( 594 toStandardCalendars[i].getTimeInMillis(), tz, true)); 595 } 596 } 597 writer.writeTag("END", "STANDARD"); 598 // And we're done 599 writer.writeTag("END", "VTIMEZONE"); 600 } 601 602 /** 603 * Find the next transition to occur (i.e. after the current date/time) 604 * @param transitions calendars representing transitions to/from DST 605 * @return millis for the first transition after the current date/time 606 */ 607 static private long findNextTransition(long startingMillis, GregorianCalendar[] transitions) { 608 for (GregorianCalendar transition: transitions) { 609 long transitionMillis = transition.getTimeInMillis(); 610 if (transitionMillis > startingMillis) { 611 return transitionMillis; 612 } 613 } 614 return 0; 615 } 616 617 /** 618 * Calculate the Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone 619 * that might be found in an Event. Since the internal representation of the TimeZone is hidden 620 * from us we'll find the DST transitions and build the structure from that information 621 * @param tz the TimeZone 622 * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element 623 */ 624 static public String timeZoneToTziStringImpl(TimeZone tz) { 625 String tziString; 626 byte[] tziBytes = new byte[MSFT_TIME_ZONE_SIZE]; 627 int standardBias = - tz.getRawOffset(); 628 standardBias /= 60*SECONDS; 629 setLong(tziBytes, MSFT_TIME_ZONE_BIAS_OFFSET, standardBias); 630 // If this time zone has daylight savings time, we need to do more work 631 if (tz.useDaylightTime()) { 632 GregorianCalendar[] toDaylightCalendars = new GregorianCalendar[3]; 633 GregorianCalendar[] toStandardCalendars = new GregorianCalendar[3]; 634 // See if we can get transitions for a few years; if not, we can't generate DST info 635 // for this time zone 636 if (getDSTCalendars(tz, toDaylightCalendars, toStandardCalendars)) { 637 // Try to find a rule to cover these years 638 RRule daylightRule = inferRRuleFromCalendars(toDaylightCalendars); 639 RRule standardRule = inferRRuleFromCalendars(toStandardCalendars); 640 if ((daylightRule != null) && (daylightRule.type == RRule.RRULE_DAY_WEEK) && 641 (standardRule != null) && (standardRule.type == RRule.RRULE_DAY_WEEK)) { 642 // We need both rules and they have to be DAY/WEEK type 643 // Write month, day of week, week, hour, minute 644 putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET, 645 standardRule, 646 toStandardCalendars[0].get(Calendar.HOUR), 647 toStandardCalendars[0].get(Calendar.MINUTE)); 648 putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET, 649 daylightRule, 650 toDaylightCalendars[0].get(Calendar.HOUR), 651 toDaylightCalendars[0].get(Calendar.MINUTE)); 652 } else { 653 // If there's no rule, we'll use the first transition to standard/to daylight 654 // And indicate that it's just for this year... 655 long now = System.currentTimeMillis(); 656 long standardTransition = findNextTransition(now, toStandardCalendars); 657 long daylightTransition = findNextTransition(now, toDaylightCalendars); 658 // If we can't find transitions, we can't do DST 659 if (standardTransition != 0 && daylightTransition != 0) { 660 putTimeInMillisIntoSystemTime(tziBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET, 661 standardTransition); 662 putTimeInMillisIntoSystemTime(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET, 663 daylightTransition); 664 } 665 } 666 } 667 int dstOffset = tz.getDSTSavings(); 668 setLong(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET, - dstOffset / MINUTES); 669 } 670 byte[] tziEncodedBytes = Base64.encode(tziBytes, Base64.NO_WRAP); 671 tziString = new String(tziEncodedBytes); 672 return tziString; 673 } 674 675 /** 676 * Given a String as directly read from EAS, returns a TimeZone corresponding to that String 677 * @param timeZoneString the String read from the server 678 * @return the TimeZone, or TimeZone.getDefault() if not found 679 */ 680 static public TimeZone tziStringToTimeZone(String timeZoneString) { 681 // If we have this time zone cached, use that value and return 682 TimeZone timeZone = sTimeZoneCache.get(timeZoneString); 683 if (timeZone != null) { 684 if (Eas.USER_LOG) { 685 Log.d(TAG, " Using cached TimeZone " + timeZone.getDisplayName()); 686 } 687 } else { 688 timeZone = tziStringToTimeZoneImpl(timeZoneString); 689 if (timeZone == null) { 690 // If we don't find a match, we just return the current TimeZone. In theory, this 691 // shouldn't be happening... 692 Log.w(TAG, "TimeZone not found using default: " + timeZoneString); 693 timeZone = TimeZone.getDefault(); 694 } 695 sTimeZoneCache.put(timeZoneString, timeZone); 696 } 697 return timeZone; 698 } 699 700 /** 701 * Given a String as directly read from EAS, tries to find a TimeZone in the database of all 702 * time zones that corresponds to that String. 703 * @param timeZoneString the String read from the server 704 * @return the TimeZone, or TimeZone.getDefault() if not found 705 */ 706 static public TimeZone tziStringToTimeZoneImpl(String timeZoneString) { 707 TimeZone timeZone = null; 708 // First, we need to decode the base64 string 709 byte[] timeZoneBytes = Base64.decode(timeZoneString, Base64.DEFAULT); 710 711 // Then, we get the bias (similar to a rawOffset); for TimeZone, we need ms 712 // but EAS gives us minutes, so do the conversion. Note that EAS is the bias that's added 713 // to the time zone to reach UTC; our library uses the time from UTC to our time zone, so 714 // we need to change the sign 715 int bias = -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_BIAS_OFFSET) * MINUTES; 716 717 // Get all of the time zones with the bias as a rawOffset; if there aren't any, we return 718 // the default time zone 719 String[] zoneIds = TimeZone.getAvailableIDs(bias); 720 if (zoneIds.length > 0) { 721 // Try to find an existing TimeZone from the data provided by EAS 722 // We start by pulling out the date that standard time begins 723 TimeZoneDate dstEnd = 724 getTimeZoneDateFromSystemTime(timeZoneBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET); 725 if (dstEnd == null) { 726 // In this case, there is no daylight savings time, so the only interesting data 727 // is the offset, and we know that all of the zoneId's match; we'll take the first 728 timeZone = TimeZone.getTimeZone(zoneIds[0]); 729 String dn = timeZone.getDisplayName(); 730 sTimeZoneCache.put(timeZoneString, timeZone); 731 if (Eas.USER_LOG) { 732 Log.d(TAG, "TimeZone without DST found by offset: " + dn); 733 } 734 } else { 735 TimeZoneDate dstStart = getTimeZoneDateFromSystemTime(timeZoneBytes, 736 MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET); 737 // See comment above for bias... 738 long dstSavings = 739 -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET) * MINUTES; 740 741 // We'll go through each time zone to find one with the same DST transitions and 742 // savings length 743 for (String zoneId: zoneIds) { 744 // Get the TimeZone using the zoneId 745 timeZone = TimeZone.getTimeZone(zoneId); 746 747 // Our strategy here is to check just before and just after the transitions 748 // and see whether the check for daylight time matches the expectation 749 // If both transitions match, then we have a match for the offset and start/end 750 // of dst. That's the best we can do for now, since there's no other info 751 // provided by EAS (i.e. we can't get dynamic transitions, etc.) 752 753 int testSavingsMinutes = timeZone.getDSTSavings() / MINUTES; 754 int errorBoundsMinutes = (testSavingsMinutes * 2) + 1; 755 756 // Check start DST transition 757 GregorianCalendar testCalendar = getCheckCalendar(timeZone, dstStart); 758 testCalendar.add(GregorianCalendar.MINUTE, - errorBoundsMinutes); 759 Date before = testCalendar.getTime(); 760 testCalendar.add(GregorianCalendar.MINUTE, 2*errorBoundsMinutes); 761 Date after = testCalendar.getTime(); 762 if (timeZone.inDaylightTime(before)) continue; 763 if (!timeZone.inDaylightTime(after)) continue; 764 765 // Check end DST transition 766 testCalendar = getCheckCalendar(timeZone, dstEnd); 767 testCalendar.add(GregorianCalendar.MINUTE, - errorBoundsMinutes); 768 before = testCalendar.getTime(); 769 testCalendar.add(GregorianCalendar.MINUTE, 2*errorBoundsMinutes); 770 after = testCalendar.getTime(); 771 if (!timeZone.inDaylightTime(before)) continue; 772 if (timeZone.inDaylightTime(after)) continue; 773 774 // Check that the savings are the same 775 if (dstSavings != timeZone.getDSTSavings()) continue; 776 break; 777 } 778 } 779 } 780 return timeZone; 781 } 782 783 /** 784 * Generate a time in milliseconds from an email date string that represents a date/time in GMT 785 * @param Email style DateTime string from Exchange server 786 * @return the time in milliseconds (since Jan 1, 1970) 787 */ 788 static public long parseEmailDateTimeToMillis(String date) { 789 // Format for email date strings is 2010-02-23T16:00:00.000Z 790 GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)), 791 Integer.parseInt(date.substring(5, 7)) - 1, Integer.parseInt(date.substring(8, 10)), 792 Integer.parseInt(date.substring(11, 13)), Integer.parseInt(date.substring(14, 16)), 793 Integer.parseInt(date.substring(17, 19))); 794 cal.setTimeZone(TimeZone.getTimeZone("GMT")); 795 return cal.getTimeInMillis(); 796 } 797 798 /** 799 * Generate a time in milliseconds from a date string that represents a date/time in GMT 800 * @param DateTime string from Exchange server 801 * @return the time in milliseconds (since Jan 1, 1970) 802 */ 803 static public long parseDateTimeToMillis(String date) { 804 // Format for calendar date strings is 20090211T180303Z 805 GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)), 806 Integer.parseInt(date.substring(4, 6)) - 1, Integer.parseInt(date.substring(6, 8)), 807 Integer.parseInt(date.substring(9, 11)), Integer.parseInt(date.substring(11, 13)), 808 Integer.parseInt(date.substring(13, 15))); 809 cal.setTimeZone(TimeZone.getTimeZone("GMT")); 810 return cal.getTimeInMillis(); 811 } 812 813 /** 814 * Generate a GregorianCalendar from a date string that represents a date/time in GMT 815 * @param DateTime string from Exchange server 816 * @return the GregorianCalendar 817 */ 818 static public GregorianCalendar parseDateTimeToCalendar(String date) { 819 // Format for calendar date strings is 20090211T180303Z 820 GregorianCalendar cal = new GregorianCalendar(Integer.parseInt(date.substring(0, 4)), 821 Integer.parseInt(date.substring(4, 6)) - 1, Integer.parseInt(date.substring(6, 8)), 822 Integer.parseInt(date.substring(9, 11)), Integer.parseInt(date.substring(11, 13)), 823 Integer.parseInt(date.substring(13, 15))); 824 cal.setTimeZone(TimeZone.getTimeZone("GMT")); 825 return cal; 826 } 827 828 static public String convertEmailDateTimeToCalendarDateTime(String date) { 829 // Format for email date strings is 2010-02-23T16:00:00.000Z 830 // Format for calendar date strings is 2010-02-23T160000Z 831 return date.substring(0, 4) + date.substring(5, 7) + date.substring(8, 13) + 832 date.substring(14, 16) + date.substring(17, 19) + 'Z'; 833 } 834 835 static String formatTwo(int num) { 836 if (num <= 12) { 837 return sTwoCharacterNumbers[num]; 838 } else 839 return Integer.toString(num); 840 } 841 842 /** 843 * Generate an EAS formatted date/time string based on GMT. See below for details. 844 */ 845 static public String millisToEasDateTime(long millis) { 846 return millisToEasDateTime(millis, sGmtTimeZone); 847 } 848 849 /** 850 * Generate an EAS formatted local date/time string from a time and a time zone 851 * @param millis a time in milliseconds 852 * @param tz a time zone 853 * @return an EAS formatted string indicating the date/time in the given time zone 854 */ 855 static public String millisToEasDateTime(long millis, TimeZone tz) { 856 StringBuilder sb = new StringBuilder(); 857 GregorianCalendar cal = new GregorianCalendar(tz); 858 cal.setTimeInMillis(millis); 859 sb.append(cal.get(Calendar.YEAR)); 860 sb.append(formatTwo(cal.get(Calendar.MONTH) + 1)); 861 sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH))); 862 sb.append('T'); 863 sb.append(formatTwo(cal.get(Calendar.HOUR_OF_DAY))); 864 sb.append(formatTwo(cal.get(Calendar.MINUTE))); 865 sb.append(formatTwo(cal.get(Calendar.SECOND))); 866 if (tz == sGmtTimeZone) { 867 sb.append('Z'); 868 } 869 return sb.toString(); 870 } 871 872 /** 873 * Generate a date/time string suitable for VTIMEZONE, the format is YYYYMMDDTHHMMSS 874 * @param millis a time in milliseconds 875 * @param tz a time zone 876 * @param dst whether we're entering daylight time 877 */ 878 static public String millisToVCalendarTime(long millis, TimeZone tz, boolean dst) { 879 StringBuilder sb = new StringBuilder(); 880 GregorianCalendar cal = new GregorianCalendar(tz); 881 cal.setTimeInMillis(millis); 882 sb.append(cal.get(Calendar.YEAR)); 883 sb.append(formatTwo(cal.get(Calendar.MONTH) + 1)); 884 sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH))); 885 sb.append('T'); 886 // If we're entering daylight time, go back an hour to compensate for the singularity 887 if (dst && (tz.getDSTSavings() == HOURS)) { 888 int hour = cal.get(Calendar.HOUR_OF_DAY); 889 if (hour > 0) { 890 hour--; 891 } 892 sb.append(formatTwo(hour)); 893 } else { 894 sb.append(formatTwo(cal.get(Calendar.HOUR_OF_DAY))); 895 } 896 sb.append(formatTwo(cal.get(Calendar.MINUTE))); 897 sb.append(formatTwo(cal.get(Calendar.SECOND))); 898 return sb.toString(); 899 } 900 901 static void addByDay(StringBuilder rrule, int dow, int wom) { 902 rrule.append(";BYDAY="); 903 boolean addComma = false; 904 for (int i = 0; i < 7; i++) { 905 if ((dow & 1) == 1) { 906 if (addComma) { 907 rrule.append(','); 908 } 909 if (wom > 0) { 910 // 5 = last week -> -1 911 // So -1SU = last sunday 912 rrule.append(wom == 5 ? -1 : wom); 913 } 914 rrule.append(sDayTokens[i]); 915 addComma = true; 916 } 917 dow >>= 1; 918 } 919 } 920 921 static void addByMonthDay(StringBuilder rrule, int dom) { 922 // 127 means last day of the month 923 if (dom == 127) { 924 dom = -1; 925 } 926 rrule.append(";BYMONTHDAY=" + dom); 927 } 928 929 /** 930 * Generate the String version of the EAS integer for a given BYDAY value in an rrule 931 * @param dow the BYDAY value of the rrule 932 * @return the String version of the EAS value of these days 933 */ 934 static String generateEasDayOfWeek(String dow) { 935 int bits = 0; 936 int bit = 1; 937 for (String token: sDayTokens) { 938 // If we can find the day in the dow String, add the bit to our bits value 939 if (dow.indexOf(token) >= 0) { 940 bits |= bit; 941 } 942 bit <<= 1; 943 } 944 return Integer.toString(bits); 945 } 946 947 /** 948 * Extract the value of a token in an RRULE string 949 * @param rrule an RRULE string 950 * @param token a token to look for in the RRULE 951 * @return the value of that token 952 */ 953 static String tokenFromRrule(String rrule, String token) { 954 int start = rrule.indexOf(token); 955 if (start < 0) return null; 956 int len = rrule.length(); 957 start += token.length(); 958 int end = start; 959 char c; 960 do { 961 c = rrule.charAt(end++); 962 if (!Character.isLetterOrDigit(c) || (end == len)) { 963 if (end == len) end++; 964 return rrule.substring(start, end -1); 965 } 966 } while (true); 967 } 968 969 /** 970 * Reformat an RRULE style UNTIL to an EAS style until 971 */ 972 static String recurrenceUntilToEasUntil(String until) { 973 StringBuilder sb = new StringBuilder(); 974 sb.append(until.substring(0, 4)); 975 sb.append('-'); 976 sb.append(until.substring(4, 6)); 977 sb.append('-'); 978 if (until.length() == 8) { 979 sb.append(until.substring(6, 8)); 980 sb.append("T00:00:00"); 981 982 } else { 983 sb.append(until.substring(6, 11)); 984 sb.append(':'); 985 sb.append(until.substring(11, 13)); 986 sb.append(':'); 987 sb.append(until.substring(13, 15)); 988 } 989 sb.append(".000Z"); 990 return sb.toString(); 991 } 992 993 /** 994 * Convenience method to add "until" to an EAS calendar stream 995 */ 996 static void addUntil(String rrule, Serializer s) throws IOException { 997 String until = tokenFromRrule(rrule, "UNTIL="); 998 if (until != null) { 999 s.data(Tags.CALENDAR_RECURRENCE_UNTIL, recurrenceUntilToEasUntil(until)); 1000 } 1001 } 1002 1003 /** 1004 * Write recurrence information to EAS based on the RRULE in CalendarProvider 1005 * @param rrule the RRULE, from CalendarProvider 1006 * @param startTime, the DTSTART of this Event 1007 * @param s the Serializer we're using to write WBXML data 1008 * @throws IOException 1009 */ 1010 // NOTE: For the moment, we're only parsing recurrence types that are supported by the 1011 // Calendar app UI, which is a subset of possible recurrence types 1012 // This code must be updated when the Calendar adds new functionality 1013 static public void recurrenceFromRrule(String rrule, long startTime, Serializer s) 1014 throws IOException { 1015 Log.d("RRULE", "rule: " + rrule); 1016 String freq = tokenFromRrule(rrule, "FREQ="); 1017 // If there's no FREQ=X, then we don't write a recurrence 1018 // Note that we duplicate s.start(Tags.CALENDAR_RECURRENCE); s.end(); to prevent the 1019 // possibility of writing out a partial recurrence stanza 1020 if (freq != null) { 1021 if (freq.equals("DAILY")) { 1022 s.start(Tags.CALENDAR_RECURRENCE); 1023 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "0"); 1024 s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, "1"); 1025 s.end(); 1026 } else if (freq.equals("WEEKLY")) { 1027 s.start(Tags.CALENDAR_RECURRENCE); 1028 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "1"); 1029 s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, "1"); 1030 // Requires a day of week (whereas RRULE does not) 1031 String byDay = tokenFromRrule(rrule, "BYDAY="); 1032 if (byDay != null) { 1033 s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(byDay)); 1034 } 1035 addUntil(rrule, s); 1036 s.end(); 1037 } else if (freq.equals("MONTHLY")) { 1038 String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY="); 1039 if (byMonthDay != null) { 1040 // The nth day of the month 1041 s.start(Tags.CALENDAR_RECURRENCE); 1042 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "2"); 1043 s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay); 1044 addUntil(rrule, s); 1045 s.end(); 1046 } else { 1047 String byDay = tokenFromRrule(rrule, "BYDAY="); 1048 String bareByDay; 1049 if (byDay != null) { 1050 // This can be 1WE (1st Wednesday) or -1FR (last Friday) 1051 int wom = byDay.charAt(0); 1052 if (wom == '-') { 1053 // -1 is the only legal case (last week) Use "5" for EAS 1054 wom = 5; 1055 bareByDay = byDay.substring(2); 1056 } else { 1057 wom = wom - '0'; 1058 bareByDay = byDay.substring(1); 1059 } 1060 s.start(Tags.CALENDAR_RECURRENCE); 1061 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "3"); 1062 s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, Integer.toString(wom)); 1063 s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(bareByDay)); 1064 addUntil(rrule, s); 1065 s.end(); 1066 } 1067 } 1068 } else if (freq.equals("YEARLY")) { 1069 String byMonth = tokenFromRrule(rrule, "BYMONTH="); 1070 String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY="); 1071 if (byMonth == null || byMonthDay == null) { 1072 // Calculate the month and day from the startDate 1073 GregorianCalendar cal = new GregorianCalendar(); 1074 cal.setTimeInMillis(startTime); 1075 cal.setTimeZone(TimeZone.getDefault()); 1076 byMonth = Integer.toString(cal.get(Calendar.MONTH) + 1); 1077 byMonthDay = Integer.toString(cal.get(Calendar.DAY_OF_MONTH)); 1078 } 1079 s.start(Tags.CALENDAR_RECURRENCE); 1080 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "5"); 1081 s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay); 1082 s.data(Tags.CALENDAR_RECURRENCE_MONTHOFYEAR, byMonth); 1083 addUntil(rrule, s); 1084 s.end(); 1085 } 1086 } 1087 } 1088 1089 /** 1090 * Build an RRULE String from EAS recurrence information 1091 * @param type the type of recurrence 1092 * @param occurrences how many recurrences (instances) 1093 * @param interval the interval between recurrences 1094 * @param dow day of the week 1095 * @param dom day of the month 1096 * @param wom week of the month 1097 * @param moy month of the year 1098 * @param until the last recurrence time 1099 * @return a valid RRULE String 1100 */ 1101 static public String rruleFromRecurrence(int type, int occurrences, int interval, int dow, 1102 int dom, int wom, int moy, String until) { 1103 StringBuilder rrule = new StringBuilder("FREQ=" + sTypeToFreq[type]); 1104 1105 // INTERVAL and COUNT 1106 if (interval > 0) { 1107 rrule.append(";INTERVAL=" + interval); 1108 } 1109 if (occurrences > 0) { 1110 rrule.append(";COUNT=" + occurrences); 1111 } 1112 1113 // Days, weeks, months, etc. 1114 switch(type) { 1115 case 0: // DAILY 1116 case 1: // WEEKLY 1117 if (dow > 0) addByDay(rrule, dow, -1); 1118 break; 1119 case 2: // MONTHLY 1120 if (dom > 0) addByMonthDay(rrule, dom); 1121 break; 1122 case 3: // MONTHLY (on the nth day) 1123 if (dow > 0) addByDay(rrule, dow, wom); 1124 break; 1125 case 5: // YEARLY (specific day) 1126 if (dom > 0) addByMonthDay(rrule, dom); 1127 if (moy > 0) { 1128 rrule.append(";BYMONTH=" + moy); 1129 } 1130 break; 1131 case 6: // YEARLY 1132 if (dow > 0) addByDay(rrule, dow, wom); 1133 if (dom > 0) addByMonthDay(rrule, dom); 1134 if (moy > 0) { 1135 rrule.append(";BYMONTH=" + moy); 1136 } 1137 break; 1138 default: 1139 break; 1140 } 1141 1142 // UNTIL comes last 1143 if (until != null) { 1144 rrule.append(";UNTIL=" + until); 1145 } 1146 1147 return rrule.toString(); 1148 } 1149 1150 /** 1151 * Create a Calendar in CalendarProvider to which synced Events will be linked 1152 * @param service the sync service requesting Calendar creation 1153 * @param account the account being synced 1154 * @param mailbox the Exchange mailbox for the calendar 1155 * @return the unique id of the Calendar 1156 */ 1157 static public long createCalendar(EasSyncService service, Account account, Mailbox mailbox) { 1158 // Create a Calendar object 1159 ContentValues cv = new ContentValues(); 1160 // TODO How will this change if the user changes his account display name? 1161 cv.put(Calendars.DISPLAY_NAME, account.mDisplayName); 1162 cv.put(Calendars._SYNC_ACCOUNT, account.mEmailAddress); 1163 cv.put(Calendars._SYNC_ACCOUNT_TYPE, Email.EXCHANGE_ACCOUNT_MANAGER_TYPE); 1164 cv.put(Calendars.SYNC_EVENTS, 1); 1165 cv.put(Calendars.SELECTED, 1); 1166 cv.put(Calendars.HIDDEN, 0); 1167 // TODO Coordinate account colors w/ Calendar, if possible 1168 // Make Email account color opaque 1169 cv.put(Calendars.COLOR, 0xFF000000 | Email.getAccountColor(account.mId)); 1170 cv.put(Calendars.TIMEZONE, Time.getCurrentTimezone()); 1171 cv.put(Calendars.ACCESS_LEVEL, Calendars.OWNER_ACCESS); 1172 cv.put(Calendars.OWNER_ACCOUNT, account.mEmailAddress); 1173 1174 Uri uri = service.mContentResolver.insert(Calendars.CONTENT_URI, cv); 1175 // We save the id of the calendar into mSyncStatus 1176 if (uri != null) { 1177 String stringId = uri.getPathSegments().get(1); 1178 mailbox.mSyncStatus = stringId; 1179 return Long.parseLong(stringId); 1180 } 1181 return -1; 1182 } 1183 1184 /** 1185 * Create a Message for an (Event) Entity 1186 * @param entity the Entity for the Event (as might be retrieved by CalendarProvider) 1187 * @param messageFlag the Message.FLAG_XXX constant indicating the type of email to be sent 1188 * @param the unique id of this Event, or null if it can be retrieved from the Event 1189 * @param the user's account 1190 * @return a Message with many fields pre-filled (more later) 1191 */ 1192 static public EmailContent.Message createMessageForEntity(Context context, Entity entity, 1193 int messageFlag, String uid, Account account) { 1194 // TODO Handle exceptions; will be a nightmare 1195 // TODO Cries out for unit test 1196 ContentValues entityValues = entity.getEntityValues(); 1197 ArrayList<NamedContentValues> subValues = entity.getSubValues(); 1198 1199 EmailContent.Message msg = new EmailContent.Message(); 1200 msg.mFlags = messageFlag; 1201 msg.mTimeStamp = System.currentTimeMillis(); 1202 1203 String method; 1204 if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_REQUEST_MASK) != 0) { 1205 method = "REQUEST"; 1206 } else { 1207 method = "REPLY"; 1208 } 1209 1210 try { 1211 // Create our iCalendar writer and start generating tags 1212 SimpleIcsWriter ics = new SimpleIcsWriter(); 1213 ics.writeTag("BEGIN", "VCALENDAR"); 1214 ics.writeTag("METHOD", method); 1215 ics.writeTag("PRODID", "AndroidEmail"); 1216 ics.writeTag("VERSION", "2.0"); 1217 1218 // Our default vcalendar time zone is UTC, but this will change (below) if we're 1219 // sending a recurring event, in which case we use local time 1220 TimeZone vCalendarTimeZone = sGmtTimeZone; 1221 String vCalendarTimeZoneSuffix = ""; 1222 1223 // If we're inviting people and the meeting is recurring, we need to send our time zone 1224 // information and make sure to send DTSTART/DTEND in local time 1225 if (method.equals("REQUEST") && entityValues.containsKey(Events.RRULE)) { 1226 vCalendarTimeZone = TimeZone.getDefault(); 1227 // Write the VTIMEZONE block to the writer 1228 timeZoneToVTimezone(vCalendarTimeZone, ics); 1229 vCalendarTimeZoneSuffix = ";TZID=" + vCalendarTimeZone.getID(); 1230 } 1231 1232 ics.writeTag("BEGIN", "VEVENT"); 1233 if (uid == null) { 1234 uid = entityValues.getAsString(Events._SYNC_DATA); 1235 } 1236 if (uid != null) { 1237 ics.writeTag("UID", uid); 1238 } 1239 1240 if (entityValues.containsKey("DTSTAMP")) { 1241 ics.writeTag("DTSTAMP", entityValues.getAsString("DTSTAMP")); 1242 } else { 1243 ics.writeTag("DTSTAMP", 1244 CalendarUtilities.millisToEasDateTime(System.currentTimeMillis())); 1245 } 1246 1247 long startTime = entityValues.getAsLong(Events.DTSTART); 1248 if (startTime != 0) { 1249 ics.writeTag("DTSTART" + vCalendarTimeZoneSuffix, 1250 CalendarUtilities.millisToEasDateTime(startTime, vCalendarTimeZone)); 1251 } 1252 1253 if (!entityValues.containsKey(Events.DURATION)) { 1254 if (entityValues.containsKey(Events.DTEND)) { 1255 ics.writeTag("DTEND" + vCalendarTimeZoneSuffix, 1256 CalendarUtilities.millisToEasDateTime( 1257 entityValues.getAsLong(Events.DTEND), vCalendarTimeZone)); 1258 } 1259 } else { 1260 // Convert this into millis and add it to DTSTART for DTEND 1261 // We'll use 1 hour as a default 1262 long durationMillis = HOURS; 1263 Duration duration = new Duration(); 1264 try { 1265 duration.parse(entityValues.getAsString(Events.DURATION)); 1266 } catch (ParseException e) { 1267 // We'll use the default in this case 1268 } 1269 ics.writeTag("DTEND" + vCalendarTimeZoneSuffix, 1270 CalendarUtilities.millisToEasDateTime( 1271 startTime + durationMillis, vCalendarTimeZone)); 1272 } 1273 1274 if (entityValues.containsKey(Events.EVENT_LOCATION)) { 1275 ics.writeTag("LOCATION", entityValues.getAsString(Events.EVENT_LOCATION)); 1276 } 1277 1278 int titleId = 0; 1279 switch (messageFlag) { 1280 case Message.FLAG_OUTGOING_MEETING_INVITE: 1281 titleId = R.string.meeting_invitation; 1282 break; 1283 case Message.FLAG_OUTGOING_MEETING_ACCEPT: 1284 titleId = R.string.meeting_accepted; 1285 break; 1286 case Message.FLAG_OUTGOING_MEETING_DECLINE: 1287 titleId = R.string.meeting_declined; 1288 break; 1289 case Message.FLAG_OUTGOING_MEETING_TENTATIVE: 1290 titleId = R.string.meeting_tentative; 1291 break; 1292 case Message.FLAG_OUTGOING_MEETING_CANCEL: 1293 titleId = R.string.meeting_canceled; 1294 break; 1295 } 1296 String title = entityValues.getAsString(Events.TITLE); 1297 if (title == null) { 1298 title = ""; 1299 } 1300 ics.writeTag("SUMMARY", title); 1301 if (titleId != 0) { 1302 msg.mSubject = context.getResources().getString(titleId, title); 1303 } 1304 if (method.equals("REQUEST")) { 1305 if (entityValues.containsKey(Events.ALL_DAY)) { 1306 Integer ade = entityValues.getAsInteger(Events.ALL_DAY); 1307 ics.writeTag("X-MICROSOFT-CDO-ALLDAYEVENT", ade == 0 ? "FALSE" : "TRUE"); 1308 } 1309 1310 String desc = entityValues.getAsString(Events.DESCRIPTION); 1311 if (desc != null) { 1312 // TODO Do we add some description of the event here? 1313 ics.writeTag("DESCRIPTION", desc); 1314 msg.mText = desc; 1315 } else { 1316 msg.mText = ""; 1317 } 1318 1319 String rrule = entityValues.getAsString(Events.RRULE); 1320 if (rrule != null) { 1321 ics.writeTag("RRULE", rrule); 1322 } 1323 1324 // Handle associated data EXCEPT for attendees, which have to be grouped 1325 for (NamedContentValues ncv: subValues) { 1326 Uri ncvUri = ncv.uri; 1327 if (ncvUri.equals(Reminders.CONTENT_URI)) { 1328 // TODO Consider sending alarm information in the meeting request, though 1329 // it's not obviously appropriate (i.e. telling the user what alarm to use) 1330 // This should be for REQUEST only 1331 // Here's what the VALARM would look like: 1332 // BEGIN:VALARM 1333 // ACTION:DISPLAY 1334 // DESCRIPTION:REMINDER 1335 // TRIGGER;RELATED=START:-PT15M 1336 // END:VALARM 1337 } 1338 } 1339 } 1340 1341 // Handle attendee data here; keep track of organizer and stream it afterward 1342 String organizerName = null; 1343 String organizerEmail = null; 1344 ArrayList<Address> toList = new ArrayList<Address>(); 1345 for (NamedContentValues ncv: subValues) { 1346 Uri ncvUri = ncv.uri; 1347 ContentValues ncvValues = ncv.values; 1348 if (ncvUri.equals(Attendees.CONTENT_URI)) { 1349 Integer relationship = 1350 ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP); 1351 // If there's no relationship, we can't create this for EAS 1352 // Similarly, we need an attendee email for each invitee 1353 if (relationship != null && 1354 ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) { 1355 // Organizer isn't among attendees in EAS 1356 if (relationship == Attendees.RELATIONSHIP_ORGANIZER) { 1357 organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 1358 organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 1359 continue; 1360 } 1361 String attendeeEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 1362 String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 1363 // This shouldn't be possible, but allow for it 1364 if (attendeeEmail == null) continue; 1365 1366 if ((messageFlag & Message.FLAG_OUTGOING_MEETING_REQUEST_MASK) != 0) { 1367 String icalTag = ICALENDAR_ATTENDEE_INVITE; 1368 if (attendeeName != null) { 1369 icalTag += ";CN=" + attendeeName; 1370 } 1371 ics.writeTag(icalTag, "MAILTO:" + attendeeEmail); 1372 toList.add(attendeeName == null ? new Address(attendeeEmail) : 1373 new Address(attendeeEmail, attendeeName)); 1374 } else if (attendeeEmail.equalsIgnoreCase(account.mEmailAddress)) { 1375 String icalTag = null; 1376 switch (messageFlag) { 1377 case Message.FLAG_OUTGOING_MEETING_ACCEPT: 1378 icalTag = ICALENDAR_ATTENDEE_ACCEPT; 1379 break; 1380 case Message.FLAG_OUTGOING_MEETING_DECLINE: 1381 icalTag = ICALENDAR_ATTENDEE_DECLINE; 1382 break; 1383 case Message.FLAG_OUTGOING_MEETING_TENTATIVE: 1384 icalTag = ICALENDAR_ATTENDEE_TENTATIVE; 1385 break; 1386 } 1387 if (icalTag != null) { 1388 if (attendeeName != null) { 1389 icalTag += ";CN=" + attendeeName; 1390 } 1391 ics.writeTag(icalTag, "MAILTO:" + attendeeEmail); 1392 } 1393 } 1394 } 1395 } 1396 } 1397 1398 // Create the organizer tag for ical 1399 if (organizerEmail != null) { 1400 String icalTag = "ORGANIZER"; 1401 // We should be able to find this, assuming the Email is the user's email 1402 // TODO Find this in the account 1403 if (organizerName != null) { 1404 icalTag += ";CN=" + organizerName; 1405 } 1406 ics.writeTag(icalTag, "MAILTO:" + organizerEmail); 1407 if (method.equals("REPLY")) { 1408 toList.add(organizerName == null ? new Address(organizerEmail) : 1409 new Address(organizerEmail, organizerName)); 1410 } 1411 } 1412 1413 // If we have no "to" list, we're done 1414 if (toList.isEmpty()) return null; 1415 // Write out the "to" list 1416 Address[] toArray = new Address[toList.size()]; 1417 int i = 0; 1418 for (Address address: toList) { 1419 toArray[i++] = address; 1420 } 1421 msg.mTo = Address.pack(toArray); 1422 1423 ics.writeTag("CLASS", "PUBLIC"); 1424 ics.writeTag("STATUS", "CONFIRMED"); 1425 ics.writeTag("TRANSP", "OPAQUE"); // What Exchange uses 1426 ics.writeTag("PRIORITY", "5"); // 1 to 9, 5 = medium 1427 ics.writeTag("SEQUENCE", entityValues.getAsString(Events._SYNC_VERSION)); 1428 ics.writeTag("END", "VEVENT"); 1429 ics.writeTag("END", "VCALENDAR"); 1430 ics.flush(); 1431 ics.close(); 1432 1433 // Create the ics attachment using the "content" field 1434 Attachment att = new Attachment(); 1435 att.mContent = ics.toString(); 1436 att.mMimeType = "text/calendar; method=" + method; 1437 att.mFileName = "invite.ics"; 1438 att.mSize = att.mContent.length(); 1439 // We don't send content-disposition with this attachment 1440 att.mFlags = Attachment.FLAG_SUPPRESS_DISPOSITION; 1441 1442 // Add the attachment to the message 1443 msg.mAttachments = new ArrayList<Attachment>(); 1444 msg.mAttachments.add(att); 1445 } catch (IOException e) { 1446 Log.w(TAG, "IOException in createMessageForEntity"); 1447 return null; 1448 } 1449 1450 // Return the new Message to caller 1451 return msg; 1452 } 1453 1454 /** 1455 * Create a Message for an Event that can be retrieved from CalendarProvider by its unique id 1456 * @param cr a content resolver that can be used to query for the Event 1457 * @param eventId the unique id of the Event 1458 * @param messageFlag the Message.FLAG_XXX constant indicating the type of email to be sent 1459 * @param the unique id of this Event, or null if it can be retrieved from the Event 1460 * @param the user's account 1461 * @return a Message with many fields pre-filled (more later) 1462 * @throws RemoteException if there is an issue retrieving the Event from CalendarProvider 1463 */ 1464 static public EmailContent.Message createMessageForEventId(Context context, long eventId, 1465 int messageFlag, String uid, Account account) throws RemoteException { 1466 ContentResolver cr = context.getContentResolver(); 1467 EntityIterator eventIterator = 1468 EventsEntity.newEntityIterator( 1469 cr.query(ContentUris.withAppendedId(Events.CONTENT_URI.buildUpon() 1470 .appendQueryParameter(android.provider.Calendar.CALLER_IS_SYNCADAPTER, 1471 "true").build(), eventId), null, null, null, null), cr); 1472 try { 1473 while (eventIterator.hasNext()) { 1474 Entity entity = eventIterator.next(); 1475 return createMessageForEntity(context, entity, messageFlag, uid, account); 1476 } 1477 } finally { 1478 eventIterator.close(); 1479 } 1480 return null; 1481 } 1482}