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