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