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 17 package com.android.exchange.utility; 18 19 import android.content.ContentResolver; 20 import android.content.ContentUris; 21 import android.content.ContentValues; 22 import android.content.Context; 23 import android.content.Entity; 24 import android.content.Entity.NamedContentValues; 25 import android.content.EntityIterator; 26 import android.content.res.Resources; 27 import android.net.Uri; 28 import android.os.RemoteException; 29 import android.provider.CalendarContract.Attendees; 30 import android.provider.CalendarContract.Calendars; 31 import android.provider.CalendarContract.Events; 32 import android.provider.CalendarContract.EventsEntity; 33 import android.text.TextUtils; 34 import android.text.format.Time; 35 import android.util.Base64; 36 import android.util.Log; 37 38 import com.android.emailcommon.Logging; 39 import com.android.emailcommon.mail.Address; 40 import com.android.emailcommon.provider.Account; 41 import com.android.emailcommon.provider.EmailContent; 42 import com.android.emailcommon.provider.EmailContent.Attachment; 43 import com.android.emailcommon.provider.EmailContent.Message; 44 import com.android.emailcommon.provider.Mailbox; 45 import com.android.emailcommon.service.AccountServiceProxy; 46 import com.android.emailcommon.utility.Utility; 47 import com.android.exchange.Eas; 48 import com.android.exchange.EasSyncService; 49 import com.android.exchange.ExchangeService; 50 import com.android.exchange.R; 51 import com.android.exchange.adapter.Serializer; 52 import com.android.exchange.adapter.Tags; 53 import com.android.internal.util.ArrayUtils; 54 import com.google.common.annotations.VisibleForTesting; 55 56 import java.io.IOException; 57 import java.text.DateFormat; 58 import java.text.ParseException; 59 import java.util.ArrayList; 60 import java.util.Calendar; 61 import java.util.Date; 62 import java.util.GregorianCalendar; 63 import java.util.HashMap; 64 import java.util.TimeZone; 65 66 public class CalendarUtilities { 67 68 // NOTE: Most definitions in this class are have package visibility for testing purposes 69 private static final String TAG = "CalendarUtility"; 70 71 // Time related convenience constants, in milliseconds 72 static final int SECONDS = 1000; 73 static final int MINUTES = SECONDS*60; 74 static final int HOURS = MINUTES*60; 75 static final long DAYS = HOURS*24; 76 77 private static final String SYNC_VERSION = Events.SYNC_DATA4; 78 // NOTE All Microsoft data structures are little endian 79 80 // The following constants relate to standard Microsoft data sizes 81 // For documentation, see http://msdn.microsoft.com/en-us/library/aa505945.aspx 82 static final int MSFT_LONG_SIZE = 4; 83 static final int MSFT_WCHAR_SIZE = 2; 84 static final int MSFT_WORD_SIZE = 2; 85 86 // The following constants relate to Microsoft's SYSTEMTIME structure 87 // For documentation, see: http://msdn.microsoft.com/en-us/library/ms724950(VS.85).aspx?ppud=4 88 89 static final int MSFT_SYSTEMTIME_YEAR = 0 * MSFT_WORD_SIZE; 90 static final int MSFT_SYSTEMTIME_MONTH = 1 * MSFT_WORD_SIZE; 91 static final int MSFT_SYSTEMTIME_DAY_OF_WEEK = 2 * MSFT_WORD_SIZE; 92 static final int MSFT_SYSTEMTIME_DAY = 3 * MSFT_WORD_SIZE; 93 static final int MSFT_SYSTEMTIME_HOUR = 4 * MSFT_WORD_SIZE; 94 static final int MSFT_SYSTEMTIME_MINUTE = 5 * MSFT_WORD_SIZE; 95 //static final int MSFT_SYSTEMTIME_SECONDS = 6 * MSFT_WORD_SIZE; 96 //static final int MSFT_SYSTEMTIME_MILLIS = 7 * MSFT_WORD_SIZE; 97 static final int MSFT_SYSTEMTIME_SIZE = 8*MSFT_WORD_SIZE; 98 99 // The following constants relate to Microsoft's TIME_ZONE_INFORMATION structure 100 // For documentation, see http://msdn.microsoft.com/en-us/library/ms725481(VS.85).aspx 101 static final int MSFT_TIME_ZONE_BIAS_OFFSET = 0; 102 static final int MSFT_TIME_ZONE_STANDARD_NAME_OFFSET = 103 MSFT_TIME_ZONE_BIAS_OFFSET + MSFT_LONG_SIZE; 104 static final int MSFT_TIME_ZONE_STANDARD_DATE_OFFSET = 105 MSFT_TIME_ZONE_STANDARD_NAME_OFFSET + (MSFT_WCHAR_SIZE*32); 106 static final int MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET = 107 MSFT_TIME_ZONE_STANDARD_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE; 108 static final int MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET = 109 MSFT_TIME_ZONE_STANDARD_BIAS_OFFSET + MSFT_LONG_SIZE; 110 static final int MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET = 111 MSFT_TIME_ZONE_DAYLIGHT_NAME_OFFSET + (MSFT_WCHAR_SIZE*32); 112 static final int MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET = 113 MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET + MSFT_SYSTEMTIME_SIZE; 114 static final int MSFT_TIME_ZONE_SIZE = 115 MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET + MSFT_LONG_SIZE; 116 117 // TimeZone cache; we parse/decode as little as possible, because the process is quite slow 118 private static HashMap<String, TimeZone> sTimeZoneCache = new HashMap<String, TimeZone>(); 119 // TZI string cache; we keep around our encoded TimeZoneInformation strings 120 private static HashMap<TimeZone, String> sTziStringCache = new HashMap<TimeZone, String>(); 121 122 private static final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC"); 123 private static final String ALLOWED_REMINDER_TYPES = "0,1"; 124 125 // There is no type 4 (thus, the "") 126 static final String[] sTypeToFreq = 127 new String[] {"DAILY", "WEEKLY", "MONTHLY", "MONTHLY", "", "YEARLY", "YEARLY"}; 128 129 static final String[] sDayTokens = 130 new String[] {"SU", "MO", "TU", "WE", "TH", "FR", "SA"}; 131 132 static final String[] sTwoCharacterNumbers = 133 new String[] {"00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"}; 134 135 // Bits used in EAS recurrences for days of the week 136 protected static final int EAS_SUNDAY = 1<<0; 137 protected static final int EAS_MONDAY = 1<<1; 138 protected static final int EAS_TUESDAY = 1<<2; 139 protected static final int EAS_WEDNESDAY = 1<<3; 140 protected static final int EAS_THURSDAY = 1<<4; 141 protected static final int EAS_FRIDAY = 1<<5; 142 protected static final int EAS_SATURDAY = 1<<6; 143 protected static final int EAS_WEEKDAYS = 144 EAS_MONDAY | EAS_TUESDAY | EAS_WEDNESDAY | EAS_THURSDAY | EAS_FRIDAY; 145 protected static final int EAS_WEEKENDS = EAS_SATURDAY | EAS_SUNDAY; 146 147 static final int sCurrentYear = new GregorianCalendar().get(Calendar.YEAR); 148 static final TimeZone sGmtTimeZone = TimeZone.getTimeZone("GMT"); 149 150 private static final String ICALENDAR_ATTENDEE = "ATTENDEE;ROLE=REQ-PARTICIPANT"; 151 static final String ICALENDAR_ATTENDEE_CANCEL = ICALENDAR_ATTENDEE; 152 static final String ICALENDAR_ATTENDEE_INVITE = 153 ICALENDAR_ATTENDEE + ";PARTSTAT=NEEDS-ACTION;RSVP=TRUE"; 154 static final String ICALENDAR_ATTENDEE_ACCEPT = 155 ICALENDAR_ATTENDEE + ";PARTSTAT=ACCEPTED"; 156 static final String ICALENDAR_ATTENDEE_DECLINE = 157 ICALENDAR_ATTENDEE + ";PARTSTAT=DECLINED"; 158 static final String ICALENDAR_ATTENDEE_TENTATIVE = 159 ICALENDAR_ATTENDEE + ";PARTSTAT=TENTATIVE"; 160 161 // Note that these constants apply to Calendar items 162 // For future reference: MeetingRequest data can also include free/busy information, but the 163 // constants for these four options in MeetingRequest data have different values! 164 // See [MS-ASCAL] 2.2.2.8 for Calendar BusyStatus 165 // See [MS-EMAIL] 2.2.2.34 for MeetingRequest BusyStatus 166 public static final int BUSY_STATUS_FREE = 0; 167 public static final int BUSY_STATUS_TENTATIVE = 1; 168 public static final int BUSY_STATUS_BUSY = 2; 169 public static final int BUSY_STATUS_OUT_OF_OFFICE = 3; 170 171 // Note that these constants apply to Calendar items, and are used in EAS 14+ 172 // See [MS-ASCAL] 2.2.2.22 for Calendar ResponseType 173 public static final int RESPONSE_TYPE_NONE = 0; 174 public static final int RESPONSE_TYPE_ORGANIZER = 1; 175 public static final int RESPONSE_TYPE_TENTATIVE = 2; 176 public static final int RESPONSE_TYPE_ACCEPTED = 3; 177 public static final int RESPONSE_TYPE_DECLINED = 4; 178 public static final int RESPONSE_TYPE_NOT_RESPONDED = 5; 179 180 // Return a 4-byte long from a byte array (little endian) 181 static int getLong(byte[] bytes, int offset) { 182 return (bytes[offset++] & 0xFF) | ((bytes[offset++] & 0xFF) << 8) | 183 ((bytes[offset++] & 0xFF) << 16) | ((bytes[offset] & 0xFF) << 24); 184 } 185 186 // Put a 4-byte long into a byte array (little endian) 187 static void setLong(byte[] bytes, int offset, int value) { 188 bytes[offset++] = (byte) (value & 0xFF); 189 bytes[offset++] = (byte) ((value >> 8) & 0xFF); 190 bytes[offset++] = (byte) ((value >> 16) & 0xFF); 191 bytes[offset] = (byte) ((value >> 24) & 0xFF); 192 } 193 194 // Return a 2-byte word from a byte array (little endian) 195 static int getWord(byte[] bytes, int offset) { 196 return (bytes[offset++] & 0xFF) | ((bytes[offset] & 0xFF) << 8); 197 } 198 199 // Put a 2-byte word into a byte array (little endian) 200 static void setWord(byte[] bytes, int offset, int value) { 201 bytes[offset++] = (byte) (value & 0xFF); 202 bytes[offset] = (byte) ((value >> 8) & 0xFF); 203 } 204 205 // Internal structure for storing a time zone date from a SYSTEMTIME structure 206 // This date represents either the start or the end time for DST 207 static class TimeZoneDate { 208 String year; 209 int month; 210 int dayOfWeek; 211 int day; 212 int time; 213 int hour; 214 int minute; 215 } 216 217 @VisibleForTesting 218 static void clearTimeZoneCache() { 219 sTimeZoneCache.clear(); 220 } 221 222 static void putRuleIntoTimeZoneInformation(byte[] bytes, int offset, RRule rrule, int hour, 223 int minute) { 224 // MSFT months are 1 based, same as RRule 225 setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, rrule.month); 226 // MSFT day of week starts w/ Sunday = 0; RRule starts w/ Sunday = 1 227 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, rrule.dayOfWeek - 1); 228 // 5 means "last" in MSFT land; for RRule, it's -1 229 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, rrule.week < 0 ? 5 : rrule.week); 230 // Turn hours/minutes into ms from midnight (per TimeZone) 231 setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, hour); 232 setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, minute); 233 } 234 235 // Write a transition time into SYSTEMTIME data (via an offset into a byte array) 236 static void putTransitionMillisIntoSystemTime(byte[] bytes, int offset, long millis) { 237 GregorianCalendar cal = new GregorianCalendar(TimeZone.getDefault()); 238 // Round to the next highest minute; we always write seconds as zero 239 cal.setTimeInMillis(millis + 30*SECONDS); 240 241 // MSFT months are 1 based; TimeZone is 0 based 242 setWord(bytes, offset + MSFT_SYSTEMTIME_MONTH, cal.get(Calendar.MONTH) + 1); 243 // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1 244 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK, cal.get(Calendar.DAY_OF_WEEK) - 1); 245 246 // Get the "day" in TimeZone format 247 int wom = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH); 248 // 5 means "last" in MSFT land; for TimeZone, it's -1 249 setWord(bytes, offset + MSFT_SYSTEMTIME_DAY, wom < 0 ? 5 : wom); 250 251 // Turn hours/minutes into ms from midnight (per TimeZone) 252 setWord(bytes, offset + MSFT_SYSTEMTIME_HOUR, getTrueTransitionHour(cal)); 253 setWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE, getTrueTransitionMinute(cal)); 254 } 255 256 // Build a TimeZoneDate structure from a SYSTEMTIME within a byte array at a given offset 257 static TimeZoneDate getTimeZoneDateFromSystemTime(byte[] bytes, int offset) { 258 TimeZoneDate tzd = new TimeZoneDate(); 259 260 // MSFT year is an int; TimeZone is a String 261 int num = getWord(bytes, offset + MSFT_SYSTEMTIME_YEAR); 262 tzd.year = Integer.toString(num); 263 264 // MSFT month = 0 means no daylight time 265 // MSFT months are 1 based; TimeZone is 0 based 266 num = getWord(bytes, offset + MSFT_SYSTEMTIME_MONTH); 267 if (num == 0) { 268 return null; 269 } else { 270 tzd.month = num -1; 271 } 272 273 // MSFT day of week starts w/ Sunday = 0; TimeZone starts w/ Sunday = 1 274 tzd.dayOfWeek = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY_OF_WEEK) + 1; 275 276 // Get the "day" in TimeZone format 277 num = getWord(bytes, offset + MSFT_SYSTEMTIME_DAY); 278 // 5 means "last" in MSFT land; for TimeZone, it's -1 279 if (num == 5) { 280 tzd.day = -1; 281 } else { 282 tzd.day = num; 283 } 284 285 // Turn hours/minutes into ms from midnight (per TimeZone) 286 int hour = getWord(bytes, offset + MSFT_SYSTEMTIME_HOUR); 287 tzd.hour = hour; 288 int minute = getWord(bytes, offset + MSFT_SYSTEMTIME_MINUTE); 289 tzd.minute = minute; 290 tzd.time = (hour*HOURS) + (minute*MINUTES); 291 292 return tzd; 293 } 294 295 /** 296 * Build a GregorianCalendar, based on a time zone and TimeZoneDate. 297 * @param timeZone the time zone we're checking 298 * @param tzd the TimeZoneDate we're interested in 299 * @return a GregorianCalendar with the given time zone and date 300 */ 301 static long getMillisAtTimeZoneDateTransition(TimeZone timeZone, TimeZoneDate tzd) { 302 GregorianCalendar testCalendar = new GregorianCalendar(timeZone); 303 testCalendar.set(GregorianCalendar.YEAR, sCurrentYear); 304 testCalendar.set(GregorianCalendar.MONTH, tzd.month); 305 testCalendar.set(GregorianCalendar.DAY_OF_WEEK, tzd.dayOfWeek); 306 testCalendar.set(GregorianCalendar.DAY_OF_WEEK_IN_MONTH, tzd.day); 307 testCalendar.set(GregorianCalendar.HOUR_OF_DAY, tzd.hour); 308 testCalendar.set(GregorianCalendar.MINUTE, tzd.minute); 309 testCalendar.set(GregorianCalendar.SECOND, 0); 310 return testCalendar.getTimeInMillis(); 311 } 312 313 /** 314 * Return a GregorianCalendar representing the first standard/daylight transition between a 315 * start time and an end time in the given time zone 316 * @param tz a TimeZone the time zone in which we're looking for transitions 317 * @param startTime the start time for the test 318 * @param endTime the end time for the test 319 * @param startInDaylightTime whether daylight time is in effect at the startTime 320 * @return a GregorianCalendar representing the transition or null if none 321 */ 322 static GregorianCalendar findTransitionDate(TimeZone tz, long startTime, 323 long endTime, boolean startInDaylightTime) { 324 long startingEndTime = endTime; 325 Date date = null; 326 327 // We'll keep splitting the difference until we're within a minute 328 while ((endTime - startTime) > MINUTES) { 329 long checkTime = ((startTime + endTime) / 2) + 1; 330 date = new Date(checkTime); 331 boolean inDaylightTime = tz.inDaylightTime(date); 332 if (inDaylightTime != startInDaylightTime) { 333 endTime = checkTime; 334 } else { 335 startTime = checkTime; 336 } 337 } 338 339 // If these are the same, we're really messed up; return null 340 if (endTime == startingEndTime) { 341 return null; 342 } 343 344 // Set up our calendar and return it 345 GregorianCalendar calendar = new GregorianCalendar(tz); 346 calendar.setTimeInMillis(startTime); 347 return calendar; 348 } 349 350 /** 351 * Return a Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone 352 * that might be found in an Event; use cached result, if possible 353 * @param tz the TimeZone 354 * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element 355 */ 356 static public String timeZoneToTziString(TimeZone tz) { 357 String tziString = sTziStringCache.get(tz); 358 if (tziString != null) { 359 if (Eas.USER_LOG) { 360 ExchangeService.log(TAG, "TZI string for " + tz.getDisplayName() + 361 " found in cache."); 362 } 363 return tziString; 364 } 365 tziString = timeZoneToTziStringImpl(tz); 366 sTziStringCache.put(tz, tziString); 367 return tziString; 368 } 369 370 /** 371 * A class for storing RRULE information. The RRULE members can be accessed individually or 372 * an RRULE string can be created with toString() 373 */ 374 static class RRule { 375 static final int RRULE_NONE = 0; 376 static final int RRULE_DAY_WEEK = 1; 377 static final int RRULE_DATE = 2; 378 379 int type; 380 int dayOfWeek; 381 int week; 382 int month; 383 int date; 384 385 /** 386 * Create an RRULE based on month and date 387 * @param _month the month (1 = JAN, 12 = DEC) 388 * @param _date the date in the month (1-31) 389 */ 390 RRule(int _month, int _date) { 391 type = RRULE_DATE; 392 month = _month; 393 date = _date; 394 } 395 396 /** 397 * Create an RRULE based on month, day of week, and week # 398 * @param _month the month (1 = JAN, 12 = DEC) 399 * @param _dayOfWeek the day of the week (1 = SU, 7 = SA) 400 * @param _week the week in the month (1-5 or -1 for last) 401 */ 402 RRule(int _month, int _dayOfWeek, int _week) { 403 type = RRULE_DAY_WEEK; 404 month = _month; 405 dayOfWeek = _dayOfWeek; 406 week = _week; 407 } 408 409 @Override 410 public String toString() { 411 if (type == RRULE_DAY_WEEK) { 412 return "FREQ=YEARLY;BYMONTH=" + month + ";BYDAY=" + week + 413 sDayTokens[dayOfWeek - 1]; 414 } else { 415 return "FREQ=YEARLY;BYMONTH=" + month + ";BYMONTHDAY=" + date; 416 } 417 } 418 } 419 420 /** 421 * Generate an RRULE string for an array of GregorianCalendars, if possible. For now, we are 422 * only looking for rules based on the same date in a month or a specific instance of a day of 423 * the week in a month (e.g. 2nd Tuesday or last Friday). Indeed, these are the only kinds of 424 * rules used in the current tzinfo database. 425 * @param calendars an array of GregorianCalendar, set to a series of transition times in 426 * consecutive years starting with the current year 427 * @return an RRULE or null if none could be inferred from the calendars 428 */ 429 static RRule inferRRuleFromCalendars(GregorianCalendar[] calendars) { 430 // Let's see if we can make a rule about these 431 GregorianCalendar calendar = calendars[0]; 432 if (calendar == null) return null; 433 int month = calendar.get(Calendar.MONTH); 434 int date = calendar.get(Calendar.DAY_OF_MONTH); 435 int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); 436 int week = calendar.get(Calendar.DAY_OF_WEEK_IN_MONTH); 437 int maxWeek = calendar.getActualMaximum(Calendar.DAY_OF_WEEK_IN_MONTH); 438 boolean dateRule = false; 439 boolean dayOfWeekRule = false; 440 for (int i = 1; i < calendars.length; i++) { 441 GregorianCalendar cal = calendars[i]; 442 if (cal == null) return null; 443 // If it's not the same month, there's no rule 444 if (cal.get(Calendar.MONTH) != month) { 445 return null; 446 } else if (dayOfWeek == cal.get(Calendar.DAY_OF_WEEK)) { 447 // Ok, it seems to be the same day of the week 448 if (dateRule) { 449 return null; 450 } 451 dayOfWeekRule = true; 452 int thisWeek = cal.get(Calendar.DAY_OF_WEEK_IN_MONTH); 453 if (week != thisWeek) { 454 if (week < 0 || week == maxWeek) { 455 int thisMaxWeek = cal.getActualMaximum(Calendar.DAY_OF_WEEK_IN_MONTH); 456 if (thisWeek == thisMaxWeek) { 457 // We'll use -1 (i.e. last) week 458 week = -1; 459 continue; 460 } 461 } 462 return null; 463 } 464 } else if (date == cal.get(Calendar.DAY_OF_MONTH)) { 465 // Maybe the same day of the month? 466 if (dayOfWeekRule) { 467 return null; 468 } 469 dateRule = true; 470 } else { 471 return null; 472 } 473 } 474 475 if (dateRule) { 476 return new RRule(month + 1, date); 477 } 478 // sDayTokens is 0 based (SU = 0); Calendar days of week are 1 based (SU = 1) 479 // iCalendar months are 1 based; Calendar months are 0 based 480 // So we adjust these when building the string 481 return new RRule(month + 1, dayOfWeek, week); 482 } 483 484 /** 485 * Generate an rfc2445 utcOffset from minutes offset from GMT 486 * These look like +0800 or -0100 487 * @param offsetMinutes minutes offset from GMT (east is positive, west is negative 488 * @return a utcOffset 489 */ 490 static String utcOffsetString(int offsetMinutes) { 491 StringBuilder sb = new StringBuilder(); 492 int hours = offsetMinutes / 60; 493 if (hours < 0) { 494 sb.append('-'); 495 hours = 0 - hours; 496 } else { 497 sb.append('+'); 498 } 499 int minutes = offsetMinutes % 60; 500 if (hours < 10) { 501 sb.append('0'); 502 } 503 sb.append(hours); 504 if (minutes < 10) { 505 sb.append('0'); 506 } 507 sb.append(minutes); 508 return sb.toString(); 509 } 510 511 /** 512 * Fill the passed in GregorianCalendars arrays with DST transition information for this and 513 * the following years (based on the length of the arrays) 514 * @param tz the time zone 515 * @param toDaylightCalendars an array of GregorianCalendars, one for each year, representing 516 * the transition to daylight time 517 * @param toStandardCalendars an array of GregorianCalendars, one for each year, representing 518 * the transition to standard time 519 * @return true if transitions could be found for all years, false otherwise 520 */ 521 static boolean getDSTCalendars(TimeZone tz, GregorianCalendar[] toDaylightCalendars, 522 GregorianCalendar[] toStandardCalendars) { 523 // We'll use the length of the arrays to determine how many years to check 524 int maxYears = toDaylightCalendars.length; 525 if (toStandardCalendars.length != maxYears) { 526 return false; 527 } 528 // Get the transitions for this year and the next few years 529 for (int i = 0; i < maxYears; i++) { 530 GregorianCalendar cal = new GregorianCalendar(tz); 531 cal.set(sCurrentYear + i, Calendar.JANUARY, 1, 0, 0, 0); 532 long startTime = cal.getTimeInMillis(); 533 // Calculate end of year; no need to be insanely precise 534 long endOfYearTime = startTime + (365*DAYS) + (DAYS>>2); 535 Date date = new Date(startTime); 536 boolean startInDaylightTime = tz.inDaylightTime(date); 537 // Find the first transition, and store 538 cal = findTransitionDate(tz, startTime, endOfYearTime, startInDaylightTime); 539 if (cal == null) { 540 return false; 541 } else if (startInDaylightTime) { 542 toStandardCalendars[i] = cal; 543 } else { 544 toDaylightCalendars[i] = cal; 545 } 546 // Find the second transition, and store 547 cal = findTransitionDate(tz, startTime, endOfYearTime, !startInDaylightTime); 548 if (cal == null) { 549 return false; 550 } else if (startInDaylightTime) { 551 toDaylightCalendars[i] = cal; 552 } else { 553 toStandardCalendars[i] = cal; 554 } 555 } 556 return true; 557 } 558 559 /** 560 * Write out the STANDARD block of VTIMEZONE and end the VTIMEZONE 561 * @param writer the SimpleIcsWriter we're using 562 * @param tz the time zone 563 * @param offsetString the offset string in VTIMEZONE format (e.g. +0800) 564 * @throws IOException 565 */ 566 static private void writeNoDST(SimpleIcsWriter writer, TimeZone tz, String offsetString) 567 throws IOException { 568 writer.writeTag("BEGIN", "STANDARD"); 569 writer.writeTag("TZOFFSETFROM", offsetString); 570 writer.writeTag("TZOFFSETTO", offsetString); 571 // Might as well use start of epoch for start date 572 writer.writeTag("DTSTART", millisToEasDateTime(0L)); 573 writer.writeTag("END", "STANDARD"); 574 writer.writeTag("END", "VTIMEZONE"); 575 } 576 577 /** Write a VTIMEZONE block for a given TimeZone into a SimpleIcsWriter 578 * @param tz the TimeZone to be used in the conversion 579 * @param writer the SimpleIcsWriter to be used 580 * @throws IOException 581 */ 582 static void timeZoneToVTimezone(TimeZone tz, SimpleIcsWriter writer) 583 throws IOException { 584 // We'll use these regardless of whether there's DST in this time zone or not 585 int rawOffsetMinutes = tz.getRawOffset() / MINUTES; 586 String standardOffsetString = utcOffsetString(rawOffsetMinutes); 587 588 // Preamble for all of our VTIMEZONEs 589 writer.writeTag("BEGIN", "VTIMEZONE"); 590 writer.writeTag("TZID", tz.getID()); 591 writer.writeTag("X-LIC-LOCATION", tz.getDisplayName()); 592 593 // Simplest case is no daylight time 594 if (!tz.useDaylightTime()) { 595 writeNoDST(writer, tz, standardOffsetString); 596 return; 597 } 598 599 int maxYears = 3; 600 GregorianCalendar[] toDaylightCalendars = new GregorianCalendar[maxYears]; 601 GregorianCalendar[] toStandardCalendars = new GregorianCalendar[maxYears]; 602 if (!getDSTCalendars(tz, toDaylightCalendars, toStandardCalendars)) { 603 writeNoDST(writer, tz, standardOffsetString); 604 return; 605 } 606 // Try to find a rule to cover these yeras 607 RRule daylightRule = inferRRuleFromCalendars(toDaylightCalendars); 608 RRule standardRule = inferRRuleFromCalendars(toStandardCalendars); 609 String daylightOffsetString = 610 utcOffsetString(rawOffsetMinutes + (tz.getDSTSavings() / MINUTES)); 611 // We'll use RRULE's if we found both 612 // Otherwise we write the first as DTSTART and the others as RDATE 613 boolean hasRule = daylightRule != null && standardRule != null; 614 615 // Write the DAYLIGHT block 616 writer.writeTag("BEGIN", "DAYLIGHT"); 617 writer.writeTag("TZOFFSETFROM", standardOffsetString); 618 writer.writeTag("TZOFFSETTO", daylightOffsetString); 619 writer.writeTag("DTSTART", 620 transitionMillisToVCalendarTime( 621 toDaylightCalendars[0].getTimeInMillis(), tz, true)); 622 if (hasRule) { 623 writer.writeTag("RRULE", daylightRule.toString()); 624 } else { 625 for (int i = 1; i < maxYears; i++) { 626 writer.writeTag("RDATE", transitionMillisToVCalendarTime( 627 toDaylightCalendars[i].getTimeInMillis(), tz, true)); 628 } 629 } 630 writer.writeTag("END", "DAYLIGHT"); 631 // Write the STANDARD block 632 writer.writeTag("BEGIN", "STANDARD"); 633 writer.writeTag("TZOFFSETFROM", daylightOffsetString); 634 writer.writeTag("TZOFFSETTO", standardOffsetString); 635 writer.writeTag("DTSTART", 636 transitionMillisToVCalendarTime( 637 toStandardCalendars[0].getTimeInMillis(), tz, false)); 638 if (hasRule) { 639 writer.writeTag("RRULE", standardRule.toString()); 640 } else { 641 for (int i = 1; i < maxYears; i++) { 642 writer.writeTag("RDATE", transitionMillisToVCalendarTime( 643 toStandardCalendars[i].getTimeInMillis(), tz, true)); 644 } 645 } 646 writer.writeTag("END", "STANDARD"); 647 // And we're done 648 writer.writeTag("END", "VTIMEZONE"); 649 } 650 651 /** 652 * Find the next transition to occur (i.e. after the current date/time) 653 * @param transitions calendars representing transitions to/from DST 654 * @return millis for the first transition after the current date/time 655 */ 656 static long findNextTransition(long startingMillis, GregorianCalendar[] transitions) { 657 for (GregorianCalendar transition: transitions) { 658 long transitionMillis = transition.getTimeInMillis(); 659 if (transitionMillis > startingMillis) { 660 return transitionMillis; 661 } 662 } 663 return 0; 664 } 665 666 /** 667 * Calculate the Base64 representation of a MSFT TIME_ZONE_INFORMATION structure from a TimeZone 668 * that might be found in an Event. Since the internal representation of the TimeZone is hidden 669 * from us we'll find the DST transitions and build the structure from that information 670 * @param tz the TimeZone 671 * @return the Base64 String representing a Microsoft TIME_ZONE_INFORMATION element 672 */ 673 static String timeZoneToTziStringImpl(TimeZone tz) { 674 String tziString; 675 byte[] tziBytes = new byte[MSFT_TIME_ZONE_SIZE]; 676 int standardBias = - tz.getRawOffset(); 677 standardBias /= 60*SECONDS; 678 setLong(tziBytes, MSFT_TIME_ZONE_BIAS_OFFSET, standardBias); 679 // If this time zone has daylight savings time, we need to do more work 680 if (tz.useDaylightTime()) { 681 GregorianCalendar[] toDaylightCalendars = new GregorianCalendar[3]; 682 GregorianCalendar[] toStandardCalendars = new GregorianCalendar[3]; 683 // See if we can get transitions for a few years; if not, we can't generate DST info 684 // for this time zone 685 if (getDSTCalendars(tz, toDaylightCalendars, toStandardCalendars)) { 686 // Try to find a rule to cover these years 687 RRule daylightRule = inferRRuleFromCalendars(toDaylightCalendars); 688 RRule standardRule = inferRRuleFromCalendars(toStandardCalendars); 689 if ((daylightRule != null) && (daylightRule.type == RRule.RRULE_DAY_WEEK) && 690 (standardRule != null) && (standardRule.type == RRule.RRULE_DAY_WEEK)) { 691 // We need both rules and they have to be DAY/WEEK type 692 // Write month, day of week, week, hour, minute 693 putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET, 694 standardRule, 695 getTrueTransitionHour(toStandardCalendars[0]), 696 getTrueTransitionMinute(toStandardCalendars[0])); 697 putRuleIntoTimeZoneInformation(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET, 698 daylightRule, 699 getTrueTransitionHour(toDaylightCalendars[0]), 700 getTrueTransitionMinute(toDaylightCalendars[0])); 701 } else { 702 // If there's no rule, we'll use the first transition to standard/to daylight 703 // And indicate that it's just for this year... 704 long now = System.currentTimeMillis(); 705 long standardTransition = findNextTransition(now, toStandardCalendars); 706 long daylightTransition = findNextTransition(now, toDaylightCalendars); 707 // If we can't find transitions, we can't do DST 708 if (standardTransition != 0 && daylightTransition != 0) { 709 putTransitionMillisIntoSystemTime(tziBytes, 710 MSFT_TIME_ZONE_STANDARD_DATE_OFFSET, standardTransition); 711 putTransitionMillisIntoSystemTime(tziBytes, 712 MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET, daylightTransition); 713 } 714 } 715 } 716 int dstOffset = tz.getDSTSavings(); 717 setLong(tziBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET, - dstOffset / MINUTES); 718 } 719 byte[] tziEncodedBytes = Base64.encode(tziBytes, Base64.NO_WRAP); 720 tziString = new String(tziEncodedBytes); 721 return tziString; 722 } 723 724 /** 725 * Given a String as directly read from EAS, returns a TimeZone corresponding to that String 726 * @param timeZoneString the String read from the server 727 * @return the TimeZone, or TimeZone.getDefault() if not found 728 */ 729 static public TimeZone tziStringToTimeZone(String timeZoneString) { 730 // If we have this time zone cached, use that value and return 731 TimeZone timeZone = sTimeZoneCache.get(timeZoneString); 732 if (timeZone != null) { 733 if (Eas.USER_LOG) { 734 ExchangeService.log(TAG, " Using cached TimeZone " + timeZone.getID()); 735 } 736 } else { 737 timeZone = tziStringToTimeZoneImpl(timeZoneString); 738 if (timeZone == null) { 739 // If we don't find a match, we just return the current TimeZone. In theory, this 740 // shouldn't be happening... 741 ExchangeService.alwaysLog("TimeZone not found using default: " + timeZoneString); 742 timeZone = TimeZone.getDefault(); 743 } 744 sTimeZoneCache.put(timeZoneString, timeZone); 745 } 746 return timeZone; 747 } 748 749 /** 750 * Given a String as directly read from EAS, tries to find a TimeZone in the database of all 751 * time zones that corresponds to that String. 752 * @param timeZoneString the String read from the server 753 * @return the TimeZone, or null if not found 754 */ 755 static TimeZone tziStringToTimeZoneImpl(String timeZoneString) { 756 TimeZone timeZone = null; 757 // First, we need to decode the base64 string 758 byte[] timeZoneBytes = Base64.decode(timeZoneString, Base64.DEFAULT); 759 760 // Then, we get the bias (similar to a rawOffset); for TimeZone, we need ms 761 // but EAS gives us minutes, so do the conversion. Note that EAS is the bias that's added 762 // to the time zone to reach UTC; our library uses the time from UTC to our time zone, so 763 // we need to change the sign 764 int bias = -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_BIAS_OFFSET) * MINUTES; 765 766 // Get all of the time zones with the bias as a rawOffset; if there aren't any, we return 767 // the default time zone 768 String[] zoneIds = TimeZone.getAvailableIDs(bias); 769 if (zoneIds.length > 0) { 770 // Try to find an existing TimeZone from the data provided by EAS 771 // We start by pulling out the date that standard time begins 772 TimeZoneDate dstEnd = 773 getTimeZoneDateFromSystemTime(timeZoneBytes, MSFT_TIME_ZONE_STANDARD_DATE_OFFSET); 774 if (dstEnd == null) { 775 // If the default time zone is a match 776 TimeZone defaultTimeZone = TimeZone.getDefault(); 777 if (!defaultTimeZone.useDaylightTime() && 778 ArrayUtils.contains(zoneIds, defaultTimeZone.getID())) { 779 if (Eas.USER_LOG) { 780 ExchangeService.log(TAG, "TimeZone without DST found to be default: " + 781 defaultTimeZone.getID()); 782 } 783 return defaultTimeZone; 784 } 785 // In this case, there is no daylight savings time, so the only interesting data 786 // for possible matches is the offset and DST availability; we'll take the first 787 // match for those 788 for (String zoneId: zoneIds) { 789 timeZone = TimeZone.getTimeZone(zoneId); 790 if (!timeZone.useDaylightTime()) { 791 if (Eas.USER_LOG) { 792 ExchangeService.log(TAG, "TimeZone without DST found by offset: " + 793 timeZone.getID()); 794 } 795 return timeZone; 796 } 797 } 798 // None found, return null 799 return null; 800 } else { 801 TimeZoneDate dstStart = getTimeZoneDateFromSystemTime(timeZoneBytes, 802 MSFT_TIME_ZONE_DAYLIGHT_DATE_OFFSET); 803 // See comment above for bias... 804 long dstSavings = 805 -1 * getLong(timeZoneBytes, MSFT_TIME_ZONE_DAYLIGHT_BIAS_OFFSET) * MINUTES; 806 807 // We'll go through each time zone to find one with the same DST transitions and 808 // savings length 809 for (String zoneId: zoneIds) { 810 // Get the TimeZone using the zoneId 811 timeZone = TimeZone.getTimeZone(zoneId); 812 813 // Our strategy here is to check just before and just after the transitions 814 // and see whether the check for daylight time matches the expectation 815 // If both transitions match, then we have a match for the offset and start/end 816 // of dst. That's the best we can do for now, since there's no other info 817 // provided by EAS (i.e. we can't get dynamic transitions, etc.) 818 819 // Check one minute before and after DST start transition 820 long millisAtTransition = getMillisAtTimeZoneDateTransition(timeZone, dstStart); 821 Date before = new Date(millisAtTransition - MINUTES); 822 Date after = new Date(millisAtTransition + MINUTES); 823 if (timeZone.inDaylightTime(before)) continue; 824 if (!timeZone.inDaylightTime(after)) continue; 825 826 // Check one minute before and after DST end transition 827 millisAtTransition = getMillisAtTimeZoneDateTransition(timeZone, dstEnd); 828 // Note that we need to subtract an extra hour here, because we end up with 829 // gaining an hour in the transition BACK to standard time 830 before = new Date(millisAtTransition - (dstSavings + MINUTES)); 831 after = new Date(millisAtTransition + MINUTES); 832 if (!timeZone.inDaylightTime(before)) continue; 833 if (timeZone.inDaylightTime(after)) continue; 834 835 // Check that the savings are the same 836 if (dstSavings != timeZone.getDSTSavings()) continue; 837 return timeZone; 838 } 839 // In this case, there is no daylight savings time, so the only interesting data 840 // is the offset, and we know that all of the zoneId's match; we'll take the first 841 timeZone = TimeZone.getTimeZone(zoneIds[0]); 842 if (Eas.USER_LOG) { 843 ExchangeService.log(TAG, 844 "No TimeZone with correct DST settings; using first: " + 845 timeZone.getID()); 846 } 847 return timeZone; 848 } 849 } 850 return null; 851 } 852 853 static public String convertEmailDateTimeToCalendarDateTime(String date) { 854 // Format for email date strings is 2010-02-23T16:00:00.000Z 855 // Format for calendar date strings is 20100223T160000Z 856 return date.substring(0, 4) + date.substring(5, 7) + date.substring(8, 13) + 857 date.substring(14, 16) + date.substring(17, 19) + 'Z'; 858 } 859 860 static String formatTwo(int num) { 861 if (num <= 12) { 862 return sTwoCharacterNumbers[num]; 863 } else 864 return Integer.toString(num); 865 } 866 867 /** 868 * Generate an EAS formatted date/time string based on GMT. See below for details. 869 */ 870 static public String millisToEasDateTime(long millis) { 871 return millisToEasDateTime(millis, sGmtTimeZone, true); 872 } 873 874 /** 875 * Generate an EAS formatted local date/time string from a time and a time zone. If the final 876 * argument is false, only a date will be returned (e.g. 20100331) 877 * @param millis a time in milliseconds 878 * @param tz a time zone 879 * @param withTime if the time is to be included in the string 880 * @return an EAS formatted string indicating the date (and time) in the given time zone 881 */ 882 static public String millisToEasDateTime(long millis, TimeZone tz, boolean withTime) { 883 StringBuilder sb = new StringBuilder(); 884 GregorianCalendar cal = new GregorianCalendar(tz); 885 cal.setTimeInMillis(millis); 886 sb.append(cal.get(Calendar.YEAR)); 887 sb.append(formatTwo(cal.get(Calendar.MONTH) + 1)); 888 sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH))); 889 if (withTime) { 890 sb.append('T'); 891 sb.append(formatTwo(cal.get(Calendar.HOUR_OF_DAY))); 892 sb.append(formatTwo(cal.get(Calendar.MINUTE))); 893 sb.append(formatTwo(cal.get(Calendar.SECOND))); 894 if (tz == sGmtTimeZone) { 895 sb.append('Z'); 896 } 897 } 898 return sb.toString(); 899 } 900 901 /** 902 * Return the true minute at which a transition occurs 903 * Our transition time should be the in the minute BEFORE the transition 904 * If this minute is 59, set minute to 0 and increment the hour 905 * NOTE: We don't want to add a minute and retrieve minute/hour from the Calendar, because 906 * Calendar time will itself be influenced by the transition! So adding 1 minute to 907 * 01:59 (assume PST->PDT) will become 03:00, which isn't what we want (we want 02:00) 908 * 909 * @param calendar the calendar holding the transition date/time 910 * @return the true minute of the transition 911 */ 912 static int getTrueTransitionMinute(GregorianCalendar calendar) { 913 int minute = calendar.get(Calendar.MINUTE); 914 if (minute == 59) { 915 minute = 0; 916 } 917 return minute; 918 } 919 920 /** 921 * Return the true hour at which a transition occurs 922 * See description for getTrueTransitionMinute, above 923 * @param calendar the calendar holding the transition date/time 924 * @return the true hour of the transition 925 */ 926 static int getTrueTransitionHour(GregorianCalendar calendar) { 927 int hour = calendar.get(Calendar.HOUR_OF_DAY); 928 hour++; 929 if (hour == 24) { 930 hour = 0; 931 } 932 return hour; 933 } 934 935 /** 936 * Generate a date/time string suitable for VTIMEZONE from a transition time in millis 937 * The format is YYYYMMDDTHHMMSS 938 * @param millis a transition time in milliseconds 939 * @param tz a time zone 940 * @param dst whether we're entering daylight time 941 */ 942 static String transitionMillisToVCalendarTime(long millis, TimeZone tz, boolean dst) { 943 StringBuilder sb = new StringBuilder(); 944 GregorianCalendar cal = new GregorianCalendar(tz); 945 cal.setTimeInMillis(millis); 946 sb.append(cal.get(Calendar.YEAR)); 947 sb.append(formatTwo(cal.get(Calendar.MONTH) + 1)); 948 sb.append(formatTwo(cal.get(Calendar.DAY_OF_MONTH))); 949 sb.append('T'); 950 sb.append(formatTwo(getTrueTransitionHour(cal))); 951 sb.append(formatTwo(getTrueTransitionMinute(cal))); 952 sb.append(formatTwo(0)); 953 return sb.toString(); 954 } 955 956 /** 957 * Returns a UTC calendar with year/month/day from local calendar and h/m/s/ms = 0 958 * @param time the time in seconds of an all-day event in local time 959 * @return the time in seconds in UTC 960 */ 961 static public long getUtcAllDayCalendarTime(long time, TimeZone localTimeZone) { 962 return transposeAllDayTime(time, localTimeZone, UTC_TIMEZONE); 963 } 964 965 /** 966 * Returns a local calendar with year/month/day from UTC calendar and h/m/s/ms = 0 967 * @param time the time in seconds of an all-day event in UTC 968 * @return the time in seconds in local time 969 */ 970 static public long getLocalAllDayCalendarTime(long time, TimeZone localTimeZone) { 971 return transposeAllDayTime(time, UTC_TIMEZONE, localTimeZone); 972 } 973 974 static private long transposeAllDayTime(long time, TimeZone fromTimeZone, 975 TimeZone toTimeZone) { 976 GregorianCalendar fromCalendar = new GregorianCalendar(fromTimeZone); 977 fromCalendar.setTimeInMillis(time); 978 GregorianCalendar toCalendar = new GregorianCalendar(toTimeZone); 979 // Set this calendar with correct year, month, and day, but zero hour, minute, and seconds 980 toCalendar.set(fromCalendar.get(GregorianCalendar.YEAR), 981 fromCalendar.get(GregorianCalendar.MONTH), 982 fromCalendar.get(GregorianCalendar.DATE), 0, 0, 0); 983 toCalendar.set(GregorianCalendar.MILLISECOND, 0); 984 return toCalendar.getTimeInMillis(); 985 } 986 987 static void addByDay(StringBuilder rrule, int dow, int wom) { 988 rrule.append(";BYDAY="); 989 boolean addComma = false; 990 for (int i = 0; i < 7; i++) { 991 if ((dow & 1) == 1) { 992 if (addComma) { 993 rrule.append(','); 994 } 995 if (wom > 0) { 996 // 5 = last week -> -1 997 // So -1SU = last sunday 998 rrule.append(wom == 5 ? -1 : wom); 999 } 1000 rrule.append(sDayTokens[i]); 1001 addComma = true; 1002 } 1003 dow >>= 1; 1004 } 1005 } 1006 1007 static void addBySetpos(StringBuilder rrule, int dow, int wom) { 1008 // Indicate the days, but don't use wom in this case (it's used in the BYSETPOS); 1009 addByDay(rrule, dow, 0); 1010 rrule.append(";BYSETPOS="); 1011 rrule.append(wom == 5 ? "-1" : wom); 1012 } 1013 1014 static void addByMonthDay(StringBuilder rrule, int dom) { 1015 // 127 means last day of the month 1016 if (dom == 127) { 1017 dom = -1; 1018 } 1019 rrule.append(";BYMONTHDAY=" + dom); 1020 } 1021 1022 /** 1023 * Generate the String version of the EAS integer for a given BYDAY value in an rrule 1024 * @param dow the BYDAY value of the rrule 1025 * @return the String version of the EAS value of these days 1026 */ 1027 static String generateEasDayOfWeek(String dow) { 1028 int bits = 0; 1029 int bit = 1; 1030 for (String token: sDayTokens) { 1031 // If we can find the day in the dow String, add the bit to our bits value 1032 if (dow.indexOf(token) >= 0) { 1033 bits |= bit; 1034 } 1035 bit <<= 1; 1036 } 1037 return Integer.toString(bits); 1038 } 1039 1040 /** 1041 * Extract the value of a token in an RRULE string 1042 * @param rrule an RRULE string 1043 * @param token a token to look for in the RRULE 1044 * @return the value of that token 1045 */ 1046 static String tokenFromRrule(String rrule, String token) { 1047 int start = rrule.indexOf(token); 1048 if (start < 0) return null; 1049 int len = rrule.length(); 1050 start += token.length(); 1051 int end = start; 1052 char c; 1053 do { 1054 c = rrule.charAt(end++); 1055 if ((c == ';') || (end == len)) { 1056 if (end == len) end++; 1057 return rrule.substring(start, end -1); 1058 } 1059 } while (true); 1060 } 1061 1062 /** 1063 * Reformat an RRULE style UNTIL to an EAS style until 1064 */ 1065 @VisibleForTesting 1066 static String recurrenceUntilToEasUntil(String until) { 1067 // Get a calendar in our local time zone 1068 GregorianCalendar localCalendar = new GregorianCalendar(TimeZone.getDefault()); 1069 // Set the time per GMT time in the 'until' 1070 localCalendar.setTimeInMillis(Utility.parseDateTimeToMillis(until)); 1071 StringBuilder sb = new StringBuilder(); 1072 // Build a string with local year/month/date 1073 sb.append(localCalendar.get(Calendar.YEAR)); 1074 sb.append(formatTwo(localCalendar.get(Calendar.MONTH) + 1)); 1075 sb.append(formatTwo(localCalendar.get(Calendar.DAY_OF_MONTH))); 1076 // EAS ignores the time in 'until'; go figure 1077 sb.append("T000000Z"); 1078 return sb.toString(); 1079 } 1080 1081 /** 1082 * Convenience method to add "count", "interval", and "until" to an EAS calendar stream 1083 * According to EAS docs, OCCURRENCES must always come before INTERVAL 1084 */ 1085 static private void addCountIntervalAndUntil(String rrule, Serializer s) throws IOException { 1086 String count = tokenFromRrule(rrule, "COUNT="); 1087 if (count != null) { 1088 s.data(Tags.CALENDAR_RECURRENCE_OCCURRENCES, count); 1089 } 1090 String interval = tokenFromRrule(rrule, "INTERVAL="); 1091 if (interval != null) { 1092 s.data(Tags.CALENDAR_RECURRENCE_INTERVAL, interval); 1093 } 1094 String until = tokenFromRrule(rrule, "UNTIL="); 1095 if (until != null) { 1096 s.data(Tags.CALENDAR_RECURRENCE_UNTIL, recurrenceUntilToEasUntil(until)); 1097 } 1098 } 1099 1100 static private void addByDay(String byDay, Serializer s) throws IOException { 1101 // This can be 1WE (1st Wednesday) or -1FR (last Friday) 1102 int weekOfMonth = byDay.charAt(0); 1103 String bareByDay; 1104 if (weekOfMonth == '-') { 1105 // -1 is the only legal case (last week) Use "5" for EAS 1106 weekOfMonth = 5; 1107 bareByDay = byDay.substring(2); 1108 } else { 1109 weekOfMonth = weekOfMonth - '0'; 1110 bareByDay = byDay.substring(1); 1111 } 1112 s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, Integer.toString(weekOfMonth)); 1113 s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(bareByDay)); 1114 } 1115 1116 static private void addByDaySetpos(String byDay, String bySetpos, Serializer s) 1117 throws IOException { 1118 int weekOfMonth = bySetpos.charAt(0); 1119 if (weekOfMonth == '-') { 1120 // -1 is the only legal case (last week) Use "5" for EAS 1121 weekOfMonth = 5; 1122 } else { 1123 weekOfMonth = weekOfMonth - '0'; 1124 } 1125 s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, Integer.toString(weekOfMonth)); 1126 s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(byDay)); 1127 } 1128 1129 /** 1130 * Write recurrence information to EAS based on the RRULE in CalendarProvider 1131 * @param rrule the RRULE, from CalendarProvider 1132 * @param startTime, the DTSTART of this Event 1133 * @param s the Serializer we're using to write WBXML data 1134 * @throws IOException 1135 */ 1136 // NOTE: For the moment, we're only parsing recurrence types that are supported by the 1137 // Calendar app UI, which is a subset of possible recurrence types 1138 // This code must be updated when the Calendar adds new functionality 1139 static public void recurrenceFromRrule(String rrule, long startTime, Serializer s) 1140 throws IOException { 1141 if (Eas.USER_LOG) { 1142 ExchangeService.log(TAG, "RRULE: " + rrule); 1143 } 1144 String freq = tokenFromRrule(rrule, "FREQ="); 1145 // If there's no FREQ=X, then we don't write a recurrence 1146 // Note that we duplicate s.start(Tags.CALENDAR_RECURRENCE); s.end(); to prevent the 1147 // possibility of writing out a partial recurrence stanza 1148 if (freq != null) { 1149 if (freq.equals("DAILY")) { 1150 s.start(Tags.CALENDAR_RECURRENCE); 1151 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "0"); 1152 addCountIntervalAndUntil(rrule, s); 1153 s.end(); 1154 } else if (freq.equals("WEEKLY")) { 1155 s.start(Tags.CALENDAR_RECURRENCE); 1156 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "1"); 1157 // Requires a day of week (whereas RRULE does not) 1158 addCountIntervalAndUntil(rrule, s); 1159 String byDay = tokenFromRrule(rrule, "BYDAY="); 1160 if (byDay != null) { 1161 s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, generateEasDayOfWeek(byDay)); 1162 // Find week number (1-4 and 5 for last) 1163 if (byDay.startsWith("-1")) { 1164 s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, "5"); 1165 } else { 1166 char c = byDay.charAt(0); 1167 if (c >= '1' && c <= '4') { 1168 s.data(Tags.CALENDAR_RECURRENCE_WEEKOFMONTH, byDay.substring(0, 1)); 1169 } 1170 } 1171 } 1172 s.end(); 1173 } else if (freq.equals("MONTHLY")) { 1174 String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY="); 1175 if (byMonthDay != null) { 1176 s.start(Tags.CALENDAR_RECURRENCE); 1177 // Special case for last day of month 1178 if (byMonthDay == "-1") { 1179 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "3"); 1180 addCountIntervalAndUntil(rrule, s); 1181 s.data(Tags.CALENDAR_RECURRENCE_DAYOFWEEK, "127"); 1182 } else { 1183 // The nth day of the month 1184 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "2"); 1185 addCountIntervalAndUntil(rrule, s); 1186 s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay); 1187 } 1188 s.end(); 1189 } else { 1190 String byDay = tokenFromRrule(rrule, "BYDAY="); 1191 String bySetpos = tokenFromRrule(rrule, "BYSETPOS="); 1192 if (byDay != null) { 1193 s.start(Tags.CALENDAR_RECURRENCE); 1194 s.data(Tags.CALENDAR_RECURRENCE_TYPE, "3"); 1195 addCountIntervalAndUntil(rrule, s); 1196 if (bySetpos != null) { 1197 addByDaySetpos(byDay, bySetpos, s); 1198 } else { 1199 addByDay(byDay, s); 1200 } 1201 s.end(); 1202 } 1203 } 1204 } else if (freq.equals("YEARLY")) { 1205 String byMonth = tokenFromRrule(rrule, "BYMONTH="); 1206 String byMonthDay = tokenFromRrule(rrule, "BYMONTHDAY="); 1207 String byDay = tokenFromRrule(rrule, "BYDAY="); 1208 if (byMonth == null && byMonthDay == null) { 1209 // Calculate the month and day from the startDate 1210 GregorianCalendar cal = new GregorianCalendar(); 1211 cal.setTimeInMillis(startTime); 1212 cal.setTimeZone(TimeZone.getDefault()); 1213 byMonth = Integer.toString(cal.get(Calendar.MONTH) + 1); 1214 byMonthDay = Integer.toString(cal.get(Calendar.DAY_OF_MONTH)); 1215 } 1216 if (byMonth != null && (byMonthDay != null || byDay != null)) { 1217 s.start(Tags.CALENDAR_RECURRENCE); 1218 s.data(Tags.CALENDAR_RECURRENCE_TYPE, byDay == null ? "5" : "6"); 1219 addCountIntervalAndUntil(rrule, s); 1220 s.data(Tags.CALENDAR_RECURRENCE_MONTHOFYEAR, byMonth); 1221 // Note that both byMonthDay and byDay can't be true in a valid RRULE 1222 if (byMonthDay != null) { 1223 s.data(Tags.CALENDAR_RECURRENCE_DAYOFMONTH, byMonthDay); 1224 } else { 1225 addByDay(byDay, s); 1226 } 1227 s.end(); 1228 } 1229 } 1230 } 1231 } 1232 1233 /** 1234 * Build an RRULE String from EAS recurrence information 1235 * @param type the type of recurrence 1236 * @param occurrences how many recurrences (instances) 1237 * @param interval the interval between recurrences 1238 * @param dow day of the week 1239 * @param dom day of the month 1240 * @param wom week of the month 1241 * @param moy month of the year 1242 * @param until the last recurrence time 1243 * @return a valid RRULE String 1244 */ 1245 static public String rruleFromRecurrence(int type, int occurrences, int interval, int dow, 1246 int dom, int wom, int moy, String until) { 1247 StringBuilder rrule = new StringBuilder("FREQ=" + sTypeToFreq[type]); 1248 // INTERVAL and COUNT 1249 if (occurrences > 0) { 1250 rrule.append(";COUNT=" + occurrences); 1251 } 1252 if (interval > 0) { 1253 rrule.append(";INTERVAL=" + interval); 1254 } 1255 1256 // Days, weeks, months, etc. 1257 switch(type) { 1258 case 0: // DAILY 1259 case 1: // WEEKLY 1260 if (dow > 0) addByDay(rrule, dow, wom); 1261 break; 1262 case 2: // MONTHLY 1263 if (dom > 0) addByMonthDay(rrule, dom); 1264 break; 1265 case 3: // MONTHLY (on the nth day) 1266 // 127 is a special case meaning "last day of the month" 1267 if (dow == 127) { 1268 rrule.append(";BYMONTHDAY=-1"); 1269 // week 5 and dow = weekdays -> last weekday (need BYSETPOS) 1270 } else if (wom == 5 && (dow == EAS_WEEKDAYS || dow == EAS_WEEKENDS)) { 1271 addBySetpos(rrule, dow, wom); 1272 } else if (dow > 0) addByDay(rrule, dow, wom); 1273 break; 1274 case 5: // YEARLY (specific day) 1275 if (dom > 0) addByMonthDay(rrule, dom); 1276 if (moy > 0) { 1277 rrule.append(";BYMONTH=" + moy); 1278 } 1279 break; 1280 case 6: // YEARLY 1281 if (dow > 0) addByDay(rrule, dow, wom); 1282 if (dom > 0) addByMonthDay(rrule, dom); 1283 if (moy > 0) { 1284 rrule.append(";BYMONTH=" + moy); 1285 } 1286 break; 1287 default: 1288 break; 1289 } 1290 1291 // UNTIL comes last 1292 if (until != null) { 1293 rrule.append(";UNTIL=" + until); 1294 } 1295 1296 if (Eas.USER_LOG) { 1297 Log.d(Logging.LOG_TAG, "Created rrule: " + rrule); 1298 } 1299 return rrule.toString(); 1300 } 1301 1302 /** 1303 * Create a Calendar in CalendarProvider to which synced Events will be linked 1304 * @param service the sync service requesting Calendar creation 1305 * @param account the account being synced 1306 * @param mailbox the Exchange mailbox for the calendar 1307 * @return the unique id of the Calendar 1308 */ 1309 static public long createCalendar(EasSyncService service, Account account, Mailbox mailbox) { 1310 // Create a Calendar object 1311 ContentValues cv = new ContentValues(); 1312 // TODO How will this change if the user changes his account display name? 1313 cv.put(Calendars.CALENDAR_DISPLAY_NAME, account.mDisplayName); 1314 cv.put(Calendars.ACCOUNT_NAME, account.mEmailAddress); 1315 cv.put(Calendars.ACCOUNT_TYPE, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE); 1316 cv.put(Calendars.SYNC_EVENTS, 1); 1317 cv.put(Calendars.VISIBLE, 1); 1318 // Don't show attendee status if we're the organizer 1319 cv.put(Calendars.CAN_ORGANIZER_RESPOND, 0); 1320 cv.put(Calendars.CAN_MODIFY_TIME_ZONE, 0); 1321 cv.put(Calendars.MAX_REMINDERS, 1); 1322 cv.put(Calendars.ALLOWED_REMINDERS, ALLOWED_REMINDER_TYPES); 1323 1324 // TODO Coordinate account colors w/ Calendar, if possible 1325 int color = new AccountServiceProxy(service.mContext).getAccountColor(account.mId); 1326 cv.put(Calendars.CALENDAR_COLOR, color); 1327 cv.put(Calendars.CALENDAR_TIME_ZONE, Time.getCurrentTimezone()); 1328 cv.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER); 1329 cv.put(Calendars.OWNER_ACCOUNT, account.mEmailAddress); 1330 1331 Uri uri = service.mContentResolver.insert( 1332 asSyncAdapter(Calendars.CONTENT_URI, account.mEmailAddress, 1333 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE), cv); 1334 // We save the id of the calendar into mSyncStatus 1335 if (uri != null) { 1336 String stringId = uri.getPathSegments().get(1); 1337 mailbox.mSyncStatus = stringId; 1338 return Long.parseLong(stringId); 1339 } 1340 return -1; 1341 } 1342 1343 static Uri asSyncAdapter(Uri uri, String account, String accountType) { 1344 return uri.buildUpon() 1345 .appendQueryParameter(android.provider.CalendarContract.CALLER_IS_SYNCADAPTER, 1346 "true") 1347 .appendQueryParameter(Calendars.ACCOUNT_NAME, account) 1348 .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build(); 1349 } 1350 1351 /** 1352 * Return the uid for an event based on its globalObjId 1353 * @param globalObjId the base64 encoded String provided by EAS 1354 * @return the uid for the calendar event 1355 */ 1356 static public String getUidFromGlobalObjId(String globalObjId) { 1357 StringBuilder sb = new StringBuilder(); 1358 // First get the decoded base64 1359 try { 1360 byte[] idBytes = Base64.decode(globalObjId, Base64.DEFAULT); 1361 String idString = new String(idBytes); 1362 // If the base64 decoded string contains the magic substring: "vCal-Uid", then 1363 // the actual uid is hidden within; the magic substring is never at the start of the 1364 // decoded base64 1365 int index = idString.indexOf("vCal-Uid"); 1366 if (index > 0) { 1367 // The uid starts after "vCal-Uidxxxx", where xxxx are padding 1368 // characters. And it ends before the last character, which is ascii 0 1369 return idString.substring(index + 12, idString.length() - 1); 1370 } else { 1371 // This is an EAS uid. Go through the bytes and write out the hex 1372 // values as characters; this is what we'll need to pass back to EAS 1373 // when responding to the invitation 1374 for (byte b: idBytes) { 1375 Utility.byteToHex(sb, b); 1376 } 1377 return sb.toString(); 1378 } 1379 } catch (RuntimeException e) { 1380 // In the worst of cases (bad format, etc.), we can always return the input 1381 return globalObjId; 1382 } 1383 } 1384 1385 /** 1386 * Get a selfAttendeeStatus from a busy status 1387 * The default here is NONE (i.e. we don't know the status) 1388 * Note that a busy status of FREE must mean NONE as well, since it can't mean declined 1389 * (there would be no event) 1390 * @param busyStatus the busy status, from EAS 1391 * @return the corresponding value for selfAttendeeStatus 1392 */ 1393 static public int attendeeStatusFromBusyStatus(int busyStatus) { 1394 int attendeeStatus; 1395 switch (busyStatus) { 1396 case BUSY_STATUS_BUSY: 1397 attendeeStatus = Attendees.ATTENDEE_STATUS_ACCEPTED; 1398 break; 1399 case BUSY_STATUS_TENTATIVE: 1400 attendeeStatus = Attendees.ATTENDEE_STATUS_TENTATIVE; 1401 break; 1402 case BUSY_STATUS_FREE: 1403 case BUSY_STATUS_OUT_OF_OFFICE: 1404 default: 1405 attendeeStatus = Attendees.ATTENDEE_STATUS_NONE; 1406 } 1407 return attendeeStatus; 1408 } 1409 1410 /** 1411 * Get a selfAttendeeStatus from a response type (EAS 14+) 1412 * The default here is NONE (i.e. we don't know the status), though in theory this can't happen 1413 * @param busyStatus the response status, from EAS 1414 * @return the corresponding value for selfAttendeeStatus 1415 */ 1416 static public int attendeeStatusFromResponseType(int responseType) { 1417 int attendeeStatus; 1418 switch (responseType) { 1419 case RESPONSE_TYPE_NOT_RESPONDED: 1420 attendeeStatus = Attendees.ATTENDEE_STATUS_NONE; 1421 break; 1422 case RESPONSE_TYPE_ACCEPTED: 1423 attendeeStatus = Attendees.ATTENDEE_STATUS_ACCEPTED; 1424 break; 1425 case RESPONSE_TYPE_TENTATIVE: 1426 attendeeStatus = Attendees.ATTENDEE_STATUS_TENTATIVE; 1427 break; 1428 case RESPONSE_TYPE_DECLINED: 1429 attendeeStatus = Attendees.ATTENDEE_STATUS_DECLINED; 1430 break; 1431 default: 1432 attendeeStatus = Attendees.ATTENDEE_STATUS_NONE; 1433 } 1434 return attendeeStatus; 1435 } 1436 1437 /** Get a busy status from a selfAttendeeStatus 1438 * The default here is BUSY 1439 * @param selfAttendeeStatus from CalendarProvider2 1440 * @return the corresponding value of busy status 1441 */ 1442 static public int busyStatusFromAttendeeStatus(int selfAttendeeStatus) { 1443 int busyStatus; 1444 switch (selfAttendeeStatus) { 1445 case Attendees.ATTENDEE_STATUS_DECLINED: 1446 case Attendees.ATTENDEE_STATUS_NONE: 1447 case Attendees.ATTENDEE_STATUS_INVITED: 1448 busyStatus = BUSY_STATUS_FREE; 1449 break; 1450 case Attendees.ATTENDEE_STATUS_TENTATIVE: 1451 busyStatus = BUSY_STATUS_TENTATIVE; 1452 break; 1453 case Attendees.ATTENDEE_STATUS_ACCEPTED: 1454 default: 1455 busyStatus = BUSY_STATUS_BUSY; 1456 break; 1457 } 1458 return busyStatus; 1459 } 1460 1461 static public String buildMessageTextFromEntityValues(Context context, 1462 ContentValues entityValues, StringBuilder sb) { 1463 if (sb == null) { 1464 sb = new StringBuilder(); 1465 } 1466 Resources resources = context.getResources(); 1467 Date date = new Date(entityValues.getAsLong(Events.DTSTART)); 1468 // TODO: Add more detail to message text 1469 // Right now, we're using.. When: Tuesday, March 5th at 2:00pm 1470 // What we're missing is the duration and any recurrence information. So this should be 1471 // more like... When: Tuesdays, starting March 5th from 2:00pm - 3:00pm 1472 // This would require code to build complex strings, and it will have to wait 1473 // For now, we'll just use the meeting_recurring string 1474 1475 boolean allDayEvent = false; 1476 if (entityValues.containsKey(Events.ALL_DAY)) { 1477 Integer ade = entityValues.getAsInteger(Events.ALL_DAY); 1478 allDayEvent = (ade != null) && (ade == 1); 1479 } 1480 boolean recurringEvent = !entityValues.containsKey(Events.ORIGINAL_SYNC_ID) && 1481 entityValues.containsKey(Events.RRULE); 1482 1483 String dateTimeString; 1484 int res; 1485 if (allDayEvent) { 1486 dateTimeString = DateFormat.getDateInstance().format(date); 1487 res = recurringEvent ? R.string.meeting_allday_recurring : R.string.meeting_allday; 1488 } else { 1489 dateTimeString = DateFormat.getDateTimeInstance().format(date); 1490 res = recurringEvent ? R.string.meeting_recurring : R.string.meeting_when; 1491 } 1492 sb.append(resources.getString(res, dateTimeString)); 1493 1494 String location = null; 1495 if (entityValues.containsKey(Events.EVENT_LOCATION)) { 1496 location = entityValues.getAsString(Events.EVENT_LOCATION); 1497 if (!TextUtils.isEmpty(location)) { 1498 sb.append("\n"); 1499 sb.append(resources.getString(R.string.meeting_where, location)); 1500 } 1501 } 1502 // If there's a description for this event, append it 1503 String desc = entityValues.getAsString(Events.DESCRIPTION); 1504 if (desc != null) { 1505 sb.append("\n--\n"); 1506 sb.append(desc); 1507 } 1508 return sb.toString(); 1509 } 1510 1511 /** 1512 * Add an attendee to the ics attachment and the to list of the Message being composed 1513 * @param ics the ics attachment writer 1514 * @param toList the list of addressees for this email 1515 * @param attendeeName the name of the attendee 1516 * @param attendeeEmail the email address of the attendee 1517 * @param messageFlag the flag indicating the action to be indicated by the message 1518 * @param account the sending account of the email 1519 */ 1520 static private void addAttendeeToMessage(SimpleIcsWriter ics, ArrayList<Address> toList, 1521 String attendeeName, String attendeeEmail, int messageFlag, Account account) { 1522 if ((messageFlag & Message.FLAG_OUTGOING_MEETING_REQUEST_MASK) != 0) { 1523 String icalTag = ICALENDAR_ATTENDEE_INVITE; 1524 if ((messageFlag & Message.FLAG_OUTGOING_MEETING_CANCEL) != 0) { 1525 icalTag = ICALENDAR_ATTENDEE_CANCEL; 1526 } 1527 if (attendeeName != null) { 1528 icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(attendeeName); 1529 } 1530 ics.writeTag(icalTag, "MAILTO:" + attendeeEmail); 1531 toList.add(attendeeName == null ? new Address(attendeeEmail) : 1532 new Address(attendeeEmail, attendeeName)); 1533 } else if (attendeeEmail.equalsIgnoreCase(account.mEmailAddress)) { 1534 String icalTag = null; 1535 switch (messageFlag) { 1536 case Message.FLAG_OUTGOING_MEETING_ACCEPT: 1537 icalTag = ICALENDAR_ATTENDEE_ACCEPT; 1538 break; 1539 case Message.FLAG_OUTGOING_MEETING_DECLINE: 1540 icalTag = ICALENDAR_ATTENDEE_DECLINE; 1541 break; 1542 case Message.FLAG_OUTGOING_MEETING_TENTATIVE: 1543 icalTag = ICALENDAR_ATTENDEE_TENTATIVE; 1544 break; 1545 } 1546 if (icalTag != null) { 1547 if (attendeeName != null) { 1548 icalTag += ";CN=" 1549 + SimpleIcsWriter.quoteParamValue(attendeeName); 1550 } 1551 ics.writeTag(icalTag, "MAILTO:" + attendeeEmail); 1552 } 1553 } 1554 } 1555 1556 /** 1557 * Create a Message for an (Event) Entity 1558 * @param entity the Entity for the Event (as might be retrieved by CalendarProvider) 1559 * @param messageFlag the Message.FLAG_XXX constant indicating the type of email to be sent 1560 * @param the unique id of this Event, or null if it can be retrieved from the Event 1561 * @param the user's account 1562 * @return a Message with many fields pre-filled (more later) 1563 */ 1564 static public Message createMessageForEntity(Context context, Entity entity, 1565 int messageFlag, String uid, Account account) { 1566 return createMessageForEntity(context, entity, messageFlag, uid, account, 1567 null /*specifiedAttendee*/); 1568 } 1569 1570 static public EmailContent.Message createMessageForEntity(Context context, Entity entity, 1571 int messageFlag, String uid, Account account, String specifiedAttendee) { 1572 ContentValues entityValues = entity.getEntityValues(); 1573 ArrayList<NamedContentValues> subValues = entity.getSubValues(); 1574 boolean isException = entityValues.containsKey(Events.ORIGINAL_SYNC_ID); 1575 boolean isReply = false; 1576 1577 EmailContent.Message msg = new EmailContent.Message(); 1578 msg.mFlags = messageFlag; 1579 msg.mTimeStamp = System.currentTimeMillis(); 1580 1581 String method; 1582 if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_INVITE) != 0) { 1583 method = "REQUEST"; 1584 } else if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_CANCEL) != 0) { 1585 method = "CANCEL"; 1586 } else { 1587 method = "REPLY"; 1588 isReply = true; 1589 } 1590 1591 try { 1592 // Create our iCalendar writer and start generating tags 1593 SimpleIcsWriter ics = new SimpleIcsWriter(); 1594 ics.writeTag("BEGIN", "VCALENDAR"); 1595 ics.writeTag("METHOD", method); 1596 ics.writeTag("PRODID", "AndroidEmail"); 1597 ics.writeTag("VERSION", "2.0"); 1598 1599 // Our default vcalendar time zone is UTC, but this will change (below) if we're 1600 // sending a recurring event, in which case we use local time 1601 TimeZone vCalendarTimeZone = sGmtTimeZone; 1602 String vCalendarDateSuffix = ""; 1603 1604 // Check for all day event 1605 boolean allDayEvent = false; 1606 if (entityValues.containsKey(Events.ALL_DAY)) { 1607 Integer ade = entityValues.getAsInteger(Events.ALL_DAY); 1608 allDayEvent = (ade != null) && (ade == 1); 1609 if (allDayEvent) { 1610 // Example: DTSTART;VALUE=DATE:20100331 (all day event) 1611 vCalendarDateSuffix = ";VALUE=DATE"; 1612 } 1613 } 1614 1615 // If we're inviting people and the meeting is recurring, we need to send our time zone 1616 // information and make sure to send DTSTART/DTEND in local time (unless, of course, 1617 // this is an all-day event). Recurring, for this purpose, includes exceptions to 1618 // recurring events 1619 if (!isReply && !allDayEvent && 1620 (entityValues.containsKey(Events.RRULE) || 1621 entityValues.containsKey(Events.ORIGINAL_SYNC_ID))) { 1622 vCalendarTimeZone = TimeZone.getDefault(); 1623 // Write the VTIMEZONE block to the writer 1624 timeZoneToVTimezone(vCalendarTimeZone, ics); 1625 // Example: DTSTART;TZID=US/Pacific:20100331T124500 1626 vCalendarDateSuffix = ";TZID=" + vCalendarTimeZone.getID(); 1627 } 1628 1629 ics.writeTag("BEGIN", "VEVENT"); 1630 if (uid == null) { 1631 uid = entityValues.getAsString(Events.SYNC_DATA2); 1632 } 1633 if (uid != null) { 1634 ics.writeTag("UID", uid); 1635 } 1636 1637 if (entityValues.containsKey("DTSTAMP")) { 1638 ics.writeTag("DTSTAMP", entityValues.getAsString("DTSTAMP")); 1639 } else { 1640 ics.writeTag("DTSTAMP", millisToEasDateTime(System.currentTimeMillis())); 1641 } 1642 1643 long startTime = entityValues.getAsLong(Events.DTSTART); 1644 if (startTime != 0) { 1645 ics.writeTag("DTSTART" + vCalendarDateSuffix, 1646 millisToEasDateTime(startTime, vCalendarTimeZone, !allDayEvent)); 1647 } 1648 1649 // If this is an Exception, we send the recurrence-id, which is just the original 1650 // instance time 1651 if (isException) { 1652 long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME); 1653 ics.writeTag("RECURRENCE-ID" + vCalendarDateSuffix, 1654 millisToEasDateTime(originalTime, vCalendarTimeZone, !allDayEvent)); 1655 } 1656 1657 if (!entityValues.containsKey(Events.DURATION)) { 1658 if (entityValues.containsKey(Events.DTEND)) { 1659 ics.writeTag("DTEND" + vCalendarDateSuffix, 1660 millisToEasDateTime( 1661 entityValues.getAsLong(Events.DTEND), vCalendarTimeZone, 1662 !allDayEvent)); 1663 } 1664 } else { 1665 // Convert this into millis and add it to DTSTART for DTEND 1666 // We'll use 1 hour as a default 1667 long durationMillis = HOURS; 1668 Duration duration = new Duration(); 1669 try { 1670 duration.parse(entityValues.getAsString(Events.DURATION)); 1671 durationMillis = duration.getMillis(); 1672 } catch (ParseException e) { 1673 // We'll use the default in this case 1674 } 1675 ics.writeTag("DTEND" + vCalendarDateSuffix, 1676 millisToEasDateTime( 1677 startTime + durationMillis, vCalendarTimeZone, !allDayEvent)); 1678 } 1679 1680 String location = null; 1681 if (entityValues.containsKey(Events.EVENT_LOCATION)) { 1682 location = entityValues.getAsString(Events.EVENT_LOCATION); 1683 ics.writeTag("LOCATION", location); 1684 } 1685 1686 String sequence = entityValues.getAsString(SYNC_VERSION); 1687 if (sequence == null) { 1688 sequence = "0"; 1689 } 1690 1691 // We'll use 0 to mean a meeting invitation 1692 int titleId = 0; 1693 switch (messageFlag) { 1694 case Message.FLAG_OUTGOING_MEETING_INVITE: 1695 if (!sequence.equals("0")) { 1696 titleId = R.string.meeting_updated; 1697 } 1698 break; 1699 case Message.FLAG_OUTGOING_MEETING_ACCEPT: 1700 titleId = R.string.meeting_accepted; 1701 break; 1702 case Message.FLAG_OUTGOING_MEETING_DECLINE: 1703 titleId = R.string.meeting_declined; 1704 break; 1705 case Message.FLAG_OUTGOING_MEETING_TENTATIVE: 1706 titleId = R.string.meeting_tentative; 1707 break; 1708 case Message.FLAG_OUTGOING_MEETING_CANCEL: 1709 titleId = R.string.meeting_canceled; 1710 break; 1711 } 1712 Resources resources = context.getResources(); 1713 String title = entityValues.getAsString(Events.TITLE); 1714 if (title == null) { 1715 title = ""; 1716 } 1717 ics.writeTag("SUMMARY", title); 1718 // For meeting invitations just use the title 1719 if (titleId == 0) { 1720 msg.mSubject = title; 1721 } else { 1722 // Otherwise, use the additional text 1723 msg.mSubject = resources.getString(titleId, title); 1724 } 1725 1726 // Build the text for the message, starting with an initial line describing the 1727 // exception (if this is one) 1728 StringBuilder sb = new StringBuilder(); 1729 if (isException && !isReply) { 1730 // Add the line, depending on whether this is a cancellation or update 1731 Date date = new Date(entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME)); 1732 String dateString = DateFormat.getDateInstance().format(date); 1733 if (titleId == R.string.meeting_canceled) { 1734 sb.append(resources.getString(R.string.exception_cancel, dateString)); 1735 } else { 1736 sb.append(resources.getString(R.string.exception_updated, dateString)); 1737 } 1738 sb.append("\n\n"); 1739 } 1740 String text = 1741 CalendarUtilities.buildMessageTextFromEntityValues(context, entityValues, sb); 1742 1743 if (text.length() > 0) { 1744 ics.writeTag("DESCRIPTION", text); 1745 } 1746 // And store the message text 1747 msg.mText = text; 1748 if (!isReply) { 1749 if (entityValues.containsKey(Events.ALL_DAY)) { 1750 Integer ade = entityValues.getAsInteger(Events.ALL_DAY); 1751 ics.writeTag("X-MICROSOFT-CDO-ALLDAYEVENT", ade == 0 ? "FALSE" : "TRUE"); 1752 } 1753 1754 String rrule = entityValues.getAsString(Events.RRULE); 1755 if (rrule != null) { 1756 ics.writeTag("RRULE", rrule); 1757 } 1758 1759 // If we decide to send alarm information in the meeting request ics file, 1760 // handle it here by looping through the subvalues 1761 } 1762 1763 // Handle attendee data here; determine "to" list and add ATTENDEE tags to ics 1764 String organizerName = null; 1765 String organizerEmail = null; 1766 ArrayList<Address> toList = new ArrayList<Address>(); 1767 for (NamedContentValues ncv: subValues) { 1768 Uri ncvUri = ncv.uri; 1769 ContentValues ncvValues = ncv.values; 1770 if (ncvUri.equals(Attendees.CONTENT_URI)) { 1771 Integer relationship = 1772 ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP); 1773 // If there's no relationship, we can't create this for EAS 1774 // Similarly, we need an attendee email for each invitee 1775 if (relationship != null && 1776 ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) { 1777 // Organizer isn't among attendees in EAS 1778 if (relationship == Attendees.RELATIONSHIP_ORGANIZER) { 1779 organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 1780 organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 1781 continue; 1782 } 1783 String attendeeEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 1784 String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 1785 1786 // This shouldn't be possible, but allow for it 1787 if (attendeeEmail == null) continue; 1788 // If we only want to send to the specifiedAttendee, eliminate others here 1789 if ((specifiedAttendee != null) && 1790 !attendeeEmail.equalsIgnoreCase(specifiedAttendee)) { 1791 continue; 1792 } 1793 1794 addAttendeeToMessage(ics, toList, attendeeName, attendeeEmail, messageFlag, 1795 account); 1796 } 1797 } 1798 } 1799 1800 // Manually add the specifiedAttendee if he wasn't added in the Attendees loop 1801 if (toList.isEmpty() && (specifiedAttendee != null)) { 1802 addAttendeeToMessage(ics, toList, null, specifiedAttendee, messageFlag, account); 1803 } 1804 1805 // Create the organizer tag for ical 1806 if (organizerEmail != null) { 1807 String icalTag = "ORGANIZER"; 1808 // We should be able to find this, assuming the Email is the user's email 1809 // TODO Find this in the account 1810 if (organizerName != null) { 1811 icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(organizerName); 1812 } 1813 ics.writeTag(icalTag, "MAILTO:" + organizerEmail); 1814 if (isReply) { 1815 toList.add(organizerName == null ? new Address(organizerEmail) : 1816 new Address(organizerEmail, organizerName)); 1817 } 1818 } 1819 1820 // If we have no "to" list, we're done 1821 if (toList.isEmpty()) return null; 1822 1823 // Write out the "to" list 1824 Address[] toArray = new Address[toList.size()]; 1825 int i = 0; 1826 for (Address address: toList) { 1827 toArray[i++] = address; 1828 } 1829 msg.mTo = Address.pack(toArray); 1830 1831 ics.writeTag("CLASS", "PUBLIC"); 1832 ics.writeTag("STATUS", (messageFlag == Message.FLAG_OUTGOING_MEETING_CANCEL) ? 1833 "CANCELLED" : "CONFIRMED"); 1834 ics.writeTag("TRANSP", "OPAQUE"); // What Exchange uses 1835 ics.writeTag("PRIORITY", "5"); // 1 to 9, 5 = medium 1836 ics.writeTag("SEQUENCE", sequence); 1837 ics.writeTag("END", "VEVENT"); 1838 ics.writeTag("END", "VCALENDAR"); 1839 1840 // Create the ics attachment using the "content" field 1841 Attachment att = new Attachment(); 1842 att.mContentBytes = ics.getBytes(); 1843 att.mMimeType = "text/calendar; method=" + method; 1844 att.mFileName = "invite.ics"; 1845 att.mSize = att.mContentBytes.length; 1846 // We don't send content-disposition with this attachment 1847 att.mFlags = Attachment.FLAG_ICS_ALTERNATIVE_PART; 1848 1849 // Add the attachment to the message 1850 msg.mAttachments = new ArrayList<Attachment>(); 1851 msg.mAttachments.add(att); 1852 } catch (IOException e) { 1853 Log.w(TAG, "IOException in createMessageForEntity"); 1854 return null; 1855 } 1856 1857 // Return the new Message to caller 1858 return msg; 1859 } 1860 1861 /** 1862 * Create a Message for an Event that can be retrieved from CalendarProvider 1863 * by its unique id 1864 * 1865 * @param cr a content resolver that can be used to query for the Event 1866 * @param eventId the unique id of the Event 1867 * @param messageFlag the Message.FLAG_XXX constant indicating the type of 1868 * email to be sent 1869 * @param the unique id of this Event, or null if it can be retrieved from 1870 * the Event 1871 * @param the user's account 1872 * @param requireAddressees if true (the default), no Message is returned if 1873 * there aren't any addressees; if false, return the Message 1874 * regardless (addressees will be filled in later) 1875 * @return a Message with many fields pre-filled (more later) 1876 * @throws RemoteException if there is an issue retrieving the Event from 1877 * CalendarProvider 1878 */ 1879 static public EmailContent.Message createMessageForEventId(Context context, long eventId, 1880 int messageFlag, String uid, Account account) throws RemoteException { 1881 return createMessageForEventId(context, eventId, messageFlag, uid, account, 1882 null /* specifiedAttendee */); 1883 } 1884 1885 static public EmailContent.Message createMessageForEventId(Context context, long eventId, 1886 int messageFlag, String uid, Account account, String specifiedAttendee) 1887 throws RemoteException { 1888 ContentResolver cr = context.getContentResolver(); 1889 EntityIterator eventIterator = EventsEntity.newEntityIterator(cr.query( 1890 ContentUris.withAppendedId(Events.CONTENT_URI, eventId), null, null, null, null), 1891 cr); 1892 try { 1893 while (eventIterator.hasNext()) { 1894 Entity entity = eventIterator.next(); 1895 return createMessageForEntity(context, entity, messageFlag, uid, account, 1896 specifiedAttendee); 1897 } 1898 } finally { 1899 eventIterator.close(); 1900 } 1901 return null; 1902 } 1903 1904 /** 1905 * Return a boolean value for an integer ContentValues column 1906 * @param values a ContentValues object 1907 * @param columnName the name of a column to be found in the ContentValues 1908 * @return a boolean representation of the value of columnName in values; null and 0 = false, 1909 * other integers = true 1910 */ 1911 static public boolean getIntegerValueAsBoolean(ContentValues values, String columnName) { 1912 Integer intValue = values.getAsInteger(columnName); 1913 return (intValue != null && intValue != 0); 1914 } 1915 } 1916