CalendarUtilities.java revision 6a4eae5f4104599cddfea67705cc4d594ee7d47f
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.content.res.Resources; 40import android.net.Uri; 41import android.os.RemoteException; 42import android.provider.Calendar.Attendees; 43import android.provider.Calendar.Calendars; 44import android.provider.Calendar.Events; 45import android.provider.Calendar.EventsEntity; 46import android.text.TextUtils; 47import android.text.format.Time; 48import android.util.Base64; 49import android.util.Log; 50 51import java.io.IOException; 52import java.text.DateFormat; 53import java.text.ParseException; 54import java.util.ArrayList; 55import java.util.Calendar; 56import java.util.Date; 57import java.util.GregorianCalendar; 58import java.util.HashMap; 59import java.util.TimeZone; 60 61public class CalendarUtilities { 62 // NOTE: Most definitions in this class are have package visibility for testing purposes 63 private static final String TAG = "CalendarUtility"; 64 65 // Time related convenience constants, in milliseconds 66 static final int SECONDS = 1000; 67 static final int MINUTES = SECONDS*60; 68 static final int HOURS = MINUTES*60; 69 static final long DAYS = HOURS*24; 70 71 // NOTE All Microsoft data structures are little endian 72 73 // The following constants relate to standard Microsoft data sizes 74 // For documentation, see http://msdn.microsoft.com/en-us/library/aa505945.aspx 75 static final int MSFT_LONG_SIZE = 4; 76 static final int MSFT_WCHAR_SIZE = 2; 77 static final int MSFT_WORD_SIZE = 2; 78 79 // The following constants relate to Microsoft's SYSTEMTIME structure 80 // For documentation, see: http://msdn.microsoft.com/en-us/library/ms724950(VS.85).aspx?ppud=4 81 82 static final int MSFT_SYSTEMTIME_YEAR = 0 * MSFT_WORD_SIZE; 83 static final int MSFT_SYSTEMTIME_MONTH = 1 * MSFT_WORD_SIZE; 84 static final int MSFT_SYSTEMTIME_DAY_OF_WEEK = 2 * MSFT_WORD_SIZE; 85 static final int MSFT_SYSTEMTIME_DAY = 3 * MSFT_WORD_SIZE; 86 static final int MSFT_SYSTEMTIME_HOUR = 4 * MSFT_WORD_SIZE; 87 static final int MSFT_SYSTEMTIME_MINUTE = 5 * MSFT_WORD_SIZE; 88 //static final int MSFT_SYSTEMTIME_SECONDS = 6 * MSFT_WORD_SIZE; 89 //static final int MSFT_SYSTEMTIME_MILLIS = 7 * MSFT_WORD_SIZE; 90 static final int MSFT_SYSTEMTIME_SIZE = 8*MSFT_WORD_SIZE; 91 92 // The following constants relate to Microsoft's TIME_ZONE_INFORMATION structure 93 // For documentation, see http://msdn.microsoft.com/en-us/library/ms725481(VS.85).aspx 94 static final int MSFT_TIME_ZONE_BIAS_OFFSET = 0; 95 static final int MSFT_TIME_ZONE_STANDARD_NAME_OFFSET = 96 MSFT_TIME_ZONE_BIAS_OFFSET + MSFT_LONG_SIZE; 97 static final int MSFT_TIME_ZONE_STANDARD_DATE_OFFSET = 98 MSFT_TIME_ZONE_STANDARD_NAME_OFFSET + (MSFT_WCHAR_SIZE*32); 99 static final int MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET = 100 MSFT_TIME_ZONE_STANDARD_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE; 101 static final int MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET = 102 MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET + MSFT_LONG_SIZE; 103 static final int MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET = 104 MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET + (MSFT_WCHAR_SIZE*32); 105 static final int MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET = 106 MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE; 107 static final int MSFT_TIME_ZONE_SIZE = 108 MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET + MSFT_LONG_SIZE; 109 110 // TimeZone cache; we parse/decode as little as possible, because the process is quite slow 111 private static HashMap<String, TimeZone> sTimeZoneCache = new HashMap<String, TimeZone>(); 112 // TZI string cache; we keep around our encoded TimeZoneInformation strings 113 private static HashMap<TimeZone, String> sTziStringCache = new HashMap<TimeZone, String>(); 114 115 // There is no type 4 (thus, the "") 116 static final String[] sTypeToFreq = 117 new String[] {"DAILY", "WEEKLY", "MONTHLY", "MONTHLY", "", "YEARLY", "YEARLY"}; 118 119 static final String[] sDayTokens = 120 new String[] {"SU", "MO", "TU", "WE", "TH", "FR", "SA"}; 121 122 static final String[] sTwoCharacterNumbers = 123 new String[] {"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"}; 124 125 static final int sCurrentYear = new GregorianCalendar().get(Calendar.YEAR); 126 static final TimeZone sGmtTimeZone = TimeZone.getTimeZone("GMT"); 127 128 private static final String ICALENDAR_ATTENDEE = "ATTENDEE;ROLE=REQ-PARTICIPANT"; 129 static final String ICALENDAR_ATTENDEE_CANCEL = ICALENDAR_ATTENDEE; 130 static final String ICALENDAR_ATTENDEE_INVITE = 131 ICALENDAR_ATTENDEE + ";PARTSTAT=NEEDS-ACTION;RSVP=TRUE"; 132 static final String ICALENDAR_ATTENDEE_ACCEPT = 133 ICALENDAR_ATTENDEE + ";PARTSTAT=ACCEPTED"; 134 static final String ICALENDAR_ATTENDEE_DECLINE = 135 ICALENDAR_ATTENDEE + ";PARTSTAT=DECLINED"; 136 static final String ICALENDAR_ATTENDEE_TENTATIVE = 137 ICALENDAR_ATTENDEE + ";PARTSTAT=TENTATIVE"; 138 139 // Return a 4-byte long from a byte array (little endian) 140 static int getLong(byte[] bytes, int offset) { 141 return (bytes[offset++] & 0xFF) | ((bytes[offset++] & 0xFF) << 8) | 142 ((bytes[offset++] & 0xFF) << 16) | ((bytes[offset] & 0xFF) << 24); 143 } 144 145 // Put a 4-byte long into a byte array (little endian) 146 static void setLong(byte[] bytes, int offset, int value) { 147 bytes[offset++] = (byte) (value & 0xFF); 148 bytes[offset++] = (byte) ((value >> 8) & 0xFF); 149 bytes[offset++] = (byte) ((value >> 16) & 0xFF); 150 bytes[offset] = (byte) ((value >> 24) & 0xFF); 151 } 152 153 // Return a 2-byte word from a byte array (little endian) 154 static int getWord(byte[] bytes, int offset) { 155 return (bytes[offset++] & 0xFF) | ((bytes[offset] & 0xFF) << 8); 156 } 157 158 // Put a 2-byte word into a byte array (little endian) 159 static void setWord(byte[] bytes, int offset, int value) { 160 bytes[offset++] = (byte) (value & 0xFF); 161 bytes[offset] = (byte) ((value >> 8) & 0xFF); 162 } 163 164 // Internal structure for storing a time zone date from a SYSTEMTIME structure 165 // This date represents either the start or the end time for DST 166 static class TimeZoneDate { 167 String year; 168 int month; 169 int dayOfWeek; 170 int day; 171 int time; 172 int hour; 173 int minute; 174 } 175 176 static void putRuleIntoTimeZoneInformation(byte[] bytes, int offset, RRule rrule, int hour, 177 int minute) { 178 // MSFT months are 1 based, same as RRule 179 setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, rrule.month); 180 // MSFT day of week starts w/ Sunday = 0; RRule starts w/ Sunday = 1 181 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, rrule.dayOfWeek - 1); 182 // 5 means "last" in MSFT land; for RRule, it's -1 183 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, rrule.week < 0 ? 5 : rrule.week); 184 // Turn hours/minutes into ms from midnight (per TimeZone) 185 setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, hour); 186 setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, minute); 187 } 188 189 // Write a transition time into SYSTEMTIME data (via an offset into a byte array) 190 static void putTransitionMillisIntoSystemTime(byte[] bytes, int offset, long millis) { 191 GregorianCalendar cal = new GregorianCalendar(TimeZone.getDefault()); 192 // Round to the next highest minute; we always write seconds as zero 193 cal.setTimeInMillis(millis + 30*SECONDS); 194 195 // MSFT months are 1 based; TimeZone is 0 based 196 setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, cal.get(Calendar.MONTH) + 1); 197 // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1 198 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, cal.get(Calendar.DAY_OF_WEEK) - 1); 199 200 // Get the "day" in TimeZone format 201 int wom = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH); 202 // 5 means "last" in MSFT land; for TimeZone, it's -1 203 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, wom < 0 ? 5 : wom); 204 205 // Turn hours/minutes into ms from midnight (per TimeZone) 206 setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, getTrueTransitionHour(cal)); 207 setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, getTrueTransitionMinute(cal)); 208 } 209 210 // Build a TimeZoneDate structure from a SYSTEMTIME within a byte array at a given offset 211 static TimeZoneDate getTimeZoneDateFromSystemTime(byte[] bytes, int offset) { 212 TimeZoneDate tzd = new TimeZoneDate(); 213 214 // MSFT year is an int; TimeZone is a String 215 int num = getWord(bytes, offset + MSFT_SYSTEMTIME_YEAR); 216 tzd.year = Integer.toString(num); 217 218 // MSFT month = 0 means no daylight time 219 // MSFT months are 1 based; TimeZone is 0 based 220 num = getWord(bytes, offset + MSFT_SYSTEMTIME_MONTH); 221 if (num == 0) { 222 return null; 223 } else { 224 tzd.month = num -1; 225 } 226 227 // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1 228 tzd.dayOfWeek = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK) + 1; 229 230 // Get the "day" in TimeZone format 231 num = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY); 232 // 5 means "last" in MSFT land; for TimeZone, it's -1 233 if (num == 5) { 234 tzd.day = -1; 235 } else { 236 tzd.day = num; 237 } 238 239 // Turn hours/minutes into ms from midnight (per TimeZone) 240 int hour = getWord(bytes, offset + MSFT_SYSTEMTIME_HOUR); 241 tzd.hour = hour; 242 int minute = getWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE); 243 tzd.minute = minute; 244 tzd.time = (hour*HOURS) + (minute*MINUTES); 245 246 return tzd; 247 } 248 249 /** 250 * Build a GregorianCalendar, based on a time zone and TimeZoneDate. 251 * @param timeZone the time zone we're checking 252 * @param tzd the TimeZoneDate we're interested in 253 * @return a GregorianCalendar with the given time zone and date 254 */ 255 static long getMillisAtTimeZoneDateTransition(TimeZone timeZone, TimeZoneDate tzd) { 256 GregorianCalendar testCalendar = new GregorianCalendar(timeZone); 257 testCalendar.set(GregorianCalendar.YEAR, sCurrentYear); 258 testCalendar.set(GregorianCalendar.MONTH, tzd.month); 259 testCalendar.set(GregorianCalendar.DAY_OF_WEEK, tzd.dayOfWeek); 260 testCalendar.set(GregorianCalendar.DAY_OF_WEEK_IN_MONTH, tzd.day); 261 testCalendar.set(GregorianCalendar.HOUR_OF_DAY, tzd.hour); 262 testCalendar.set(GregorianCalendar.MINUTE, tzd.minute); 263 testCalendar.set(GregorianCalendar.SECOND, 0); 264 return testCalendar.getTimeInMillis(); 265 } 266 267 /** 268 * Return a GregorianCalendar representing the first standard/daylight transition between a 269 * start time and an end time in the given time zone 270 * @param tz a TimeZone the time zone in which we're looking for transitions 271 * @param startTime the start time for the test 272 * @param endTime the end time for the test 273 * @param startInDaylightTime whether daylight time is in effect at the startTime 274 * @return a GregorianCalendar representing the transition or null if none 275 */ 276 static GregorianCalendar findTransitionDate(TimeZone tz, long startTime, 277 long endTime, boolean startInDaylightTime) { 278 long startingEndTime = endTime; 279 Date date = null; 280 281 // We'll keep splitting the difference until we're within a minute 282 while ((endTime - startTime) > MINUTES) { 283 long checkTime = ((startTime + endTime) / 2) + 1; 284 date = new Date(checkTime); 285 boolean inDaylightTime = tz.inDaylightTime(date); 286 if (inDaylightTime != startInDaylightTime) { 287 endTime = checkTime; 288 } else { 289 startTime = checkTime; 290 } 291 } 292 293 // If these are the same, we're really messed up; return null 294 if (endTime == startingEndTime) { 295 return null; 296 } 297 298 // Set up our calendar and return it 299 GregorianCalendar calendar = new GregorianCalendar(tz); 300 calendar.setTimeInMillis(startTime); 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 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 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 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 transitionMillisToVCalendarTime( 574 toDaylightCalendars[0].getTimeInMillis(), tz, true)); 575 if (hasRule) { 576 writer.writeTag("RRULE", daylightRule.toString()); 577 } else { 578 for (int i = 1; i < maxYears; i++) { 579 writer.writeTag("RDATE", transitionMillisToVCalendarTime( 580 toDaylightCalendars[i].getTimeInMillis(), tz, true)); 581 } 582 } 583 writer.writeTag("END", "DAYLIGHT"); 584 // Write the STANDARD block 585 writer.writeTag("BEGIN", "STANDARD"); 586 writer.writeTag("TZOFFSETFROM", daylightOffsetString); 587 writer.writeTag("TZOFFSETTO", standardOffsetString); 588 writer.writeTag("DTSTART", 589 transitionMillisToVCalendarTime( 590 toStandardCalendars[0].getTimeInMillis(), tz, false)); 591 if (hasRule) { 592 writer.writeTag("RRULE", standardRule.toString()); 593 } else { 594 for (int i = 1; i < maxYears; i++) { 595 writer.writeTag("RDATE", transitionMillisToVCalendarTime( 596 toStandardCalendars[i].getTimeInMillis(), tz, true)); 597 } 598 } 599 writer.writeTag("END", "STANDARD"); 600 // And we're done 601 writer.writeTag("END", "VTIMEZONE"); 602 } 603 604 /** 605 * Find the next transition to occur (i.e. after the current date/time) 606 * @param transitions calendars representing transitions to/from DST 607 * @return millis for the first transition after the current date/time 608 */ 609 static long findNextTransition(long startingMillis, GregorianCalendar[] transitions) { 610 for (GregorianCalendar transition: transitions) { 611 long transitionMillis = transition.getTimeInMillis(); 612 if (transitionMillis > startingMillis) { 613 return transitionMillis; 614 } 615 } 616 return 0; 617 } 618 619 /** 620 * Calculate the Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone 621 * that might be found in an Event. Since the internal representation of the TimeZone is hidden 622 * from us we'll find the DST transitions and build the structure from that information 623 * @param tz the TimeZone 624 * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element 625 */ 626 static String timeZoneToTziStringImpl(TimeZone tz) { 627 String tziString; 628 byte[] tziBytes = new byte[MSFT_TIME_ZONE_SIZE]; 629 int standardBias = - tz.getRawOffset(); 630 standardBias /= 60*SECONDS; 631 setLong(tziBytes, MSFT_TIME_ZONE_BIAS_OFFSET, standardBias); 632 // If this time zone has daylight savings time, we need to do more work 633 if (tz.useDaylightTime()) { 634 GregorianCalendar[] toDaylightCalendars = new GregorianCalendar[3]; 635 GregorianCalendar[] toStandardCalendars = new GregorianCalendar[3]; 636 // See if we can get transitions for a few years; if not, we can't generate DST info 637 // for this time zone 638 if (getDSTCalendars(tz, toDaylightCalendars, toStandardCalendars)) { 639 // Try to find a rule to cover these years 640 RRule daylightRule = inferRRuleFromCalendars(toDaylightCalendars); 641 RRule standardRule = inferRRuleFromCalendars(toStandardCalendars); 642 if ((daylightRule != null) && (daylightRule.type == RRule.RRULE_DAY_WEEK) && 643 (standardRule != null) && (standardRule.type == RRule.RRULE_DAY_WEEK)) { 644 // We need both rules and they have to be DAY/WEEK type 645 // Write month, day of week, week, hour, minute 646 putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET, 647 standardRule, 648 getTrueTransitionHour(toStandardCalendars[0]), 649 getTrueTransitionMinute(toStandardCalendars[0])); 650 putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET, 651 daylightRule, 652 getTrueTransitionHour(toDaylightCalendars[0]), 653 getTrueTransitionMinute(toDaylightCalendars[0])); 654 } else { 655 // If there's no rule, we'll use the first transition to standard/to daylight 656 // And indicate that it's just for this year... 657 long now = System.currentTimeMillis(); 658 long standardTransition = findNextTransition(now, toStandardCalendars); 659 long daylightTransition = findNextTransition(now, toDaylightCalendars); 660 // If we can't find transitions, we can't do DST 661 if (standardTransition != 0 && daylightTransition != 0) { 662 putTransitionMillisIntoSystemTime(tziBytes, 663 MSFT_TIME_ZONE_STANDARD_DATE_OFFSET, standardTransition); 664 putTransitionMillisIntoSystemTime(tziBytes, 665 MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET, daylightTransition); 666 } 667 } 668 } 669 int dstOffset = tz.getDSTSavings(); 670 setLong(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET, - dstOffset / MINUTES); 671 } 672 byte[] tziEncodedBytes = Base64.encode(tziBytes, Base64.NO_WRAP); 673 tziString = new String(tziEncodedBytes); 674 return tziString; 675 } 676 677 /** 678 * Given a String as directly read from EAS, returns a TimeZone corresponding to that String 679 * @param timeZoneString the String read from the server 680 * @return the TimeZone, or TimeZone.getDefault() if not found 681 */ 682 static public TimeZone tziStringToTimeZone(String timeZoneString) { 683 // If we have this time zone cached, use that value and return 684 TimeZone timeZone = sTimeZoneCache.get(timeZoneString); 685 if (timeZone != null) { 686 if (Eas.USER_LOG) { 687 Log.d(TAG, " Using cached TimeZone " + timeZone.getDisplayName()); 688 } 689 } else { 690 timeZone = tziStringToTimeZoneImpl(timeZoneString); 691 if (timeZone == null) { 692 // If we don't find a match, we just return the current TimeZone. In theory, this 693 // shouldn't be happening... 694 Log.w(TAG, "TimeZone not found using default: " + timeZoneString); 695 timeZone = TimeZone.getDefault(); 696 } 697 sTimeZoneCache.put(timeZoneString, timeZone); 698 } 699 return timeZone; 700 } 701 702 /** 703 * Given a String as directly read from EAS, tries to find a TimeZone in the database of all 704 * time zones that corresponds to that String. 705 * @param timeZoneString the String read from the server 706 * @return the TimeZone, or null if not found 707 */ 708 static TimeZone tziStringToTimeZoneImpl(String timeZoneString) { 709 TimeZone timeZone = null; 710 // First, we need to decode the base64 string 711 byte[] timeZoneBytes = Base64.decode(timeZoneString, Base64.DEFAULT); 712 713 // Then, we get the bias (similar to a rawOffset); for TimeZone, we need ms 714 // but EAS gives us minutes, so do the conversion. Note that EAS is the bias that's added 715 // to the time zone to reach UTC; our library uses the time from UTC to our time zone, so 716 // we need to change the sign 717 int bias = -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_BIAS_OFFSET) * MINUTES; 718 719 // Get all of the time zones with the bias as a rawOffset; if there aren't any, we return 720 // the default time zone 721 String[] zoneIds = TimeZone.getAvailableIDs(bias); 722 if (zoneIds.length > 0) { 723 // Try to find an existing TimeZone from the data provided by EAS 724 // We start by pulling out the date that standard time begins 725 TimeZoneDate dstEnd = 726 getTimeZoneDateFromSystemTime(timeZoneBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET); 727 if (dstEnd == null) { 728 // In this case, there is no daylight savings time, so the only interesting data 729 // is the offset, and we know that all of the zoneId's match; we'll take the first 730 timeZone = TimeZone.getTimeZone(zoneIds[0]); 731 String dn = timeZone.getDisplayName(); 732 sTimeZoneCache.put(timeZoneString, timeZone); 733 if (Eas.USER_LOG) { 734 Log.d(TAG, "TimeZone without DST found by offset: " + dn); 735 } 736 return timeZone; 737 } else { 738 TimeZoneDate dstStart = getTimeZoneDateFromSystemTime(timeZoneBytes, 739 MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET); 740 // See comment above for bias... 741 long dstSavings = 742 -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET) * MINUTES; 743 744 // We'll go through each time zone to find one with the same DST transitions and 745 // savings length 746 for (String zoneId: zoneIds) { 747 // Get the TimeZone using the zoneId 748 timeZone = TimeZone.getTimeZone(zoneId); 749 750 // Our strategy here is to check just before and just after the transitions 751 // and see whether the check for daylight time matches the expectation 752 // If both transitions match, then we have a match for the offset and start/end 753 // of dst. That's the best we can do for now, since there's no other info 754 // provided by EAS (i.e. we can't get dynamic transitions, etc.) 755 756 // Check one minute before and after DST start transition 757 long millisAtTransition = getMillisAtTimeZoneDateTransition(timeZone, dstStart); 758 Date before = new Date(millisAtTransition - MINUTES); 759 Date after = new Date(millisAtTransition + MINUTES); 760 if (timeZone.inDaylightTime(before)) continue; 761 if (!timeZone.inDaylightTime(after)) continue; 762 763 // Check one minute before and after DST end transition 764 millisAtTransition = getMillisAtTimeZoneDateTransition(timeZone, dstEnd); 765 // Note that we need to subtract an extra hour here, because we end up with 766 // gaining an hour in the transition BACK to standard time 767 before = new Date(millisAtTransition - (dstSavings + MINUTES)); 768 after = new Date(millisAtTransition + MINUTES); 769 if (!timeZone.inDaylightTime(before)) continue; 770 if (timeZone.inDaylightTime(after)) continue; 771 772 // Check that the savings are the same 773 if (dstSavings != timeZone.getDSTSavings()) continue; 774 return timeZone; 775 } 776 } 777 } 778 return null; 779 } 780 781 static public String convertEmailDateTimeToCalendarDateTime(String date) { 782 // Format for email date strings is 2010-02-23T16:00:00.000Z 783 // Format for calendar date strings is 20100223T160000Z 784 return date.substring(0, 4) + date.substring(5, 7) + date.substring(8, 13) + 785 date.substring(14, 16) + date.substring(17, 19) + 'Z'; 786 } 787 788 static String formatTwo(int num) { 789 if (num <= 12) { 790 return sTwoCharacterNumbers[num]; 791 } else 792 return Integer.toString(num); 793 } 794 795 /** 796 * Generate an EAS formatted date/time string based on GMT. See below for details. 797 */ 798 static public String millisToEasDateTime(long millis) { 799 return millisToEasDateTime(millis, sGmtTimeZone, true); 800 } 801 802 /** 803 * Generate an EAS formatted local date/time string from a time and a time zone. If the final 804 * argument is false, only a date will be returned (e.g. 20100331) 805 * @param millis a time in milliseconds 806 * @param tz a time zone 807 * @param withTime if the time is to be included in the string 808 * @return an EAS formatted string indicating the date (and time) in the given time zone 809 */ 810 static public String millisToEasDateTime(long millis, TimeZone tz, boolean withTime) { 811 StringBuilder sb = new StringBuilder(); 812 GregorianCalendar cal = new GregorianCalendar(tz); 813 cal.setTimeInMillis(millis); 814 sb.append(cal.get(Calendar.YEAR)); 815 sb.append(formatTwo(cal.get(Calendar.MONTH) + 1)); 816 sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH))); 817 if (withTime) { 818 sb.append('T'); 819 sb.append(formatTwo(cal.get(Calendar.HOUR_OF_DAY))); 820 sb.append(formatTwo(cal.get(Calendar.MINUTE))); 821 sb.append(formatTwo(cal.get(Calendar.SECOND))); 822 if (tz == sGmtTimeZone) { 823 sb.append('Z'); 824 } 825 } 826 return sb.toString(); 827 } 828 829 /** 830 * Return the true minute at which a transition occurs 831 * Our transition time should be the in the minute BEFORE the transition 832 * If this minute is 59, set minute to 0 and increment the hour 833 * NOTE: We don't want to add a minute and retrieve minute/hour from the Calendar, because 834 * Calendar time will itself be influenced by the transition! So adding 1 minute to 835 * 01:59 (assume PST->PDT) will become 03:00, which isn't what we want (we want 02:00) 836 * 837 * @param calendar the calendar holding the transition date/time 838 * @return the true minute of the transition 839 */ 840 static int getTrueTransitionMinute(GregorianCalendar calendar) { 841 int minute = calendar.get(Calendar.MINUTE); 842 if (minute == 59) { 843 minute = 0; 844 } 845 return minute; 846 } 847 848 /** 849 * Return the true hour at which a transition occurs 850 * See description for getTrueTransitionMinute, above 851 * @param calendar the calendar holding the transition date/time 852 * @return the true hour of the transition 853 */ 854 static int getTrueTransitionHour(GregorianCalendar calendar) { 855 int hour = calendar.get(Calendar.HOUR_OF_DAY); 856 hour++; 857 if (hour == 24) { 858 hour = 0; 859 } 860 return hour; 861 } 862 863 /** 864 * Generate a date/time string suitable for VTIMEZONE from a transition time in millis 865 * The format is YYYYMMDDTHHMMSS 866 * @param millis a transition time in milliseconds 867 * @param tz a time zone 868 * @param dst whether we're entering daylight time 869 */ 870 static String transitionMillisToVCalendarTime(long millis, TimeZone tz, boolean dst) { 871 StringBuilder sb = new StringBuilder(); 872 GregorianCalendar cal = new GregorianCalendar(tz); 873 cal.setTimeInMillis(millis); 874 sb.append(cal.get(Calendar.YEAR)); 875 sb.append(formatTwo(cal.get(Calendar.MONTH) + 1)); 876 sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH))); 877 sb.append('T'); 878 sb.append(formatTwo(getTrueTransitionHour(cal))); 879 sb.append(formatTwo(getTrueTransitionMinute(cal))); 880 sb.append(formatTwo(0)); 881 return sb.toString(); 882 } 883 884 /** 885 * Create a GregorianCalendar representing the year, month, and day for the given time in 886 * milliseconds and the local time zone. Hours, minutes, and seconds will be set to zero 887 * @param time the time in millis 888 * @param timeZone the time zone to be used 889 * @return a GregorianCalendar with the data required for an all-day event 890 */ 891 static public GregorianCalendar getAllDayCalendar(long time, TimeZone timeZone) { 892 // Calendar gives us times in GMT 893 GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone("GMT")); 894 calendar.setTimeInMillis(time); 895 // But we must send back to EAS in the event's time zone 896 GregorianCalendar allDayCalendar = new GregorianCalendar(timeZone); 897 // Set this calendar with correct year, month, and day, but zero hour, minute, and seconds 898 allDayCalendar.set(calendar.get(GregorianCalendar.YEAR), 899 calendar.get(GregorianCalendar.MONTH), 900 calendar.get(GregorianCalendar.DATE), 0, 0, 0); 901 return allDayCalendar; 902 } 903 904 static void addByDay(StringBuilder rrule, int dow, int wom) { 905 rrule.append(";BYDAY="); 906 boolean addComma = false; 907 for (int i = 0; i < 7; i++) { 908 if ((dow & 1) == 1) { 909 if (addComma) { 910 rrule.append(','); 911 } 912 if (wom > 0) { 913 // 5 = last week -> -1 914 // So -1SU = last sunday 915 rrule.append(wom == 5 ? -1 : wom); 916 } 917 rrule.append(sDayTokens[i]); 918 addComma = true; 919 } 920 dow >>= 1; 921 } 922 } 923 924 static void addByMonthDay(StringBuilder rrule, int dom) { 925 // 127 means last day of the month 926 if (dom == 127) { 927 dom = -1; 928 } 929 rrule.append(";BYMONTHDAY=" + dom); 930 } 931 932 /** 933 * Generate the String version of the EAS integer for a given BYDAY value in an rrule 934 * @param dow the BYDAY value of the rrule 935 * @return the String version of the EAS value of these days 936 */ 937 static String generateEasDayOfWeek(String dow) { 938 int bits = 0; 939 int bit = 1; 940 for (String token: sDayTokens) { 941 // If we can find the day in the dow String, add the bit to our bits value 942 if (dow.indexOf(token) >= 0) { 943 bits |= bit; 944 } 945 bit <<= 1; 946 } 947 return Integer.toString(bits); 948 } 949 950 /** 951 * Extract the value of a token in an RRULE string 952 * @param rrule an RRULE string 953 * @param token a token to look for in the RRULE 954 * @return the value of that token 955 */ 956 static String tokenFromRrule(String rrule, String token) { 957 int start = rrule.indexOf(token); 958 if (start < 0) return null; 959 int len = rrule.length(); 960 start += token.length(); 961 int end = start; 962 char c; 963 do { 964 c = rrule.charAt(end++); 965 if ((c == ';') || (end == len)) { 966 if (end == len) end++; 967 return rrule.substring(start, end -1); 968 } 969 } while (true); 970 } 971 972 /** 973 * Reformat an RRULE style UNTIL to an EAS style until 974 */ 975 static String recurrenceUntilToEasUntil(String until) { 976 StringBuilder sb = new StringBuilder(); 977 sb.append(until.substring(0, 4)); 978 sb.append(until.substring(4, 6)); 979 sb.append(until.substring(6, 8)); 980 sb.append("T000000Z"); 981 return sb.toString(); 982 } 983 984 /** 985 * Convenience method to add "until" to an EAS calendar stream 986 */ 987 static void addUntil(String rrule, Serializer s) throws IOException { 988 String until = tokenFromRrule(rrule, "UNTIL="); 989 if (until != null) { 990 s.data(Tags.CALENDAR_RECURRENCE_UNTIL, recurrenceUntilToEasUntil(until)); 991 } 992 } 993 994 /** 995 * Write recurrence information to EAS based on the RRULE in CalendarProvider 996 * @param rrule the RRULE, from CalendarProvider 997 * @param startTime, the DTSTART of this Event 998 * @param s the Serializer we're using to write WBXML data 999 * @throws IOException 1000 */ 1001 // NOTE: For the moment, we're only parsing recurrence types that are supported by the 1002 // Calendar app UI, which is a subset of possible recurrence types 1003 // This code must be updated when the Calendar adds new functionality 1004 static public void recurrenceFromRrule(String rrule, long startTime, Serializer s) 1005 throws IOException { 1006 Log.d("RRULE", "rule: " + rrule); 1007 String freq = tokenFromRrule(rrule, "FREQ="); 1008 // If there's no FREQ=X, then we don't write a recurrence 1009 // Note that we duplicate s.start(Tags.CALENDAR_RECURRENCE); s.end(); to prevent the 1010 // possibility of writing out a partial recurrence stanza 1011 if (freq != null) { 1012 if (freq.equals("DAILY")) { 1013 s.start(Tags.CALENDAR_RECURRENCE); 1014 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "0"); 1015 s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, "1"); 1016 s.end(); 1017 } else if (freq.equals("WEEKLY")) { 1018 s.start(Tags.CALENDAR_RECURRENCE); 1019 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "1"); 1020 s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, "1"); 1021 // Requires a day of week (whereas RRULE does not) 1022 String byDay = tokenFromRrule(rrule, "BYDAY="); 1023 if (byDay != null) { 1024 s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(byDay)); 1025 } 1026 addUntil(rrule, s); 1027 s.end(); 1028 } else if (freq.equals("MONTHLY")) { 1029 String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY="); 1030 if (byMonthDay != null) { 1031 // The nth day of the month 1032 s.start(Tags.CALENDAR_RECURRENCE); 1033 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "2"); 1034 s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay); 1035 addUntil(rrule, s); 1036 s.end(); 1037 } else { 1038 String byDay = tokenFromRrule(rrule, "BYDAY="); 1039 String bareByDay; 1040 if (byDay != null) { 1041 // This can be 1WE (1st Wednesday) or -1FR (last Friday) 1042 int wom = byDay.charAt(0); 1043 if (wom == '-') { 1044 // -1 is the only legal case (last week) Use "5" for EAS 1045 wom = 5; 1046 bareByDay = byDay.substring(2); 1047 } else { 1048 wom = wom - '0'; 1049 bareByDay = byDay.substring(1); 1050 } 1051 s.start(Tags.CALENDAR_RECURRENCE); 1052 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "3"); 1053 s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, Integer.toString(wom)); 1054 s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(bareByDay)); 1055 addUntil(rrule, s); 1056 s.end(); 1057 } 1058 } 1059 } else if (freq.equals("YEARLY")) { 1060 String byMonth = tokenFromRrule(rrule, "BYMONTH="); 1061 String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY="); 1062 if (byMonth == null || byMonthDay == null) { 1063 // Calculate the month and day from the startDate 1064 GregorianCalendar cal = new GregorianCalendar(); 1065 cal.setTimeInMillis(startTime); 1066 cal.setTimeZone(TimeZone.getDefault()); 1067 byMonth = Integer.toString(cal.get(Calendar.MONTH) + 1); 1068 byMonthDay = Integer.toString(cal.get(Calendar.DAY_OF_MONTH)); 1069 } 1070 s.start(Tags.CALENDAR_RECURRENCE); 1071 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "5"); 1072 s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay); 1073 s.data(Tags.CALENDAR_RECURRENCE_MONTHOFYEAR, byMonth); 1074 addUntil(rrule, s); 1075 s.end(); 1076 } 1077 } 1078 } 1079 1080 /** 1081 * Build an RRULE String from EAS recurrence information 1082 * @param type the type of recurrence 1083 * @param occurrences how many recurrences (instances) 1084 * @param interval the interval between recurrences 1085 * @param dow day of the week 1086 * @param dom day of the month 1087 * @param wom week of the month 1088 * @param moy month of the year 1089 * @param until the last recurrence time 1090 * @return a valid RRULE String 1091 */ 1092 static public String rruleFromRecurrence(int type, int occurrences, int interval, int dow, 1093 int dom, int wom, int moy, String until) { 1094 StringBuilder rrule = new StringBuilder("FREQ=" + sTypeToFreq[type]); 1095 1096 // INTERVAL and COUNT 1097 if (interval > 0) { 1098 rrule.append(";INTERVAL=" + interval); 1099 } 1100 if (occurrences > 0) { 1101 rrule.append(";COUNT=" + occurrences); 1102 } 1103 1104 // Days, weeks, months, etc. 1105 switch(type) { 1106 case 0: // DAILY 1107 case 1: // WEEKLY 1108 if (dow > 0) addByDay(rrule, dow, -1); 1109 break; 1110 case 2: // MONTHLY 1111 if (dom > 0) addByMonthDay(rrule, dom); 1112 break; 1113 case 3: // MONTHLY (on the nth day) 1114 if (dow > 0) addByDay(rrule, dow, wom); 1115 break; 1116 case 5: // YEARLY (specific day) 1117 if (dom > 0) addByMonthDay(rrule, dom); 1118 if (moy > 0) { 1119 rrule.append(";BYMONTH=" + moy); 1120 } 1121 break; 1122 case 6: // YEARLY 1123 if (dow > 0) addByDay(rrule, dow, wom); 1124 if (dom > 0) addByMonthDay(rrule, dom); 1125 if (moy > 0) { 1126 rrule.append(";BYMONTH=" + moy); 1127 } 1128 break; 1129 default: 1130 break; 1131 } 1132 1133 // UNTIL comes last 1134 if (until != null) { 1135 rrule.append(";UNTIL=" + until); 1136 } 1137 1138 return rrule.toString(); 1139 } 1140 1141 /** 1142 * Create a Calendar in CalendarProvider to which synced Events will be linked 1143 * @param service the sync service requesting Calendar creation 1144 * @param account the account being synced 1145 * @param mailbox the Exchange mailbox for the calendar 1146 * @return the unique id of the Calendar 1147 */ 1148 static public long createCalendar(EasSyncService service, Account account, Mailbox mailbox) { 1149 // Create a Calendar object 1150 ContentValues cv = new ContentValues(); 1151 // TODO How will this change if the user changes his account display name? 1152 cv.put(Calendars.DISPLAY_NAME, account.mDisplayName); 1153 cv.put(Calendars._SYNC_ACCOUNT, account.mEmailAddress); 1154 cv.put(Calendars._SYNC_ACCOUNT_TYPE, Email.EXCHANGE_ACCOUNT_MANAGER_TYPE); 1155 cv.put(Calendars.SYNC_EVENTS, 1); 1156 cv.put(Calendars.SELECTED, 1); 1157 cv.put(Calendars.HIDDEN, 0); 1158 // Don't show attendee status if we're the organizer 1159 cv.put(Calendars.ORGANIZER_CAN_RESPOND, 0); 1160 1161 // TODO Coordinate account colors w/ Calendar, if possible 1162 // Make Email account color opaque 1163 cv.put(Calendars.COLOR, 0xFF000000 | Email.getAccountColor(account.mId)); 1164 cv.put(Calendars.TIMEZONE, Time.getCurrentTimezone()); 1165 cv.put(Calendars.ACCESS_LEVEL, Calendars.OWNER_ACCESS); 1166 cv.put(Calendars.OWNER_ACCOUNT, account.mEmailAddress); 1167 1168 Uri uri = service.mContentResolver.insert(Calendars.CONTENT_URI, cv); 1169 // We save the id of the calendar into mSyncStatus 1170 if (uri != null) { 1171 String stringId = uri.getPathSegments().get(1); 1172 mailbox.mSyncStatus = stringId; 1173 return Long.parseLong(stringId); 1174 } 1175 return -1; 1176 } 1177 1178 static public String buildMessageTextFromEntityValues(Context context, 1179 ContentValues entityValues, StringBuilder sb) { 1180 if (sb == null) { 1181 sb = new StringBuilder(); 1182 } 1183 Resources resources = context.getResources(); 1184 Date date = new Date(entityValues.getAsLong(Events.DTSTART)); 1185 String dateTimeString = DateFormat.getDateTimeInstance().format(date); 1186 // TODO: Add more detail to message text 1187 // Right now, we're using.. When: Tuesday, March 5th at 2:00pm 1188 // What we're missing is the duration and any recurrence information. So this should be 1189 // more like... When: Tuesdays, starting March 5th from 2:00pm - 3:00pm 1190 // This would require code to build complex strings, and it will have to wait 1191 // For now, we'll just use the meeting_recurring string 1192 if (!entityValues.containsKey(Events.ORIGINAL_EVENT) && 1193 entityValues.containsKey(Events.RRULE)) { 1194 sb.append(resources.getString(R.string.meeting_recurring, dateTimeString)); 1195 } else { 1196 sb.append(resources.getString(R.string.meeting_when, dateTimeString)); 1197 } 1198 String location = null; 1199 if (entityValues.containsKey(Events.EVENT_LOCATION)) { 1200 location = entityValues.getAsString(Events.EVENT_LOCATION); 1201 if (!TextUtils.isEmpty(location)) { 1202 sb.append("\n"); 1203 sb.append(resources.getString(R.string.meeting_where, location)); 1204 } 1205 } 1206 // If there's a description for this event, append it 1207 String desc = entityValues.getAsString(Events.DESCRIPTION); 1208 if (desc != null) { 1209 sb.append("\n--\n"); 1210 sb.append(desc); 1211 } 1212 return sb.toString(); 1213 } 1214 1215 /** 1216 * Create a Message for an (Event) Entity 1217 * @param entity the Entity for the Event (as might be retrieved by CalendarProvider) 1218 * @param messageFlag the Message.FLAG_XXX constant indicating the type of email to be sent 1219 * @param the unique id of this Event, or null if it can be retrieved from the Event 1220 * @param the user's account 1221 * @return a Message with many fields pre-filled (more later) 1222 */ 1223 static public EmailContent.Message createMessageForEntity(Context context, Entity entity, 1224 int messageFlag, String uid, Account account) { 1225 return createMessageForEntity(context, entity, messageFlag, uid, account, 1226 true /*requireAddressees*/); 1227 } 1228 1229 static public EmailContent.Message createMessageForEntity(Context context, Entity entity, 1230 int messageFlag, String uid, Account account, boolean requireAddressees) { 1231 ContentValues entityValues = entity.getEntityValues(); 1232 ArrayList<NamedContentValues> subValues = entity.getSubValues(); 1233 boolean isException = entityValues.containsKey(Events.ORIGINAL_EVENT); 1234 boolean isReply = false; 1235 1236 EmailContent.Message msg = new EmailContent.Message(); 1237 msg.mFlags = messageFlag; 1238 msg.mTimeStamp = System.currentTimeMillis(); 1239 1240 String method; 1241 if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_INVITE) != 0) { 1242 method = "REQUEST"; 1243 } else if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_CANCEL) != 0) { 1244 method = "CANCEL"; 1245 } else { 1246 method = "REPLY"; 1247 isReply = true; 1248 } 1249 1250 try { 1251 // Create our iCalendar writer and start generating tags 1252 SimpleIcsWriter ics = new SimpleIcsWriter(); 1253 ics.writeTag("BEGIN", "VCALENDAR"); 1254 ics.writeTag("METHOD", method); 1255 ics.writeTag("PRODID", "AndroidEmail"); 1256 ics.writeTag("VERSION", "2.0"); 1257 1258 // Our default vcalendar time zone is UTC, but this will change (below) if we're 1259 // sending a recurring event, in which case we use local time 1260 TimeZone vCalendarTimeZone = sGmtTimeZone; 1261 String vCalendarDateSuffix = ""; 1262 1263 // Check for all day event 1264 boolean allDayEvent = false; 1265 if (entityValues.containsKey(Events.ALL_DAY)) { 1266 Integer ade = entityValues.getAsInteger(Events.ALL_DAY); 1267 allDayEvent = (ade != null) && (ade == 1); 1268 if (allDayEvent) { 1269 // Example: DTSTART;VALUE=DATE:20100331 (all day event) 1270 vCalendarDateSuffix = ";VALUE=DATE"; 1271 } 1272 } 1273 1274 // If we're inviting people and the meeting is recurring, we need to send our time zone 1275 // information and make sure to send DTSTART/DTEND in local time (unless, of course, 1276 // this is an all-day event) 1277 if (!isReply && entityValues.containsKey(Events.RRULE) && !allDayEvent) { 1278 vCalendarTimeZone = TimeZone.getDefault(); 1279 // Write the VTIMEZONE block to the writer 1280 timeZoneToVTimezone(vCalendarTimeZone, ics); 1281 // Example: DTSTART;TZID=US/Pacific:20100331T124500 1282 vCalendarDateSuffix = ";TZID=" + vCalendarTimeZone.getID(); 1283 } 1284 1285 ics.writeTag("BEGIN", "VEVENT"); 1286 if (uid == null) { 1287 uid = entityValues.getAsString(Events._SYNC_DATA); 1288 } 1289 if (uid != null) { 1290 ics.writeTag("UID", uid); 1291 } 1292 1293 if (entityValues.containsKey("DTSTAMP")) { 1294 ics.writeTag("DTSTAMP", entityValues.getAsString("DTSTAMP")); 1295 } else { 1296 ics.writeTag("DTSTAMP", millisToEasDateTime(System.currentTimeMillis())); 1297 } 1298 1299 long startTime = entityValues.getAsLong(Events.DTSTART); 1300 if (startTime != 0) { 1301 ics.writeTag("DTSTART" + vCalendarDateSuffix, 1302 millisToEasDateTime(startTime, vCalendarTimeZone, !allDayEvent)); 1303 } 1304 1305 // If this is an Exception, we send the recurrence-id, which is just the original 1306 // instance time 1307 if (isException) { 1308 long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME); 1309 ics.writeTag("RECURRENCE-ID" + vCalendarDateSuffix, 1310 millisToEasDateTime(originalTime, vCalendarTimeZone, !allDayEvent)); 1311 } 1312 1313 if (!entityValues.containsKey(Events.DURATION)) { 1314 if (entityValues.containsKey(Events.DTEND)) { 1315 ics.writeTag("DTEND" + vCalendarDateSuffix, 1316 millisToEasDateTime( 1317 entityValues.getAsLong(Events.DTEND), vCalendarTimeZone, 1318 !allDayEvent)); 1319 } 1320 } else { 1321 // Convert this into millis and add it to DTSTART for DTEND 1322 // We'll use 1 hour as a default 1323 long durationMillis = HOURS; 1324 Duration duration = new Duration(); 1325 try { 1326 duration.parse(entityValues.getAsString(Events.DURATION)); 1327 } catch (ParseException e) { 1328 // We'll use the default in this case 1329 } 1330 ics.writeTag("DTEND" + vCalendarDateSuffix, 1331 millisToEasDateTime( 1332 startTime + durationMillis, vCalendarTimeZone, !allDayEvent)); 1333 } 1334 1335 String location = null; 1336 if (entityValues.containsKey(Events.EVENT_LOCATION)) { 1337 location = entityValues.getAsString(Events.EVENT_LOCATION); 1338 ics.writeTag("LOCATION", location); 1339 } 1340 1341 String sequence = entityValues.getAsString(Events._SYNC_VERSION); 1342 if (sequence == null) { 1343 sequence = "0"; 1344 } 1345 1346 // We'll use 0 to mean a meeting invitation 1347 int titleId = 0; 1348 switch (messageFlag) { 1349 case Message.FLAG_OUTGOING_MEETING_INVITE: 1350 if (!sequence.equals("0")) { 1351 titleId = R.string.meeting_updated; 1352 } 1353 break; 1354 case Message.FLAG_OUTGOING_MEETING_ACCEPT: 1355 titleId = R.string.meeting_accepted; 1356 break; 1357 case Message.FLAG_OUTGOING_MEETING_DECLINE: 1358 titleId = R.string.meeting_declined; 1359 break; 1360 case Message.FLAG_OUTGOING_MEETING_TENTATIVE: 1361 titleId = R.string.meeting_tentative; 1362 break; 1363 case Message.FLAG_OUTGOING_MEETING_CANCEL: 1364 titleId = R.string.meeting_canceled; 1365 break; 1366 } 1367 Resources resources = context.getResources(); 1368 String title = entityValues.getAsString(Events.TITLE); 1369 if (title == null) { 1370 title = ""; 1371 } 1372 ics.writeTag("SUMMARY", title); 1373 // For meeting invitations just use the title 1374 if (titleId == 0) { 1375 msg.mSubject = title; 1376 } else { 1377 // Otherwise, use the additional text 1378 msg.mSubject = resources.getString(titleId, title); 1379 } 1380 1381 // Build the text for the message, starting with an initial line describing the 1382 // exception (if this is one) 1383 StringBuilder sb = new StringBuilder(); 1384 if (isException && !isReply) { 1385 // Add the line, depending on whether this is a cancellation or update 1386 Date date = new Date(entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME)); 1387 String dateString = DateFormat.getDateInstance().format(date); 1388 if (titleId == R.string.meeting_canceled) { 1389 sb.append(resources.getString(R.string.exception_cancel, dateString)); 1390 } else { 1391 sb.append(resources.getString(R.string.exception_updated, dateString)); 1392 } 1393 sb.append("\n\n"); 1394 } 1395 String text = 1396 CalendarUtilities.buildMessageTextFromEntityValues(context, entityValues, sb); 1397 1398 if (text.length() > 0) { 1399 ics.writeTag("DESCRIPTION", text); 1400 } 1401 // And store the message text 1402 msg.mText = text; 1403 if (!isReply) { 1404 if (entityValues.containsKey(Events.ALL_DAY)) { 1405 Integer ade = entityValues.getAsInteger(Events.ALL_DAY); 1406 ics.writeTag("X-MICROSOFT-CDO-ALLDAYEVENT", ade == 0 ? "FALSE" : "TRUE"); 1407 } 1408 1409 String rrule = entityValues.getAsString(Events.RRULE); 1410 if (rrule != null) { 1411 ics.writeTag("RRULE", rrule); 1412 } 1413 1414 // If we decide to send alarm information in the meeting request ics file, 1415 // handle it here by looping through the subvalues 1416 } 1417 1418 // Handle attendee data here; determine "to" list and add ATTENDEE tags to ics 1419 String organizerName = null; 1420 String organizerEmail = null; 1421 ArrayList<Address> toList = new ArrayList<Address>(); 1422 for (NamedContentValues ncv: subValues) { 1423 Uri ncvUri = ncv.uri; 1424 ContentValues ncvValues = ncv.values; 1425 if (ncvUri.equals(Attendees.CONTENT_URI)) { 1426 Integer relationship = 1427 ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP); 1428 // If there's no relationship, we can't create this for EAS 1429 // Similarly, we need an attendee email for each invitee 1430 if (relationship != null && 1431 ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) { 1432 // Organizer isn't among attendees in EAS 1433 if (relationship == Attendees.RELATIONSHIP_ORGANIZER) { 1434 organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 1435 organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 1436 continue; 1437 } 1438 String attendeeEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 1439 String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 1440 // This shouldn't be possible, but allow for it 1441 if (attendeeEmail == null) continue; 1442 1443 if ((messageFlag & Message.FLAG_OUTGOING_MEETING_REQUEST_MASK) != 0) { 1444 String icalTag = ICALENDAR_ATTENDEE_INVITE; 1445 if ((messageFlag & Message.FLAG_OUTGOING_MEETING_CANCEL) != 0) { 1446 icalTag = ICALENDAR_ATTENDEE_CANCEL; 1447 } 1448 if (attendeeName != null) { 1449 icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(attendeeName); 1450 } 1451 ics.writeTag(icalTag, "MAILTO:" + attendeeEmail); 1452 toList.add(attendeeName == null ? new Address(attendeeEmail) : 1453 new Address(attendeeEmail, attendeeName)); 1454 } else if (attendeeEmail.equalsIgnoreCase(account.mEmailAddress)) { 1455 String icalTag = null; 1456 switch (messageFlag) { 1457 case Message.FLAG_OUTGOING_MEETING_ACCEPT: 1458 icalTag = ICALENDAR_ATTENDEE_ACCEPT; 1459 break; 1460 case Message.FLAG_OUTGOING_MEETING_DECLINE: 1461 icalTag = ICALENDAR_ATTENDEE_DECLINE; 1462 break; 1463 case Message.FLAG_OUTGOING_MEETING_TENTATIVE: 1464 icalTag = ICALENDAR_ATTENDEE_TENTATIVE; 1465 break; 1466 } 1467 if (icalTag != null) { 1468 if (attendeeName != null) { 1469 icalTag += ";CN=" 1470 + SimpleIcsWriter.quoteParamValue(attendeeName); 1471 } 1472 ics.writeTag(icalTag, "MAILTO:" + attendeeEmail); 1473 } 1474 } 1475 } 1476 } 1477 } 1478 1479 // Create the organizer tag for ical 1480 if (organizerEmail != null) { 1481 String icalTag = "ORGANIZER"; 1482 // We should be able to find this, assuming the Email is the user's email 1483 // TODO Find this in the account 1484 if (organizerName != null) { 1485 icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(organizerName); 1486 } 1487 ics.writeTag(icalTag, "MAILTO:" + organizerEmail); 1488 if (isReply) { 1489 toList.add(organizerName == null ? new Address(organizerEmail) : 1490 new Address(organizerEmail, organizerName)); 1491 } 1492 } 1493 1494 // If we have no "to" list and addressees are required (the default), we're done 1495 if (toList.isEmpty() && requireAddressees) return null; 1496 1497 // Write out the "to" list 1498 Address[] toArray = new Address[toList.size()]; 1499 int i = 0; 1500 for (Address address: toList) { 1501 toArray[i++] = address; 1502 } 1503 msg.mTo = Address.pack(toArray); 1504 1505 ics.writeTag("CLASS", "PUBLIC"); 1506 ics.writeTag("STATUS", (messageFlag == Message.FLAG_OUTGOING_MEETING_CANCEL) ? 1507 "CANCELLED" : "CONFIRMED"); 1508 ics.writeTag("TRANSP", "OPAQUE"); // What Exchange uses 1509 ics.writeTag("PRIORITY", "5"); // 1 to 9, 5 = medium 1510 ics.writeTag("SEQUENCE", sequence); 1511 ics.writeTag("END", "VEVENT"); 1512 ics.writeTag("END", "VCALENDAR"); 1513 1514 // Create the ics attachment using the "content" field 1515 Attachment att = new Attachment(); 1516 att.mContentBytes = ics.getBytes(); 1517 att.mMimeType = "text/calendar; method=" + method; 1518 att.mFileName = "invite.ics"; 1519 att.mSize = att.mContentBytes.length; 1520 // We don't send content-disposition with this attachment 1521 att.mFlags = Attachment.FLAG_ICS_ALTERNATIVE_PART; 1522 1523 // Add the attachment to the message 1524 msg.mAttachments = new ArrayList<Attachment>(); 1525 msg.mAttachments.add(att); 1526 } catch (IOException e) { 1527 Log.w(TAG, "IOException in createMessageForEntity"); 1528 return null; 1529 } 1530 1531 // Return the new Message to caller 1532 return msg; 1533 } 1534 1535 /** 1536 * Create a Message for an Event that can be retrieved from CalendarProvider by its unique id 1537 * @param cr a content resolver that can be used to query for the Event 1538 * @param eventId the unique id of the Event 1539 * @param messageFlag the Message.FLAG_XXX constant indicating the type of email to be sent 1540 * @param the unique id of this Event, or null if it can be retrieved from the Event 1541 * @param the user's account 1542 * @param requireAddressees if true (the default), no Message is returned if there aren't any 1543 * addressees; if false, return the Message regardless (addressees will be filled in later) 1544 * @return a Message with many fields pre-filled (more later) 1545 * @throws RemoteException if there is an issue retrieving the Event from CalendarProvider 1546 */ 1547 static public EmailContent.Message createMessageForEventId(Context context, long eventId, 1548 int messageFlag, String uid, Account account) throws RemoteException { 1549 return createMessageForEventId(context, eventId, messageFlag, uid, account, 1550 true /*requireAddressees*/); 1551 } 1552 1553 static public EmailContent.Message createMessageForEventId(Context context, long eventId, 1554 int messageFlag, String uid, Account account, boolean requireAddressees) 1555 throws RemoteException { 1556 ContentResolver cr = context.getContentResolver(); 1557 EntityIterator eventIterator = 1558 EventsEntity.newEntityIterator( 1559 cr.query(ContentUris.withAppendedId(Events.CONTENT_URI.buildUpon() 1560 .appendQueryParameter(android.provider.Calendar.CALLER_IS_SYNCADAPTER, 1561 "true").build(), eventId), null, null, null, null), cr); 1562 try { 1563 while (eventIterator.hasNext()) { 1564 Entity entity = eventIterator.next(); 1565 return createMessageForEntity(context, entity, messageFlag, uid, account, 1566 requireAddressees); 1567 } 1568 } finally { 1569 eventIterator.close(); 1570 } 1571 return null; 1572 } 1573} 1574