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