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