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