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