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) getLong(byte[] bytes, int offset)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) setLong(byte[] bytes, int offset, int value)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) getWord(byte[] bytes, int offset)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) setWord(byte[] bytes, int offset, int value)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 clearTimeZoneCache()218 static void clearTimeZoneCache() { 219 sTimeZoneCache.clear(); 220 } 221 putRuleIntoTimeZoneInformation(byte[] bytes, int offset, RRule rrule, int hour, int minute)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) putTransitionMillisIntoSystemTime(byte[] bytes, int offset, long millis)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 getTimeZoneDateFromSystemTime(byte[] bytes, int 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 */ getMillisAtTimeZoneDateTransition(TimeZone timeZone, TimeZoneDate tzd)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 */ findTransitionDate(TimeZone tz, long startTime, long endTime, boolean startInDaylightTime)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 */ timeZoneToTziString(TimeZone tz)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 */ RRule(int _month, int _date)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 */ RRule(int _month, int _dayOfWeek, int _week)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 toString()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 */ inferRRuleFromCalendars(GregorianCalendar[] calendars)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 */ utcOffsetString(int offsetMinutes)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 */ getDSTCalendars(TimeZone tz, GregorianCalendar[] toDaylightCalendars, GregorianCalendar[] toStandardCalendars)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 */ writeNoDST(SimpleIcsWriter writer, TimeZone tz, String offsetString)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 */ timeZoneToVTimezone(TimeZone tz, SimpleIcsWriter writer)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 */ findNextTransition(long startingMillis, GregorianCalendar[] transitions)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 */ timeZoneToTziStringImpl(TimeZone tz)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 */ tziStringToTimeZone(String timeZoneString)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 */ tziStringToTimeZoneImpl(String timeZoneString)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 convertEmailDateTimeToCalendarDateTime(String date)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 formatTwo(int num)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 */ millisToEasDateTime(long millis)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 */ millisToEasDateTime(long millis, TimeZone tz, boolean withTime)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 */ getTrueTransitionMinute(GregorianCalendar calendar)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 */ getTrueTransitionHour(GregorianCalendar calendar)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 */ transitionMillisToVCalendarTime(long millis, TimeZone tz, boolean dst)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 */ getUtcAllDayCalendarTime(long time, TimeZone localTimeZone)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 */ getLocalAllDayCalendarTime(long time, TimeZone localTimeZone)970 static public long getLocalAllDayCalendarTime(long time, TimeZone localTimeZone) { 971 return transposeAllDayTime(time, UTC_TIMEZONE, localTimeZone); 972 } 973 transposeAllDayTime(long time, TimeZone fromTimeZone, TimeZone toTimeZone)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 addByDay(StringBuilder rrule, int dow, int wom)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 addBySetpos(StringBuilder rrule, int dow, int wom)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 addByMonthDay(StringBuilder rrule, int dom)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 */ generateEasDayOfWeek(String dow)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 */ tokenFromRrule(String rrule, String token)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 recurrenceUntilToEasUntil(String until)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 */ addCountIntervalAndUntil(String rrule, Serializer s)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 addByDay(String byDay, Serializer s)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 addByDaySetpos(String byDay, String bySetpos, Serializer s)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 recurrenceFromRrule(String rrule, long startTime, Serializer s)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 */ rruleFromRecurrence(int type, int occurrences, int interval, int dow, int dom, int wom, int moy, String until)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 */ createCalendar(EasSyncService service, Account account, Mailbox mailbox)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 asSyncAdapter(Uri uri, String account, String accountType)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 */ getUidFromGlobalObjId(String globalObjId)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 */ attendeeStatusFromBusyStatus(int busyStatus)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 */ attendeeStatusFromResponseType(int responseType)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 */ busyStatusFromAttendeeStatus(int selfAttendeeStatus)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 buildMessageTextFromEntityValues(Context context, ContentValues entityValues, StringBuilder sb)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 */ addAttendeeToMessage(SimpleIcsWriter ics, ArrayList<Address> toList, String attendeeName, String attendeeEmail, int messageFlag, Account account)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 */ createMessageForEntity(Context context, Entity entity, int messageFlag, String uid, Account account)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 createMessageForEntity(Context context, Entity entity, int messageFlag, String uid, Account account, String specifiedAttendee)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 */ createMessageForEventId(Context context, long eventId, int messageFlag, String uid, Account account)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 createMessageForEventId(Context context, long eventId, int messageFlag, String uid, Account account, String specifiedAttendee)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 */ getIntegerValueAsBoolean(ContentValues values, String columnName)1911 static public boolean getIntegerValueAsBoolean(ContentValues values, String columnName) { 1912 Integer intValue = values.getAsInteger(columnName); 1913 return (intValue != null && intValue != 0); 1914 } 1915 } 1916