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