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) 195 static int getLong(byte[] bytes, int offset) { 196 return (bytes[offset++] & 0xFF) | ((bytes[offset++] & 0xFF) << 8) | 197 ((bytes[offset++] & 0xFF) << 16) | ((bytes[offset] & 0xFF) << 24); 198 } 199 200 // Put a 4-byte long into a byte array (little endian) 201 static void setLong(byte[] bytes, int offset, int value) { 202 bytes[offset++] = (byte) (value & 0xFF); 203 bytes[offset++] = (byte) ((value >> 8) & 0xFF); 204 bytes[offset++] = (byte) ((value >> 16) & 0xFF); 205 bytes[offset] = (byte) ((value >> 24) & 0xFF); 206 } 207 208 // Return a 2-byte word from a byte array (little endian) 209 static int getWord(byte[] bytes, int offset) { 210 return (bytes[offset++] & 0xFF) | ((bytes[offset] & 0xFF) << 8); 211 } 212 213 // Put a 2-byte word into a byte array (little endian) 214 static void setWord(byte[] bytes, int offset, int value) { 215 bytes[offset++] = (byte) (value & 0xFF); 216 bytes[offset] = (byte) ((value >> 8) & 0xFF); 217 } 218 219 // 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 232 static void clearTimeZoneCache() { 233 sTimeZoneCache.clear(); 234 } 235 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) 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 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 */ 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 */ 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 */ 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 */ 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 */ 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 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 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 */ 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 */ 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 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 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 1017 static public long getLocalAllDayCalendarTime(long time, TimeZone localTimeZone) { 1018 return transposeAllDayTime(time, UTC_TIMEZONE, localTimeZone); 1019 } 1020 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 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 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 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 */ 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 */ 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 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 */ 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 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 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 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 */ 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 */ 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 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 */ 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 */ 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 */ 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 */ 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 1510 /** Get a busy status from event availability 1511 * The default here is TENTATIVE 1512 * @param availability from CalendarProvider2 1513 * @return the corresponding value of busy status 1514 */ 1515 static public int busyStatusFromAvailability(int availability) { 1516 int busyStatus; 1517 switch (availability) { 1518 case Events.AVAILABILITY_BUSY: 1519 busyStatus = BUSY_STATUS_BUSY; 1520 break; 1521 case Events.AVAILABILITY_FREE: 1522 busyStatus = BUSY_STATUS_FREE; 1523 break; 1524 case Events.AVAILABILITY_TENTATIVE: 1525 default: 1526 busyStatus = BUSY_STATUS_TENTATIVE; 1527 break; 1528 } 1529 return busyStatus; 1530 } 1531 1532 /** Get an event availability from busy status 1533 * The default here is TENTATIVE 1534 * @param busyStatus from CalendarProvider2 1535 * @return the corresponding availability value 1536 */ 1537 static public int availabilityFromBusyStatus(int busyStatus) { 1538 int availability; 1539 switch (busyStatus) { 1540 case BUSY_STATUS_BUSY: 1541 availability = Events.AVAILABILITY_BUSY; 1542 break; 1543 case BUSY_STATUS_FREE: 1544 availability = Events.AVAILABILITY_FREE; 1545 break; 1546 case BUSY_STATUS_TENTATIVE: 1547 default: 1548 availability = Events.AVAILABILITY_TENTATIVE; 1549 break; 1550 } 1551 return availability; 1552 } 1553 1554 static public String buildMessageTextFromEntityValues(Context context, 1555 ContentValues entityValues, StringBuilder sb) { 1556 if (sb == null) { 1557 sb = new StringBuilder(); 1558 } 1559 Resources resources = context.getResources(); 1560 // TODO: Add more detail to message text 1561 // Right now, we're using.. When: Tuesday, March 5th at 2:00pm 1562 // What we're missing is the duration and any recurrence information. So this should be 1563 // more like... When: Tuesdays, starting March 5th from 2:00pm - 3:00pm 1564 // This would require code to build complex strings, and it will have to wait 1565 // For now, we'll just use the meeting_recurring string 1566 1567 boolean allDayEvent = false; 1568 if (entityValues.containsKey(Events.ALL_DAY)) { 1569 Integer ade = entityValues.getAsInteger(Events.ALL_DAY); 1570 allDayEvent = (ade != null) && (ade == 1); 1571 } 1572 boolean recurringEvent = !entityValues.containsKey(Events.ORIGINAL_SYNC_ID) && 1573 entityValues.containsKey(Events.RRULE); 1574 1575 String dateTimeString; 1576 int res; 1577 long startTime = entityValues.getAsLong(Events.DTSTART); 1578 if (allDayEvent) { 1579 Date date = new Date(getLocalAllDayCalendarTime(startTime, TimeZone.getDefault())); 1580 dateTimeString = DateFormat.getDateInstance().format(date); 1581 res = recurringEvent ? R.string.meeting_allday_recurring : R.string.meeting_allday; 1582 } else { 1583 dateTimeString = DateFormat.getDateTimeInstance().format(new Date(startTime)); 1584 res = recurringEvent ? R.string.meeting_recurring : R.string.meeting_when; 1585 } 1586 sb.append(resources.getString(res, dateTimeString)); 1587 1588 String location = null; 1589 if (entityValues.containsKey(Events.EVENT_LOCATION)) { 1590 location = entityValues.getAsString(Events.EVENT_LOCATION); 1591 if (!TextUtils.isEmpty(location)) { 1592 sb.append("\n"); 1593 sb.append(resources.getString(R.string.meeting_where, location)); 1594 } 1595 } 1596 // If there's a description for this event, append it 1597 String desc = entityValues.getAsString(Events.DESCRIPTION); 1598 if (desc != null) { 1599 sb.append("\n--\n"); 1600 sb.append(desc); 1601 } 1602 return sb.toString(); 1603 } 1604 1605 /** 1606 * Add an attendee to the ics attachment and the to list of the Message being composed 1607 * @param ics the ics attachment writer 1608 * @param toList the list of addressees for this email 1609 * @param attendeeName the name of the attendee 1610 * @param attendeeEmail the email address of the attendee 1611 * @param messageFlag the flag indicating the action to be indicated by the message 1612 * @param account the sending account of the email 1613 */ 1614 static private void addAttendeeToMessage(SimpleIcsWriter ics, ArrayList<Address> toList, 1615 String attendeeName, String attendeeEmail, int messageFlag, Account account) { 1616 if ((messageFlag & Message.FLAG_OUTGOING_MEETING_REQUEST_MASK) != 0) { 1617 String icalTag = ICALENDAR_ATTENDEE_INVITE; 1618 if ((messageFlag & Message.FLAG_OUTGOING_MEETING_CANCEL) != 0) { 1619 icalTag = ICALENDAR_ATTENDEE_CANCEL; 1620 } 1621 if (attendeeName != null) { 1622 icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(attendeeName); 1623 } 1624 ics.writeTag(icalTag, "MAILTO:" + attendeeEmail); 1625 toList.add(attendeeName == null ? new Address(attendeeEmail) : 1626 new Address(attendeeEmail, attendeeName)); 1627 } else if (attendeeEmail.equalsIgnoreCase(account.mEmailAddress)) { 1628 String icalTag = null; 1629 switch (messageFlag) { 1630 case Message.FLAG_OUTGOING_MEETING_ACCEPT: 1631 icalTag = ICALENDAR_ATTENDEE_ACCEPT; 1632 break; 1633 case Message.FLAG_OUTGOING_MEETING_DECLINE: 1634 icalTag = ICALENDAR_ATTENDEE_DECLINE; 1635 break; 1636 case Message.FLAG_OUTGOING_MEETING_TENTATIVE: 1637 icalTag = ICALENDAR_ATTENDEE_TENTATIVE; 1638 break; 1639 } 1640 if (icalTag != null) { 1641 if (attendeeName != null) { 1642 icalTag += ";CN=" 1643 + SimpleIcsWriter.quoteParamValue(attendeeName); 1644 } 1645 ics.writeTag(icalTag, "MAILTO:" + attendeeEmail); 1646 } 1647 } 1648 } 1649 1650 /** 1651 * Create a Message for an (Event) Entity 1652 * @param entity the Entity for the Event (as might be retrieved by CalendarProvider) 1653 * @param messageFlag the Message.FLAG_XXX constant indicating the type of email to be sent 1654 * @param the unique id of this Event, or null if it can be retrieved from the Event 1655 * @param the user's account 1656 * @return a Message with many fields pre-filled (more later) 1657 */ 1658 static public Message createMessageForEntity(Context context, Entity entity, 1659 int messageFlag, String uid, Account account) { 1660 return createMessageForEntity(context, entity, messageFlag, uid, account, 1661 null /*specifiedAttendee*/); 1662 } 1663 1664 static public EmailContent.Message createMessageForEntity(Context context, Entity entity, 1665 int messageFlag, String uid, Account account, String specifiedAttendee) { 1666 ContentValues entityValues = entity.getEntityValues(); 1667 ArrayList<NamedContentValues> subValues = entity.getSubValues(); 1668 boolean isException = entityValues.containsKey(Events.ORIGINAL_SYNC_ID); 1669 boolean isReply = false; 1670 1671 EmailContent.Message msg = new EmailContent.Message(); 1672 msg.mFlags = messageFlag; 1673 msg.mTimeStamp = System.currentTimeMillis(); 1674 1675 String method; 1676 if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_INVITE) != 0) { 1677 method = "REQUEST"; 1678 } else if ((messageFlag & EmailContent.Message.FLAG_OUTGOING_MEETING_CANCEL) != 0) { 1679 method = "CANCEL"; 1680 } else { 1681 method = "REPLY"; 1682 isReply = true; 1683 } 1684 1685 try { 1686 // Create our iCalendar writer and start generating tags 1687 SimpleIcsWriter ics = new SimpleIcsWriter(); 1688 ics.writeTag("BEGIN", "VCALENDAR"); 1689 ics.writeTag("METHOD", method); 1690 ics.writeTag("PRODID", "AndroidEmail"); 1691 ics.writeTag("VERSION", "2.0"); 1692 1693 // Our default vcalendar time zone is UTC, but this will change (below) if we're 1694 // sending a recurring event, in which case we use local time 1695 TimeZone vCalendarTimeZone = sGmtTimeZone; 1696 String vCalendarDateSuffix = ""; 1697 1698 // Check for all day event 1699 boolean allDayEvent = false; 1700 if (entityValues.containsKey(Events.ALL_DAY)) { 1701 Integer ade = entityValues.getAsInteger(Events.ALL_DAY); 1702 allDayEvent = (ade != null) && (ade == 1); 1703 if (allDayEvent) { 1704 // Example: DTSTART;VALUE=DATE:20100331 (all day event) 1705 vCalendarDateSuffix = ";VALUE=DATE"; 1706 } 1707 } 1708 1709 // If we're inviting people and the meeting is recurring, we need to send our time zone 1710 // information and make sure to send DTSTART/DTEND in local time (unless, of course, 1711 // this is an all-day event). Recurring, for this purpose, includes exceptions to 1712 // recurring events 1713 if (!isReply && !allDayEvent && 1714 (entityValues.containsKey(Events.RRULE) || 1715 entityValues.containsKey(Events.ORIGINAL_SYNC_ID))) { 1716 vCalendarTimeZone = TimeZone.getDefault(); 1717 // Write the VTIMEZONE block to the writer 1718 timeZoneToVTimezone(vCalendarTimeZone, ics); 1719 // Example: DTSTART;TZID=US/Pacific:20100331T124500 1720 vCalendarDateSuffix = ";TZID=" + vCalendarTimeZone.getID(); 1721 } 1722 1723 ics.writeTag("BEGIN", "VEVENT"); 1724 if (uid == null) { 1725 uid = entityValues.getAsString(Events.SYNC_DATA2); 1726 } 1727 if (uid != null) { 1728 ics.writeTag("UID", uid); 1729 } 1730 1731 if (entityValues.containsKey("DTSTAMP")) { 1732 ics.writeTag("DTSTAMP", entityValues.getAsString("DTSTAMP")); 1733 } else { 1734 ics.writeTag("DTSTAMP", millisToEasDateTime(System.currentTimeMillis())); 1735 } 1736 1737 long startTime = entityValues.getAsLong(Events.DTSTART); 1738 if (startTime != 0) { 1739 ics.writeTag("DTSTART" + vCalendarDateSuffix, 1740 millisToEasDateTime(startTime, vCalendarTimeZone, !allDayEvent)); 1741 } 1742 1743 // If this is an Exception, we send the recurrence-id, which is just the original 1744 // instance time 1745 if (isException) { 1746 long originalTime = entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME); 1747 ics.writeTag("RECURRENCE-ID" + vCalendarDateSuffix, 1748 millisToEasDateTime(originalTime, vCalendarTimeZone, !allDayEvent)); 1749 } 1750 1751 if (!entityValues.containsKey(Events.DURATION)) { 1752 if (entityValues.containsKey(Events.DTEND)) { 1753 ics.writeTag("DTEND" + vCalendarDateSuffix, 1754 millisToEasDateTime( 1755 entityValues.getAsLong(Events.DTEND), vCalendarTimeZone, 1756 !allDayEvent)); 1757 } 1758 } else { 1759 // Convert this into millis and add it to DTSTART for DTEND 1760 // We'll use 1 hour as a default 1761 long durationMillis = HOURS; 1762 Duration duration = new Duration(); 1763 try { 1764 duration.parse(entityValues.getAsString(Events.DURATION)); 1765 durationMillis = duration.getMillis(); 1766 } catch (DateException e) { 1767 // We'll use the default in this case 1768 } 1769 ics.writeTag("DTEND" + vCalendarDateSuffix, 1770 millisToEasDateTime( 1771 startTime + durationMillis, vCalendarTimeZone, !allDayEvent)); 1772 } 1773 1774 String location = null; 1775 if (entityValues.containsKey(Events.EVENT_LOCATION)) { 1776 location = entityValues.getAsString(Events.EVENT_LOCATION); 1777 ics.writeTag("LOCATION", location); 1778 } 1779 1780 String sequence = entityValues.getAsString(SYNC_VERSION); 1781 if (sequence == null) { 1782 sequence = "0"; 1783 } 1784 1785 // We'll use 0 to mean a meeting invitation 1786 int titleId = 0; 1787 switch (messageFlag) { 1788 case Message.FLAG_OUTGOING_MEETING_INVITE: 1789 if (!sequence.equals("0")) { 1790 titleId = R.string.meeting_updated; 1791 } 1792 break; 1793 case Message.FLAG_OUTGOING_MEETING_ACCEPT: 1794 titleId = R.string.meeting_accepted; 1795 break; 1796 case Message.FLAG_OUTGOING_MEETING_DECLINE: 1797 titleId = R.string.meeting_declined; 1798 break; 1799 case Message.FLAG_OUTGOING_MEETING_TENTATIVE: 1800 titleId = R.string.meeting_tentative; 1801 break; 1802 case Message.FLAG_OUTGOING_MEETING_CANCEL: 1803 titleId = R.string.meeting_canceled; 1804 break; 1805 } 1806 Resources resources = context.getResources(); 1807 String title = entityValues.getAsString(Events.TITLE); 1808 if (title == null) { 1809 title = ""; 1810 } 1811 ics.writeTag("SUMMARY", title); 1812 // For meeting invitations just use the title 1813 if (titleId == 0) { 1814 msg.mSubject = title; 1815 } else { 1816 // Otherwise, use the additional text 1817 msg.mSubject = resources.getString(titleId, title); 1818 } 1819 1820 // Build the text for the message, starting with an initial line describing the 1821 // exception (if this is one) 1822 StringBuilder sb = new StringBuilder(); 1823 if (isException && !isReply) { 1824 // Add the line, depending on whether this is a cancellation or update 1825 Date date = new Date(entityValues.getAsLong(Events.ORIGINAL_INSTANCE_TIME)); 1826 String dateString = DateFormat.getDateInstance().format(date); 1827 if (titleId == R.string.meeting_canceled) { 1828 sb.append(resources.getString(R.string.exception_cancel, dateString)); 1829 } else { 1830 sb.append(resources.getString(R.string.exception_updated, dateString)); 1831 } 1832 sb.append("\n\n"); 1833 } 1834 String text = 1835 CalendarUtilities.buildMessageTextFromEntityValues(context, entityValues, sb); 1836 1837 if (text.length() > 0) { 1838 ics.writeTag("DESCRIPTION", text); 1839 } 1840 // And store the message text 1841 msg.mText = text; 1842 if (!isReply) { 1843 if (entityValues.containsKey(Events.ALL_DAY)) { 1844 Integer ade = entityValues.getAsInteger(Events.ALL_DAY); 1845 ics.writeTag("X-MICROSOFT-CDO-ALLDAYEVENT", ade == 0 ? "FALSE" : "TRUE"); 1846 } 1847 1848 String rrule = entityValues.getAsString(Events.RRULE); 1849 if (rrule != null) { 1850 ics.writeTag("RRULE", rrule); 1851 } 1852 1853 // If we decide to send alarm information in the meeting request ics file, 1854 // handle it here by looping through the subvalues 1855 } 1856 1857 // Handle attendee data here; determine "to" list and add ATTENDEE tags to ics 1858 String organizerName = null; 1859 String organizerEmail = null; 1860 ArrayList<Address> toList = new ArrayList<Address>(); 1861 for (NamedContentValues ncv: subValues) { 1862 Uri ncvUri = ncv.uri; 1863 ContentValues ncvValues = ncv.values; 1864 if (ncvUri.equals(Attendees.CONTENT_URI)) { 1865 Integer relationship = 1866 ncvValues.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP); 1867 // If there's no relationship, we can't create this for EAS 1868 // Similarly, we need an attendee email for each invitee 1869 if (relationship != null && 1870 ncvValues.containsKey(Attendees.ATTENDEE_EMAIL)) { 1871 // Organizer isn't among attendees in EAS 1872 if (relationship == Attendees.RELATIONSHIP_ORGANIZER) { 1873 organizerName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 1874 organizerEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 1875 continue; 1876 } 1877 String attendeeEmail = ncvValues.getAsString(Attendees.ATTENDEE_EMAIL); 1878 String attendeeName = ncvValues.getAsString(Attendees.ATTENDEE_NAME); 1879 1880 // This shouldn't be possible, but allow for it 1881 if (attendeeEmail == null) continue; 1882 // If we only want to send to the specifiedAttendee, eliminate others here 1883 if ((specifiedAttendee != null) && 1884 !attendeeEmail.equalsIgnoreCase(specifiedAttendee)) { 1885 continue; 1886 } 1887 1888 addAttendeeToMessage(ics, toList, attendeeName, attendeeEmail, messageFlag, 1889 account); 1890 } 1891 } 1892 } 1893 1894 // Manually add the specifiedAttendee if he wasn't added in the Attendees loop 1895 if (toList.isEmpty() && (specifiedAttendee != null)) { 1896 addAttendeeToMessage(ics, toList, null, specifiedAttendee, messageFlag, account); 1897 } 1898 1899 // Create the organizer tag for ical 1900 if (organizerEmail != null) { 1901 String icalTag = "ORGANIZER"; 1902 // We should be able to find this, assuming the Email is the user's email 1903 // TODO Find this in the account 1904 if (organizerName != null) { 1905 icalTag += ";CN=" + SimpleIcsWriter.quoteParamValue(organizerName); 1906 } 1907 ics.writeTag(icalTag, "MAILTO:" + organizerEmail); 1908 if (isReply) { 1909 toList.add(organizerName == null ? new Address(organizerEmail) : 1910 new Address(organizerEmail, organizerName)); 1911 } 1912 } 1913 1914 // If we have no "to" list, we're done 1915 if (toList.isEmpty()) return null; 1916 1917 // Write out the "to" list 1918 Address[] toArray = new Address[toList.size()]; 1919 int i = 0; 1920 for (Address address: toList) { 1921 toArray[i++] = address; 1922 } 1923 msg.mTo = Address.pack(toArray); 1924 1925 ics.writeTag("CLASS", "PUBLIC"); 1926 ics.writeTag("STATUS", (messageFlag == Message.FLAG_OUTGOING_MEETING_CANCEL) ? 1927 "CANCELLED" : "CONFIRMED"); 1928 ics.writeTag("TRANSP", "OPAQUE"); // What Exchange uses 1929 ics.writeTag("PRIORITY", "5"); // 1 to 9, 5 = medium 1930 ics.writeTag("SEQUENCE", sequence); 1931 ics.writeTag("END", "VEVENT"); 1932 ics.writeTag("END", "VCALENDAR"); 1933 1934 // Create the ics attachment using the "content" field 1935 Attachment att = new Attachment(); 1936 att.mContentBytes = ics.getBytes(); 1937 att.mMimeType = "text/calendar; method=" + method; 1938 att.mFileName = "invite.ics"; 1939 att.mSize = att.mContentBytes.length; 1940 // We don't send content-disposition with this attachment 1941 att.mFlags = Attachment.FLAG_ICS_ALTERNATIVE_PART; 1942 1943 // Add the attachment to the message 1944 msg.mAttachments = new ArrayList<Attachment>(); 1945 msg.mAttachments.add(att); 1946 } catch (IOException e) { 1947 Log.w(TAG, "IOException in createMessageForEntity"); 1948 return null; 1949 } 1950 1951 // Return the new Message to caller 1952 return msg; 1953 } 1954 1955 /** 1956 * Create a Message for an Event that can be retrieved from CalendarProvider 1957 * by its unique id 1958 * 1959 * @param cr a content resolver that can be used to query for the Event 1960 * @param eventId the unique id of the Event 1961 * @param messageFlag the Message.FLAG_XXX constant indicating the type of 1962 * email to be sent 1963 * @param the unique id of this Event, or null if it can be retrieved from 1964 * the Event 1965 * @param the user's account 1966 * @param requireAddressees if true (the default), no Message is returned if 1967 * there aren't any addressees; if false, return the Message 1968 * regardless (addressees will be filled in later) 1969 * @return a Message with many fields pre-filled (more later) 1970 * @throws RemoteException if there is an issue retrieving the Event from 1971 * CalendarProvider 1972 */ 1973 static public EmailContent.Message createMessageForEventId(Context context, long eventId, 1974 int messageFlag, String uid, Account account) throws RemoteException { 1975 return createMessageForEventId(context, eventId, messageFlag, uid, account, 1976 null /* specifiedAttendee */); 1977 } 1978 1979 static public EmailContent.Message createMessageForEventId(Context context, long eventId, 1980 int messageFlag, String uid, Account account, String specifiedAttendee) 1981 throws RemoteException { 1982 ContentResolver cr = context.getContentResolver(); 1983 EntityIterator eventIterator = EventsEntity.newEntityIterator(cr.query( 1984 ContentUris.withAppendedId(Events.CONTENT_URI, eventId), null, null, null, null), 1985 cr); 1986 try { 1987 while (eventIterator.hasNext()) { 1988 Entity entity = eventIterator.next(); 1989 return createMessageForEntity(context, entity, messageFlag, uid, account, 1990 specifiedAttendee); 1991 } 1992 } finally { 1993 eventIterator.close(); 1994 } 1995 return null; 1996 } 1997 1998 /** 1999 * Return a boolean value for an integer ContentValues column 2000 * @param values a ContentValues object 2001 * @param columnName the name of a column to be found in the ContentValues 2002 * @return a boolean representation of the value of columnName in values; null and 0 = false, 2003 * other integers = true 2004 */ 2005 static public boolean getIntegerValueAsBoolean(ContentValues values, String columnName) { 2006 Integer intValue = values.getAsInteger(columnName); 2007 return (intValue != null && intValue != 0); 2008 } 2009 } 2010