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