1 /* 2 * Copyright (C) 2006 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.calendar; 18 19 import static android.provider.CalendarContract.EXTRA_EVENT_BEGIN_TIME; 20 21 import android.app.Activity; 22 import android.app.SearchManager; 23 import android.content.BroadcastReceiver; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.content.IntentFilter; 27 import android.content.SharedPreferences; 28 import android.content.res.Resources; 29 import android.database.Cursor; 30 import android.database.MatrixCursor; 31 import android.graphics.Color; 32 import android.graphics.drawable.Drawable; 33 import android.graphics.drawable.LayerDrawable; 34 import android.net.Uri; 35 import android.os.Bundle; 36 import android.os.Handler; 37 import android.text.TextUtils; 38 import android.text.format.DateFormat; 39 import android.text.format.DateUtils; 40 import android.text.format.Time; 41 import android.util.Log; 42 import android.widget.SearchView; 43 44 import com.android.calendar.CalendarController.ViewType; 45 import com.android.calendar.CalendarUtils.TimeZoneUtils; 46 47 import java.util.ArrayList; 48 import java.util.Arrays; 49 import java.util.Calendar; 50 import java.util.Formatter; 51 import java.util.HashMap; 52 import java.util.Iterator; 53 import java.util.LinkedHashSet; 54 import java.util.LinkedList; 55 import java.util.List; 56 import java.util.Locale; 57 import java.util.Map; 58 import java.util.Set; 59 import java.util.TimeZone; 60 61 public class Utils { 62 private static final boolean DEBUG = false; 63 private static final String TAG = "CalUtils"; 64 65 // Set to 0 until we have UI to perform undo 66 public static final long UNDO_DELAY = 0; 67 68 // For recurring events which instances of the series are being modified 69 public static final int MODIFY_UNINITIALIZED = 0; 70 public static final int MODIFY_SELECTED = 1; 71 public static final int MODIFY_ALL_FOLLOWING = 2; 72 public static final int MODIFY_ALL = 3; 73 74 // When the edit event view finishes it passes back the appropriate exit 75 // code. 76 public static final int DONE_REVERT = 1 << 0; 77 public static final int DONE_SAVE = 1 << 1; 78 public static final int DONE_DELETE = 1 << 2; 79 // And should re run with DONE_EXIT if it should also leave the view, just 80 // exiting is identical to reverting 81 public static final int DONE_EXIT = 1 << 0; 82 83 public static final String OPEN_EMAIL_MARKER = " <"; 84 public static final String CLOSE_EMAIL_MARKER = ">"; 85 86 public static final String INTENT_KEY_DETAIL_VIEW = "DETAIL_VIEW"; 87 public static final String INTENT_KEY_VIEW_TYPE = "VIEW"; 88 public static final String INTENT_VALUE_VIEW_TYPE_DAY = "DAY"; 89 public static final String INTENT_KEY_HOME = "KEY_HOME"; 90 91 public static final int MONDAY_BEFORE_JULIAN_EPOCH = Time.EPOCH_JULIAN_DAY - 3; 92 public static final int DECLINED_EVENT_ALPHA = 0x66; 93 public static final int DECLINED_EVENT_TEXT_ALPHA = 0xC0; 94 95 private static final float SATURATION_ADJUST = 1.3f; 96 private static final float INTENSITY_ADJUST = 0.8f; 97 98 // Defines used by the DNA generation code 99 static final int DAY_IN_MINUTES = 60 * 24; 100 static final int WEEK_IN_MINUTES = DAY_IN_MINUTES * 7; 101 // The work day is being counted as 6am to 8pm 102 static int WORK_DAY_MINUTES = 14 * 60; 103 static int WORK_DAY_START_MINUTES = 6 * 60; 104 static int WORK_DAY_END_MINUTES = 20 * 60; 105 static int WORK_DAY_END_LENGTH = (24 * 60) - WORK_DAY_END_MINUTES; 106 static int CONFLICT_COLOR = 0xFF000000; 107 static boolean mMinutesLoaded = false; 108 109 // The name of the shared preferences file. This name must be maintained for 110 // historical 111 // reasons, as it's what PreferenceManager assigned the first time the file 112 // was created. 113 static final String SHARED_PREFS_NAME = "com.android.calendar_preferences"; 114 115 public static final String KEY_QUICK_RESPONSES = "preferences_quick_responses"; 116 117 public static final String APPWIDGET_DATA_TYPE = "vnd.android.data/update"; 118 119 static final String MACHINE_GENERATED_ADDRESS = "calendar.google.com"; 120 121 private static final TimeZoneUtils mTZUtils = new TimeZoneUtils(SHARED_PREFS_NAME); 122 private static boolean mAllowWeekForDetailView = false; 123 private static long mTardis = 0; 124 125 public static int getViewTypeFromIntentAndSharedPref(Activity activity) { 126 Intent intent = activity.getIntent(); 127 Bundle extras = intent.getExtras(); 128 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(activity); 129 130 if (TextUtils.equals(intent.getAction(), Intent.ACTION_EDIT)) { 131 return ViewType.EDIT; 132 } 133 if (extras != null) { 134 if (extras.getBoolean(INTENT_KEY_DETAIL_VIEW, false)) { 135 // This is the "detail" view which is either agenda or day view 136 return prefs.getInt(GeneralPreferences.KEY_DETAILED_VIEW, 137 GeneralPreferences.DEFAULT_DETAILED_VIEW); 138 } else if (INTENT_VALUE_VIEW_TYPE_DAY.equals(extras.getString(INTENT_KEY_VIEW_TYPE))) { 139 // Not sure who uses this. This logic came from LaunchActivity 140 return ViewType.DAY; 141 } 142 } 143 144 // Default to the last view 145 return prefs.getInt( 146 GeneralPreferences.KEY_START_VIEW, GeneralPreferences.DEFAULT_START_VIEW); 147 } 148 149 /** 150 * Gets the intent action for telling the widget to update. 151 */ 152 public static String getWidgetUpdateAction(Context context) { 153 return context.getPackageName() + ".APPWIDGET_UPDATE"; 154 } 155 156 /** 157 * Gets the intent action for telling the widget to update. 158 */ 159 public static String getWidgetScheduledUpdateAction(Context context) { 160 return context.getPackageName() + ".APPWIDGET_SCHEDULED_UPDATE"; 161 } 162 163 /** 164 * Gets the intent action for telling the widget to update. 165 */ 166 public static String getSearchAuthority(Context context) { 167 return context.getPackageName() + ".CalendarRecentSuggestionsProvider"; 168 } 169 170 /** 171 * Writes a new home time zone to the db. Updates the home time zone in the 172 * db asynchronously and updates the local cache. Sending a time zone of 173 * **tbd** will cause it to be set to the device's time zone. null or empty 174 * tz will be ignored. 175 * 176 * @param context The calling activity 177 * @param timeZone The time zone to set Calendar to, or **tbd** 178 */ 179 public static void setTimeZone(Context context, String timeZone) { 180 mTZUtils.setTimeZone(context, timeZone); 181 } 182 183 /** 184 * Gets the time zone that Calendar should be displayed in This is a helper 185 * method to get the appropriate time zone for Calendar. If this is the 186 * first time this method has been called it will initiate an asynchronous 187 * query to verify that the data in preferences is correct. The callback 188 * supplied will only be called if this query returns a value other than 189 * what is stored in preferences and should cause the calling activity to 190 * refresh anything that depends on calling this method. 191 * 192 * @param context The calling activity 193 * @param callback The runnable that should execute if a query returns new 194 * values 195 * @return The string value representing the time zone Calendar should 196 * display 197 */ 198 public static String getTimeZone(Context context, Runnable callback) { 199 return mTZUtils.getTimeZone(context, callback); 200 } 201 202 /** 203 * Formats a date or a time range according to the local conventions. 204 * 205 * @param context the context is required only if the time is shown 206 * @param startMillis the start time in UTC milliseconds 207 * @param endMillis the end time in UTC milliseconds 208 * @param flags a bit mask of options See {@link DateUtils#formatDateRange(Context, Formatter, 209 * long, long, int, String) formatDateRange} 210 * @return a string containing the formatted date/time range. 211 */ 212 public static String formatDateRange( 213 Context context, long startMillis, long endMillis, int flags) { 214 return mTZUtils.formatDateRange(context, startMillis, endMillis, flags); 215 } 216 217 public static String[] getSharedPreference(Context context, String key, String[] defaultValue) { 218 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 219 Set<String> ss = prefs.getStringSet(key, null); 220 if (ss != null) { 221 String strings[] = new String[ss.size()]; 222 return ss.toArray(strings); 223 } 224 return defaultValue; 225 } 226 227 public static String getSharedPreference(Context context, String key, String defaultValue) { 228 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 229 return prefs.getString(key, defaultValue); 230 } 231 232 public static int getSharedPreference(Context context, String key, int defaultValue) { 233 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 234 return prefs.getInt(key, defaultValue); 235 } 236 237 public static boolean getSharedPreference(Context context, String key, boolean defaultValue) { 238 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 239 return prefs.getBoolean(key, defaultValue); 240 } 241 242 /** 243 * Asynchronously sets the preference with the given key to the given value 244 * 245 * @param context the context to use to get preferences from 246 * @param key the key of the preference to set 247 * @param value the value to set 248 */ 249 public static void setSharedPreference(Context context, String key, String value) { 250 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 251 prefs.edit().putString(key, value).apply(); 252 } 253 254 public static void setSharedPreference(Context context, String key, String[] values) { 255 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 256 LinkedHashSet<String> set = new LinkedHashSet<String>(); 257 for (int i = 0; i < values.length; i++) { 258 set.add(values[i]); 259 } 260 prefs.edit().putStringSet(key, set).apply(); 261 } 262 263 protected static void tardis() { 264 mTardis = System.currentTimeMillis(); 265 } 266 267 protected static long getTardis() { 268 return mTardis; 269 } 270 271 static void setSharedPreference(Context context, String key, boolean value) { 272 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 273 SharedPreferences.Editor editor = prefs.edit(); 274 editor.putBoolean(key, value); 275 editor.apply(); 276 } 277 278 static void setSharedPreference(Context context, String key, int value) { 279 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 280 SharedPreferences.Editor editor = prefs.edit(); 281 editor.putInt(key, value); 282 editor.apply(); 283 } 284 285 /** 286 * Save default agenda/day/week/month view for next time 287 * 288 * @param context 289 * @param viewId {@link CalendarController.ViewType} 290 */ 291 static void setDefaultView(Context context, int viewId) { 292 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 293 SharedPreferences.Editor editor = prefs.edit(); 294 295 boolean validDetailView = false; 296 if (mAllowWeekForDetailView && viewId == CalendarController.ViewType.WEEK) { 297 validDetailView = true; 298 } else { 299 validDetailView = viewId == CalendarController.ViewType.AGENDA 300 || viewId == CalendarController.ViewType.DAY; 301 } 302 303 if (validDetailView) { 304 // Record the detail start view 305 editor.putInt(GeneralPreferences.KEY_DETAILED_VIEW, viewId); 306 } 307 308 // Record the (new) start view 309 editor.putInt(GeneralPreferences.KEY_START_VIEW, viewId); 310 editor.apply(); 311 } 312 313 public static MatrixCursor matrixCursorFromCursor(Cursor cursor) { 314 String[] columnNames = cursor.getColumnNames(); 315 if (columnNames == null) { 316 columnNames = new String[] {}; 317 } 318 MatrixCursor newCursor = new MatrixCursor(columnNames); 319 int numColumns = cursor.getColumnCount(); 320 String data[] = new String[numColumns]; 321 cursor.moveToPosition(-1); 322 while (cursor.moveToNext()) { 323 for (int i = 0; i < numColumns; i++) { 324 data[i] = cursor.getString(i); 325 } 326 newCursor.addRow(data); 327 } 328 return newCursor; 329 } 330 331 /** 332 * Compares two cursors to see if they contain the same data. 333 * 334 * @return Returns true of the cursors contain the same data and are not 335 * null, false otherwise 336 */ 337 public static boolean compareCursors(Cursor c1, Cursor c2) { 338 if (c1 == null || c2 == null) { 339 return false; 340 } 341 342 int numColumns = c1.getColumnCount(); 343 if (numColumns != c2.getColumnCount()) { 344 return false; 345 } 346 347 if (c1.getCount() != c2.getCount()) { 348 return false; 349 } 350 351 c1.moveToPosition(-1); 352 c2.moveToPosition(-1); 353 while (c1.moveToNext() && c2.moveToNext()) { 354 for (int i = 0; i < numColumns; i++) { 355 if (!TextUtils.equals(c1.getString(i), c2.getString(i))) { 356 return false; 357 } 358 } 359 } 360 361 return true; 362 } 363 364 /** 365 * If the given intent specifies a time (in milliseconds since the epoch), 366 * then that time is returned. Otherwise, the current time is returned. 367 */ 368 public static final long timeFromIntentInMillis(Intent intent) { 369 // If the time was specified, then use that. Otherwise, use the current 370 // time. 371 Uri data = intent.getData(); 372 long millis = intent.getLongExtra(EXTRA_EVENT_BEGIN_TIME, -1); 373 if (millis == -1 && data != null && data.isHierarchical()) { 374 List<String> path = data.getPathSegments(); 375 if (path.size() == 2 && path.get(0).equals("time")) { 376 try { 377 millis = Long.valueOf(data.getLastPathSegment()); 378 } catch (NumberFormatException e) { 379 Log.i("Calendar", "timeFromIntentInMillis: Data existed but no valid time " 380 + "found. Using current time."); 381 } 382 } 383 } 384 if (millis <= 0) { 385 millis = System.currentTimeMillis(); 386 } 387 return millis; 388 } 389 390 /** 391 * Formats the given Time object so that it gives the month and year (for 392 * example, "September 2007"). 393 * 394 * @param time the time to format 395 * @return the string containing the weekday and the date 396 */ 397 public static String formatMonthYear(Context context, Time time) { 398 int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_MONTH_DAY 399 | DateUtils.FORMAT_SHOW_YEAR; 400 long millis = time.toMillis(true); 401 return formatDateRange(context, millis, millis, flags); 402 } 403 404 /** 405 * Returns a list joined together by the provided delimiter, for example, 406 * ["a", "b", "c"] could be joined into "a,b,c" 407 * 408 * @param things the things to join together 409 * @param delim the delimiter to use 410 * @return a string contained the things joined together 411 */ 412 public static String join(List<?> things, String delim) { 413 StringBuilder builder = new StringBuilder(); 414 boolean first = true; 415 for (Object thing : things) { 416 if (first) { 417 first = false; 418 } else { 419 builder.append(delim); 420 } 421 builder.append(thing.toString()); 422 } 423 return builder.toString(); 424 } 425 426 /** 427 * Returns the week since {@link Time#EPOCH_JULIAN_DAY} (Jan 1, 1970) 428 * adjusted for first day of week. 429 * 430 * This takes a julian day and the week start day and calculates which 431 * week since {@link Time#EPOCH_JULIAN_DAY} that day occurs in, starting 432 * at 0. *Do not* use this to compute the ISO week number for the year. 433 * 434 * @param julianDay The julian day to calculate the week number for 435 * @param firstDayOfWeek Which week day is the first day of the week, 436 * see {@link Time#SUNDAY} 437 * @return Weeks since the epoch 438 */ 439 public static int getWeeksSinceEpochFromJulianDay(int julianDay, int firstDayOfWeek) { 440 int diff = Time.THURSDAY - firstDayOfWeek; 441 if (diff < 0) { 442 diff += 7; 443 } 444 int refDay = Time.EPOCH_JULIAN_DAY - diff; 445 return (julianDay - refDay) / 7; 446 } 447 448 /** 449 * Takes a number of weeks since the epoch and calculates the Julian day of 450 * the Monday for that week. 451 * 452 * This assumes that the week containing the {@link Time#EPOCH_JULIAN_DAY} 453 * is considered week 0. It returns the Julian day for the Monday 454 * {@code week} weeks after the Monday of the week containing the epoch. 455 * 456 * @param week Number of weeks since the epoch 457 * @return The julian day for the Monday of the given week since the epoch 458 */ 459 public static int getJulianMondayFromWeeksSinceEpoch(int week) { 460 return MONDAY_BEFORE_JULIAN_EPOCH + week * 7; 461 } 462 463 /** 464 * Get first day of week as android.text.format.Time constant. 465 * 466 * @return the first day of week in android.text.format.Time 467 */ 468 public static int getFirstDayOfWeek(Context context) { 469 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 470 String pref = prefs.getString( 471 GeneralPreferences.KEY_WEEK_START_DAY, GeneralPreferences.WEEK_START_DEFAULT); 472 473 int startDay; 474 if (GeneralPreferences.WEEK_START_DEFAULT.equals(pref)) { 475 startDay = Calendar.getInstance().getFirstDayOfWeek(); 476 } else { 477 startDay = Integer.parseInt(pref); 478 } 479 480 if (startDay == Calendar.SATURDAY) { 481 return Time.SATURDAY; 482 } else if (startDay == Calendar.MONDAY) { 483 return Time.MONDAY; 484 } else { 485 return Time.SUNDAY; 486 } 487 } 488 489 /** 490 * @return true when week number should be shown. 491 */ 492 public static boolean getShowWeekNumber(Context context) { 493 final SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 494 return prefs.getBoolean( 495 GeneralPreferences.KEY_SHOW_WEEK_NUM, GeneralPreferences.DEFAULT_SHOW_WEEK_NUM); 496 } 497 498 /** 499 * @return true when declined events should be hidden. 500 */ 501 public static boolean getHideDeclinedEvents(Context context) { 502 final SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 503 return prefs.getBoolean(GeneralPreferences.KEY_HIDE_DECLINED, false); 504 } 505 506 public static int getDaysPerWeek(Context context) { 507 final SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 508 return prefs.getInt(GeneralPreferences.KEY_DAYS_PER_WEEK, 7); 509 } 510 511 /** 512 * Determine whether the column position is Saturday or not. 513 * 514 * @param column the column position 515 * @param firstDayOfWeek the first day of week in android.text.format.Time 516 * @return true if the column is Saturday position 517 */ 518 public static boolean isSaturday(int column, int firstDayOfWeek) { 519 return (firstDayOfWeek == Time.SUNDAY && column == 6) 520 || (firstDayOfWeek == Time.MONDAY && column == 5) 521 || (firstDayOfWeek == Time.SATURDAY && column == 0); 522 } 523 524 /** 525 * Determine whether the column position is Sunday or not. 526 * 527 * @param column the column position 528 * @param firstDayOfWeek the first day of week in android.text.format.Time 529 * @return true if the column is Sunday position 530 */ 531 public static boolean isSunday(int column, int firstDayOfWeek) { 532 return (firstDayOfWeek == Time.SUNDAY && column == 0) 533 || (firstDayOfWeek == Time.MONDAY && column == 6) 534 || (firstDayOfWeek == Time.SATURDAY && column == 1); 535 } 536 537 /** 538 * Convert given UTC time into current local time. This assumes it is for an 539 * allday event and will adjust the time to be on a midnight boundary. 540 * 541 * @param recycle Time object to recycle, otherwise null. 542 * @param utcTime Time to convert, in UTC. 543 * @param tz The time zone to convert this time to. 544 */ 545 public static long convertAlldayUtcToLocal(Time recycle, long utcTime, String tz) { 546 if (recycle == null) { 547 recycle = new Time(); 548 } 549 recycle.timezone = Time.TIMEZONE_UTC; 550 recycle.set(utcTime); 551 recycle.timezone = tz; 552 return recycle.normalize(true); 553 } 554 555 public static long convertAlldayLocalToUTC(Time recycle, long localTime, String tz) { 556 if (recycle == null) { 557 recycle = new Time(); 558 } 559 recycle.timezone = tz; 560 recycle.set(localTime); 561 recycle.timezone = Time.TIMEZONE_UTC; 562 return recycle.normalize(true); 563 } 564 565 /** 566 * Finds and returns the next midnight after "theTime" in milliseconds UTC 567 * 568 * @param recycle - Time object to recycle, otherwise null. 569 * @param theTime - Time used for calculations (in UTC) 570 * @param tz The time zone to convert this time to. 571 */ 572 public static long getNextMidnight(Time recycle, long theTime, String tz) { 573 if (recycle == null) { 574 recycle = new Time(); 575 } 576 recycle.timezone = tz; 577 recycle.set(theTime); 578 recycle.monthDay ++; 579 recycle.hour = 0; 580 recycle.minute = 0; 581 recycle.second = 0; 582 return recycle.normalize(true); 583 } 584 585 /** 586 * Scan through a cursor of calendars and check if names are duplicated. 587 * This travels a cursor containing calendar display names and fills in the 588 * provided map with whether or not each name is repeated. 589 * 590 * @param isDuplicateName The map to put the duplicate check results in. 591 * @param cursor The query of calendars to check 592 * @param nameIndex The column of the query that contains the display name 593 */ 594 public static void checkForDuplicateNames( 595 Map<String, Boolean> isDuplicateName, Cursor cursor, int nameIndex) { 596 isDuplicateName.clear(); 597 cursor.moveToPosition(-1); 598 while (cursor.moveToNext()) { 599 String displayName = cursor.getString(nameIndex); 600 // Set it to true if we've seen this name before, false otherwise 601 if (displayName != null) { 602 isDuplicateName.put(displayName, isDuplicateName.containsKey(displayName)); 603 } 604 } 605 } 606 607 /** 608 * Null-safe object comparison 609 * 610 * @param s1 611 * @param s2 612 * @return 613 */ 614 public static boolean equals(Object o1, Object o2) { 615 return o1 == null ? o2 == null : o1.equals(o2); 616 } 617 618 public static void setAllowWeekForDetailView(boolean allowWeekView) { 619 mAllowWeekForDetailView = allowWeekView; 620 } 621 622 public static boolean getAllowWeekForDetailView() { 623 return mAllowWeekForDetailView; 624 } 625 626 public static boolean getConfigBool(Context c, int key) { 627 return c.getResources().getBoolean(key); 628 } 629 630 public static int getDisplayColorFromColor(int color) { 631 // STOPSHIP - Finalize color adjustment algorithm before shipping 632 633 float[] hsv = new float[3]; 634 Color.colorToHSV(color, hsv); 635 hsv[1] = Math.min(hsv[1] * SATURATION_ADJUST, 1.0f); 636 hsv[2] = hsv[2] * INTENSITY_ADJUST; 637 return Color.HSVToColor(hsv); 638 } 639 640 // This takes a color and computes what it would look like blended with 641 // white. The result is the color that should be used for declined events. 642 public static int getDeclinedColorFromColor(int color) { 643 int bg = 0xffffffff; 644 int a = DECLINED_EVENT_ALPHA; 645 int r = (((color & 0x00ff0000) * a) + ((bg & 0x00ff0000) * (0xff - a))) & 0xff000000; 646 int g = (((color & 0x0000ff00) * a) + ((bg & 0x0000ff00) * (0xff - a))) & 0x00ff0000; 647 int b = (((color & 0x000000ff) * a) + ((bg & 0x000000ff) * (0xff - a))) & 0x0000ff00; 648 return (0xff000000) | ((r | g | b) >> 8); 649 } 650 651 // A single strand represents one color of events. Events are divided up by 652 // color to make them convenient to draw. The black strand is special in 653 // that it holds conflicting events as well as color settings for allday on 654 // each day. 655 public static class DNAStrand { 656 public float[] points; 657 public int[] allDays; // color for the allday, 0 means no event 658 int position; 659 public int color; 660 int count; 661 } 662 663 // A segment is a single continuous length of time occupied by a single 664 // color. Segments should never span multiple days. 665 private static class DNASegment { 666 int startMinute; // in minutes since the start of the week 667 int endMinute; 668 int color; // Calendar color or black for conflicts 669 int day; // quick reference to the day this segment is on 670 } 671 672 /** 673 * Converts a list of events to a list of segments to draw. Assumes list is 674 * ordered by start time of the events. The function processes events for a 675 * range of days from firstJulianDay to firstJulianDay + dayXs.length - 1. 676 * The algorithm goes over all the events and creates a set of segments 677 * ordered by start time. This list of segments is then converted into a 678 * HashMap of strands which contain the draw points and are organized by 679 * color. The strands can then be drawn by setting the paint color to each 680 * strand's color and calling drawLines on its set of points. The points are 681 * set up using the following parameters. 682 * <ul> 683 * <li>Events between midnight and WORK_DAY_START_MINUTES are compressed 684 * into the first 1/8th of the space between top and bottom.</li> 685 * <li>Events between WORK_DAY_END_MINUTES and the following midnight are 686 * compressed into the last 1/8th of the space between top and bottom</li> 687 * <li>Events between WORK_DAY_START_MINUTES and WORK_DAY_END_MINUTES use 688 * the remaining 3/4ths of the space</li> 689 * <li>All segments drawn will maintain at least minPixels height, except 690 * for conflicts in the first or last 1/8th, which may be smaller</li> 691 * </ul> 692 * 693 * @param firstJulianDay The julian day of the first day of events 694 * @param events A list of events sorted by start time 695 * @param top The lowest y value the dna should be drawn at 696 * @param bottom The highest y value the dna should be drawn at 697 * @param dayXs An array of x values to draw the dna at, one for each day 698 * @param conflictColor the color to use for conflicts 699 * @return 700 */ 701 public static HashMap<Integer, DNAStrand> createDNAStrands(int firstJulianDay, 702 ArrayList<Event> events, int top, int bottom, int minPixels, int[] dayXs, 703 Context context) { 704 705 if (!mMinutesLoaded) { 706 if (context == null) { 707 Log.wtf(TAG, "No context and haven't loaded parameters yet! Can't create DNA."); 708 } 709 Resources res = context.getResources(); 710 CONFLICT_COLOR = res.getColor(R.color.month_dna_conflict_time_color); 711 WORK_DAY_START_MINUTES = res.getInteger(R.integer.work_start_minutes); 712 WORK_DAY_END_MINUTES = res.getInteger(R.integer.work_end_minutes); 713 WORK_DAY_END_LENGTH = DAY_IN_MINUTES - WORK_DAY_END_MINUTES; 714 WORK_DAY_MINUTES = WORK_DAY_END_MINUTES - WORK_DAY_START_MINUTES; 715 mMinutesLoaded = true; 716 } 717 718 if (events == null || events.isEmpty() || dayXs == null || dayXs.length < 1 719 || bottom - top < 8 || minPixels < 0) { 720 Log.e(TAG, 721 "Bad values for createDNAStrands! events:" + events + " dayXs:" 722 + Arrays.toString(dayXs) + " bot-top:" + (bottom - top) + " minPixels:" 723 + minPixels); 724 return null; 725 } 726 727 LinkedList<DNASegment> segments = new LinkedList<DNASegment>(); 728 HashMap<Integer, DNAStrand> strands = new HashMap<Integer, DNAStrand>(); 729 // add a black strand by default, other colors will get added in 730 // the loop 731 DNAStrand blackStrand = new DNAStrand(); 732 blackStrand.color = CONFLICT_COLOR; 733 strands.put(CONFLICT_COLOR, blackStrand); 734 // the min length is the number of minutes that will occupy 735 // MIN_SEGMENT_PIXELS in the 'work day' time slot. This computes the 736 // minutes/pixel * minpx where the number of pixels are 3/4 the total 737 // dna height: 4*(mins/(px * 3/4)) 738 int minMinutes = minPixels * 4 * WORK_DAY_MINUTES / (3 * (bottom - top)); 739 740 // There are slightly fewer than half as many pixels in 1/6 the space, 741 // so round to 2.5x for the min minutes in the non-work area 742 int minOtherMinutes = minMinutes * 5 / 2; 743 int lastJulianDay = firstJulianDay + dayXs.length - 1; 744 745 Event event = new Event(); 746 // Go through all the events for the week 747 for (Event currEvent : events) { 748 // if this event is outside the weeks range skip it 749 if (currEvent.endDay < firstJulianDay || currEvent.startDay > lastJulianDay) { 750 continue; 751 } 752 if (currEvent.drawAsAllday()) { 753 addAllDayToStrands(currEvent, strands, firstJulianDay, dayXs.length); 754 continue; 755 } 756 // Copy the event over so we can clip its start and end to our range 757 currEvent.copyTo(event); 758 if (event.startDay < firstJulianDay) { 759 event.startDay = firstJulianDay; 760 event.startTime = 0; 761 } 762 // If it starts after the work day make sure the start is at least 763 // minPixels from midnight 764 if (event.startTime > DAY_IN_MINUTES - minOtherMinutes) { 765 event.startTime = DAY_IN_MINUTES - minOtherMinutes; 766 } 767 if (event.endDay > lastJulianDay) { 768 event.endDay = lastJulianDay; 769 event.endTime = DAY_IN_MINUTES - 1; 770 } 771 // If the end time is before the work day make sure it ends at least 772 // minPixels after midnight 773 if (event.endTime < minOtherMinutes) { 774 event.endTime = minOtherMinutes; 775 } 776 // If the start and end are on the same day make sure they are at 777 // least minPixels apart. This only needs to be done for times 778 // outside the work day as the min distance for within the work day 779 // is enforced in the segment code. 780 if (event.startDay == event.endDay && 781 event.endTime - event.startTime < minOtherMinutes) { 782 // If it's less than minPixels in an area before the work 783 // day 784 if (event.startTime < WORK_DAY_START_MINUTES) { 785 // extend the end to the first easy guarantee that it's 786 // minPixels 787 event.endTime = Math.min(event.startTime + minOtherMinutes, 788 WORK_DAY_START_MINUTES + minMinutes); 789 // if it's in the area after the work day 790 } else if (event.endTime > WORK_DAY_END_MINUTES) { 791 // First try shifting the end but not past midnight 792 event.endTime = Math.min(event.endTime + minOtherMinutes, DAY_IN_MINUTES - 1); 793 // if it's still too small move the start back 794 if (event.endTime - event.startTime < minOtherMinutes) { 795 event.startTime = event.endTime - minOtherMinutes; 796 } 797 } 798 } 799 800 // This handles adding the first segment 801 if (segments.size() == 0) { 802 addNewSegment(segments, event, strands, firstJulianDay, 0, minMinutes); 803 continue; 804 } 805 // Now compare our current start time to the end time of the last 806 // segment in the list 807 DNASegment lastSegment = segments.getLast(); 808 int startMinute = (event.startDay - firstJulianDay) * DAY_IN_MINUTES + event.startTime; 809 int endMinute = Math.max((event.endDay - firstJulianDay) * DAY_IN_MINUTES 810 + event.endTime, startMinute + minMinutes); 811 812 if (startMinute < 0) { 813 startMinute = 0; 814 } 815 if (endMinute >= WEEK_IN_MINUTES) { 816 endMinute = WEEK_IN_MINUTES - 1; 817 } 818 // If we start before the last segment in the list ends we need to 819 // start going through the list as this may conflict with other 820 // events 821 if (startMinute < lastSegment.endMinute) { 822 int i = segments.size(); 823 // find the last segment this event intersects with 824 while (--i >= 0 && endMinute < segments.get(i).startMinute); 825 826 DNASegment currSegment; 827 // for each segment this event intersects with 828 for (; i >= 0 && startMinute <= (currSegment = segments.get(i)).endMinute; i--) { 829 // if the segment is already a conflict ignore it 830 if (currSegment.color == CONFLICT_COLOR) { 831 continue; 832 } 833 // if the event ends before the segment and wouldn't create 834 // a segment that is too small split off the right side 835 if (endMinute < currSegment.endMinute - minMinutes) { 836 DNASegment rhs = new DNASegment(); 837 rhs.endMinute = currSegment.endMinute; 838 rhs.color = currSegment.color; 839 rhs.startMinute = endMinute + 1; 840 rhs.day = currSegment.day; 841 currSegment.endMinute = endMinute; 842 segments.add(i + 1, rhs); 843 strands.get(rhs.color).count++; 844 if (DEBUG) { 845 Log.d(TAG, "Added rhs, curr:" + currSegment.toString() + " i:" 846 + segments.get(i).toString()); 847 } 848 } 849 // if the event starts after the segment and wouldn't create 850 // a segment that is too small split off the left side 851 if (startMinute > currSegment.startMinute + minMinutes) { 852 DNASegment lhs = new DNASegment(); 853 lhs.startMinute = currSegment.startMinute; 854 lhs.color = currSegment.color; 855 lhs.endMinute = startMinute - 1; 856 lhs.day = currSegment.day; 857 currSegment.startMinute = startMinute; 858 // increment i so that we are at the right position when 859 // referencing the segments to the right and left of the 860 // current segment. 861 segments.add(i++, lhs); 862 strands.get(lhs.color).count++; 863 if (DEBUG) { 864 Log.d(TAG, "Added lhs, curr:" + currSegment.toString() + " i:" 865 + segments.get(i).toString()); 866 } 867 } 868 // if the right side is black merge this with the segment to 869 // the right if they're on the same day and overlap 870 if (i + 1 < segments.size()) { 871 DNASegment rhs = segments.get(i + 1); 872 if (rhs.color == CONFLICT_COLOR && currSegment.day == rhs.day 873 && rhs.startMinute <= currSegment.endMinute + 1) { 874 rhs.startMinute = Math.min(currSegment.startMinute, rhs.startMinute); 875 segments.remove(currSegment); 876 strands.get(currSegment.color).count--; 877 // point at the new current segment 878 currSegment = rhs; 879 } 880 } 881 // if the left side is black merge this with the segment to 882 // the left if they're on the same day and overlap 883 if (i - 1 >= 0) { 884 DNASegment lhs = segments.get(i - 1); 885 if (lhs.color == CONFLICT_COLOR && currSegment.day == lhs.day 886 && lhs.endMinute >= currSegment.startMinute - 1) { 887 lhs.endMinute = Math.max(currSegment.endMinute, lhs.endMinute); 888 segments.remove(currSegment); 889 strands.get(currSegment.color).count--; 890 // point at the new current segment 891 currSegment = lhs; 892 // point i at the new current segment in case new 893 // code is added 894 i--; 895 } 896 } 897 // if we're still not black, decrement the count for the 898 // color being removed, change this to black, and increment 899 // the black count 900 if (currSegment.color != CONFLICT_COLOR) { 901 strands.get(currSegment.color).count--; 902 currSegment.color = CONFLICT_COLOR; 903 strands.get(CONFLICT_COLOR).count++; 904 } 905 } 906 907 } 908 // If this event extends beyond the last segment add a new segment 909 if (endMinute > lastSegment.endMinute) { 910 addNewSegment(segments, event, strands, firstJulianDay, lastSegment.endMinute, 911 minMinutes); 912 } 913 } 914 weaveDNAStrands(segments, firstJulianDay, strands, top, bottom, dayXs); 915 return strands; 916 } 917 918 // This figures out allDay colors as allDay events are found 919 private static void addAllDayToStrands(Event event, HashMap<Integer, DNAStrand> strands, 920 int firstJulianDay, int numDays) { 921 DNAStrand strand = getOrCreateStrand(strands, CONFLICT_COLOR); 922 // if we haven't initialized the allDay portion create it now 923 if (strand.allDays == null) { 924 strand.allDays = new int[numDays]; 925 } 926 927 // For each day this event is on update the color 928 int end = Math.min(event.endDay - firstJulianDay, numDays - 1); 929 for (int i = Math.max(event.startDay - firstJulianDay, 0); i <= end; i++) { 930 if (strand.allDays[i] != 0) { 931 // if this day already had a color, it is now a conflict 932 strand.allDays[i] = CONFLICT_COLOR; 933 } else { 934 // else it's just the color of the event 935 strand.allDays[i] = event.color; 936 } 937 } 938 } 939 940 // This processes all the segments, sorts them by color, and generates a 941 // list of points to draw 942 private static void weaveDNAStrands(LinkedList<DNASegment> segments, int firstJulianDay, 943 HashMap<Integer, DNAStrand> strands, int top, int bottom, int[] dayXs) { 944 // First, get rid of any colors that ended up with no segments 945 Iterator<DNAStrand> strandIterator = strands.values().iterator(); 946 while (strandIterator.hasNext()) { 947 DNAStrand strand = strandIterator.next(); 948 if (strand.count < 1 && strand.allDays == null) { 949 strandIterator.remove(); 950 continue; 951 } 952 strand.points = new float[strand.count * 4]; 953 strand.position = 0; 954 } 955 // Go through each segment and compute its points 956 for (DNASegment segment : segments) { 957 // Add the points to the strand of that color 958 DNAStrand strand = strands.get(segment.color); 959 int dayIndex = segment.day - firstJulianDay; 960 int dayStartMinute = segment.startMinute % DAY_IN_MINUTES; 961 int dayEndMinute = segment.endMinute % DAY_IN_MINUTES; 962 int height = bottom - top; 963 int workDayHeight = height * 3 / 4; 964 int remainderHeight = (height - workDayHeight) / 2; 965 966 int x = dayXs[dayIndex]; 967 int y0 = 0; 968 int y1 = 0; 969 970 y0 = top + getPixelOffsetFromMinutes(dayStartMinute, workDayHeight, remainderHeight); 971 y1 = top + getPixelOffsetFromMinutes(dayEndMinute, workDayHeight, remainderHeight); 972 if (DEBUG) { 973 Log.d(TAG, "Adding " + Integer.toHexString(segment.color) + " at x,y0,y1: " + x 974 + " " + y0 + " " + y1 + " for " + dayStartMinute + " " + dayEndMinute); 975 } 976 strand.points[strand.position++] = x; 977 strand.points[strand.position++] = y0; 978 strand.points[strand.position++] = x; 979 strand.points[strand.position++] = y1; 980 } 981 } 982 983 /** 984 * Compute a pixel offset from the top for a given minute from the work day 985 * height and the height of the top area. 986 */ 987 private static int getPixelOffsetFromMinutes(int minute, int workDayHeight, 988 int remainderHeight) { 989 int y; 990 if (minute < WORK_DAY_START_MINUTES) { 991 y = minute * remainderHeight / WORK_DAY_START_MINUTES; 992 } else if (minute < WORK_DAY_END_MINUTES) { 993 y = remainderHeight + (minute - WORK_DAY_START_MINUTES) * workDayHeight 994 / WORK_DAY_MINUTES; 995 } else { 996 y = remainderHeight + workDayHeight + (minute - WORK_DAY_END_MINUTES) * remainderHeight 997 / WORK_DAY_END_LENGTH; 998 } 999 return y; 1000 } 1001 1002 /** 1003 * Add a new segment based on the event provided. This will handle splitting 1004 * segments across day boundaries and ensures a minimum size for segments. 1005 */ 1006 private static void addNewSegment(LinkedList<DNASegment> segments, Event event, 1007 HashMap<Integer, DNAStrand> strands, int firstJulianDay, int minStart, int minMinutes) { 1008 if (event.startDay > event.endDay) { 1009 Log.wtf(TAG, "Event starts after it ends: " + event.toString()); 1010 } 1011 // If this is a multiday event split it up by day 1012 if (event.startDay != event.endDay) { 1013 Event lhs = new Event(); 1014 lhs.color = event.color; 1015 lhs.startDay = event.startDay; 1016 // the first day we want the start time to be the actual start time 1017 lhs.startTime = event.startTime; 1018 lhs.endDay = lhs.startDay; 1019 lhs.endTime = DAY_IN_MINUTES - 1; 1020 // Nearly recursive iteration! 1021 while (lhs.startDay != event.endDay) { 1022 addNewSegment(segments, lhs, strands, firstJulianDay, minStart, minMinutes); 1023 // The days in between are all day, even though that shouldn't 1024 // actually happen due to the allday filtering 1025 lhs.startDay++; 1026 lhs.endDay = lhs.startDay; 1027 lhs.startTime = 0; 1028 minStart = 0; 1029 } 1030 // The last day we want the end time to be the actual end time 1031 lhs.endTime = event.endTime; 1032 event = lhs; 1033 } 1034 // Create the new segment and compute its fields 1035 DNASegment segment = new DNASegment(); 1036 int dayOffset = (event.startDay - firstJulianDay) * DAY_IN_MINUTES; 1037 int endOfDay = dayOffset + DAY_IN_MINUTES - 1; 1038 // clip the start if needed 1039 segment.startMinute = Math.max(dayOffset + event.startTime, minStart); 1040 // and extend the end if it's too small, but not beyond the end of the 1041 // day 1042 int minEnd = Math.min(segment.startMinute + minMinutes, endOfDay); 1043 segment.endMinute = Math.max(dayOffset + event.endTime, minEnd); 1044 if (segment.endMinute > endOfDay) { 1045 segment.endMinute = endOfDay; 1046 } 1047 1048 segment.color = event.color; 1049 segment.day = event.startDay; 1050 segments.add(segment); 1051 // increment the count for the correct color or add a new strand if we 1052 // don't have that color yet 1053 DNAStrand strand = getOrCreateStrand(strands, segment.color); 1054 strand.count++; 1055 } 1056 1057 /** 1058 * Try to get a strand of the given color. Create it if it doesn't exist. 1059 */ 1060 private static DNAStrand getOrCreateStrand(HashMap<Integer, DNAStrand> strands, int color) { 1061 DNAStrand strand = strands.get(color); 1062 if (strand == null) { 1063 strand = new DNAStrand(); 1064 strand.color = color; 1065 strand.count = 0; 1066 strands.put(strand.color, strand); 1067 } 1068 return strand; 1069 } 1070 1071 /** 1072 * Sends an intent to launch the top level Calendar view. 1073 * 1074 * @param context 1075 */ 1076 public static void returnToCalendarHome(Context context) { 1077 Intent launchIntent = new Intent(context, AllInOneActivity.class); 1078 launchIntent.setAction(Intent.ACTION_DEFAULT); 1079 launchIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); 1080 launchIntent.putExtra(INTENT_KEY_HOME, true); 1081 context.startActivity(launchIntent); 1082 } 1083 1084 /** 1085 * This sets up a search view to use Calendar's search suggestions provider 1086 * and to allow refining the search. 1087 * 1088 * @param view The {@link SearchView} to set up 1089 * @param act The activity using the view 1090 */ 1091 public static void setUpSearchView(SearchView view, Activity act) { 1092 SearchManager searchManager = (SearchManager) act.getSystemService(Context.SEARCH_SERVICE); 1093 view.setSearchableInfo(searchManager.getSearchableInfo(act.getComponentName())); 1094 view.setQueryRefinementEnabled(true); 1095 } 1096 1097 /** 1098 * Given a context and a time in millis since unix epoch figures out the 1099 * correct week of the year for that time. 1100 * 1101 * @param millisSinceEpoch 1102 * @return 1103 */ 1104 public static int getWeekNumberFromTime(long millisSinceEpoch, Context context) { 1105 Time weekTime = new Time(getTimeZone(context, null)); 1106 weekTime.set(millisSinceEpoch); 1107 weekTime.normalize(true); 1108 int firstDayOfWeek = getFirstDayOfWeek(context); 1109 // if the date is on Saturday or Sunday and the start of the week 1110 // isn't Monday we may need to shift the date to be in the correct 1111 // week 1112 if (weekTime.weekDay == Time.SUNDAY 1113 && (firstDayOfWeek == Time.SUNDAY || firstDayOfWeek == Time.SATURDAY)) { 1114 weekTime.monthDay++; 1115 weekTime.normalize(true); 1116 } else if (weekTime.weekDay == Time.SATURDAY && firstDayOfWeek == Time.SATURDAY) { 1117 weekTime.monthDay += 2; 1118 weekTime.normalize(true); 1119 } 1120 return weekTime.getWeekNumber(); 1121 } 1122 1123 /** 1124 * Formats a day of the week string. This is either just the name of the day 1125 * or a combination of yesterday/today/tomorrow and the day of the week. 1126 * 1127 * @param julianDay The julian day to get the string for 1128 * @param todayJulianDay The julian day for today's date 1129 * @param millis A utc millis since epoch time that falls on julian day 1130 * @param context The calling context, used to get the timezone and do the 1131 * formatting 1132 * @return 1133 */ 1134 public static String getDayOfWeekString(int julianDay, int todayJulianDay, long millis, 1135 Context context) { 1136 getTimeZone(context, null); 1137 int flags = DateUtils.FORMAT_SHOW_WEEKDAY; 1138 String dayViewText; 1139 if (julianDay == todayJulianDay) { 1140 dayViewText = context.getString(R.string.agenda_today, 1141 mTZUtils.formatDateRange(context, millis, millis, flags).toString()); 1142 } else if (julianDay == todayJulianDay - 1) { 1143 dayViewText = context.getString(R.string.agenda_yesterday, 1144 mTZUtils.formatDateRange(context, millis, millis, flags).toString()); 1145 } else if (julianDay == todayJulianDay + 1) { 1146 dayViewText = context.getString(R.string.agenda_tomorrow, 1147 mTZUtils.formatDateRange(context, millis, millis, flags).toString()); 1148 } else { 1149 dayViewText = mTZUtils.formatDateRange(context, millis, millis, flags).toString(); 1150 } 1151 dayViewText = dayViewText.toUpperCase(); 1152 return dayViewText; 1153 } 1154 1155 // Calculate the time until midnight + 1 second and set the handler to 1156 // do run the runnable 1157 public static void setMidnightUpdater(Handler h, Runnable r, String timezone) { 1158 if (h == null || r == null || timezone == null) { 1159 return; 1160 } 1161 long now = System.currentTimeMillis(); 1162 Time time = new Time(timezone); 1163 time.set(now); 1164 long runInMillis = (24 * 3600 - time.hour * 3600 - time.minute * 60 - 1165 time.second + 1) * 1000; 1166 h.removeCallbacks(r); 1167 h.postDelayed(r, runInMillis); 1168 } 1169 1170 // Stop the midnight update thread 1171 public static void resetMidnightUpdater(Handler h, Runnable r) { 1172 if (h == null || r == null) { 1173 return; 1174 } 1175 h.removeCallbacks(r); 1176 } 1177 1178 /** 1179 * Returns a string description of the specified time interval. 1180 */ 1181 public static String getDisplayedDatetime(long startMillis, long endMillis, long currentMillis, 1182 String localTimezone, boolean allDay, Context context) { 1183 // Configure date/time formatting. 1184 int flagsDate = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_WEEKDAY; 1185 int flagsTime = DateUtils.FORMAT_SHOW_TIME; 1186 if (DateFormat.is24HourFormat(context)) { 1187 flagsTime |= DateUtils.FORMAT_24HOUR; 1188 } 1189 1190 Time currentTime = new Time(localTimezone); 1191 currentTime.set(currentMillis); 1192 Resources resources = context.getResources(); 1193 String datetimeString = null; 1194 if (allDay) { 1195 // All day events require special timezone adjustment. 1196 long localStartMillis = convertAlldayUtcToLocal(null, startMillis, localTimezone); 1197 long localEndMillis = convertAlldayUtcToLocal(null, endMillis, localTimezone); 1198 if (singleDayEvent(localStartMillis, localEndMillis, currentTime.gmtoff)) { 1199 // If possible, use "Today" or "Tomorrow" instead of a full date string. 1200 int todayOrTomorrow = isTodayOrTomorrow(context.getResources(), 1201 localStartMillis, currentMillis, currentTime.gmtoff); 1202 if (TODAY == todayOrTomorrow) { 1203 datetimeString = resources.getString(R.string.today); 1204 } else if (TOMORROW == todayOrTomorrow) { 1205 datetimeString = resources.getString(R.string.tomorrow); 1206 } 1207 } 1208 if (datetimeString == null) { 1209 // For multi-day allday events or single-day all-day events that are not 1210 // today or tomorrow, use framework formatter. 1211 Formatter f = new Formatter(new StringBuilder(50), Locale.getDefault()); 1212 datetimeString = DateUtils.formatDateRange(context, f, startMillis, 1213 endMillis, flagsDate, Time.TIMEZONE_UTC).toString(); 1214 } 1215 } else { 1216 if (singleDayEvent(startMillis, endMillis, currentTime.gmtoff)) { 1217 // Format the time. 1218 String timeString = Utils.formatDateRange(context, startMillis, endMillis, 1219 flagsTime); 1220 1221 // If possible, use "Today" or "Tomorrow" instead of a full date string. 1222 int todayOrTomorrow = isTodayOrTomorrow(context.getResources(), startMillis, 1223 currentMillis, currentTime.gmtoff); 1224 if (TODAY == todayOrTomorrow) { 1225 // Example: "Today at 1:00pm - 2:00 pm" 1226 datetimeString = resources.getString(R.string.today_at_time_fmt, 1227 timeString); 1228 } else if (TOMORROW == todayOrTomorrow) { 1229 // Example: "Tomorrow at 1:00pm - 2:00 pm" 1230 datetimeString = resources.getString(R.string.tomorrow_at_time_fmt, 1231 timeString); 1232 } else { 1233 // Format the full date. Example: "Thursday, April 12, 1:00pm - 2:00pm" 1234 String dateString = Utils.formatDateRange(context, startMillis, endMillis, 1235 flagsDate); 1236 datetimeString = resources.getString(R.string.date_time_fmt, dateString, 1237 timeString); 1238 } 1239 } else { 1240 // For multiday events, shorten day/month names. 1241 // Example format: "Fri Apr 6, 5:00pm - Sun, Apr 8, 6:00pm" 1242 int flagsDatetime = flagsDate | flagsTime | DateUtils.FORMAT_ABBREV_MONTH | 1243 DateUtils.FORMAT_ABBREV_WEEKDAY; 1244 datetimeString = Utils.formatDateRange(context, startMillis, endMillis, 1245 flagsDatetime); 1246 } 1247 } 1248 return datetimeString; 1249 } 1250 1251 /** 1252 * Returns the timezone to display in the event info, if the local timezone is different 1253 * from the event timezone. Otherwise returns null. 1254 */ 1255 public static String getDisplayedTimezone(long startMillis, String localTimezone, 1256 String eventTimezone) { 1257 String tzDisplay = null; 1258 if (!TextUtils.equals(localTimezone, eventTimezone)) { 1259 // Figure out if this is in DST 1260 TimeZone tz = TimeZone.getTimeZone(localTimezone); 1261 if (tz == null || tz.getID().equals("GMT")) { 1262 tzDisplay = localTimezone; 1263 } else { 1264 Time startTime = new Time(localTimezone); 1265 startTime.set(startMillis); 1266 tzDisplay = tz.getDisplayName(startTime.isDst != 0, TimeZone.SHORT); 1267 } 1268 } 1269 return tzDisplay; 1270 } 1271 1272 /** 1273 * Returns whether the specified time interval is in a single day. 1274 */ 1275 private static boolean singleDayEvent(long startMillis, long endMillis, long localGmtOffset) { 1276 if (startMillis == endMillis) { 1277 return true; 1278 } 1279 1280 // An event ending at midnight should still be a single-day event, so check 1281 // time end-1. 1282 int startDay = Time.getJulianDay(startMillis, localGmtOffset); 1283 int endDay = Time.getJulianDay(endMillis - 1, localGmtOffset); 1284 return startDay == endDay; 1285 } 1286 1287 // Using int constants as a return value instead of an enum to minimize resources. 1288 private static final int TODAY = 1; 1289 private static final int TOMORROW = 2; 1290 private static final int NONE = 0; 1291 1292 /** 1293 * Returns TODAY or TOMORROW if applicable. Otherwise returns NONE. 1294 */ 1295 private static int isTodayOrTomorrow(Resources r, long dayMillis, 1296 long currentMillis, long localGmtOffset) { 1297 int startDay = Time.getJulianDay(dayMillis, localGmtOffset); 1298 int currentDay = Time.getJulianDay(currentMillis, localGmtOffset); 1299 1300 int days = startDay - currentDay; 1301 if (days == 1) { 1302 return TOMORROW; 1303 } else if (days == 0) { 1304 return TODAY; 1305 } else { 1306 return NONE; 1307 } 1308 } 1309 1310 /** 1311 * Create an intent for emailing attendees of an event. 1312 * 1313 * @param resources The resources for translating strings. 1314 * @param eventTitle The title of the event to use as the email subject. 1315 * @param body The default text for the email body. 1316 * @param toEmails The list of emails for the 'to' line. 1317 * @param ccEmails The list of emails for the 'cc' line. 1318 * @param ownerAccount The owner account to use as the email sender. 1319 */ 1320 public static Intent createEmailAttendeesIntent(Resources resources, String eventTitle, 1321 String body, List<String> toEmails, List<String> ccEmails, String ownerAccount) { 1322 List<String> toList = toEmails; 1323 List<String> ccList = ccEmails; 1324 if (toEmails.size() <= 0) { 1325 if (ccEmails.size() <= 0) { 1326 // TODO: Return a SEND intent if no one to email to, to at least populate 1327 // a draft email with the subject (and no recipients). 1328 throw new IllegalArgumentException("Both toEmails and ccEmails are empty."); 1329 } 1330 1331 // Email app does not work with no "to" recipient. Move all 'cc' to 'to' 1332 // in this case. 1333 toList = ccEmails; 1334 ccList = null; 1335 } 1336 1337 // Use the event title as the email subject (prepended with 'Re: '). 1338 String subject = null; 1339 if (eventTitle != null) { 1340 subject = resources.getString(R.string.email_subject_prefix) + eventTitle; 1341 } 1342 1343 // Use the SENDTO intent with a 'mailto' URI, because using SEND will cause 1344 // the picker to show apps like text messaging, which does not make sense 1345 // for email addresses. We put all data in the URI instead of using the extra 1346 // Intent fields (ie. EXTRA_CC, etc) because some email apps might not handle 1347 // those (though gmail does). 1348 Uri.Builder uriBuilder = new Uri.Builder(); 1349 uriBuilder.scheme("mailto"); 1350 1351 // We will append the first email to the 'mailto' field later (because the 1352 // current state of the Email app requires it). Add the remaining 'to' values 1353 // here. When the email codebase is updated, we can simplify this. 1354 if (toList.size() > 1) { 1355 for (int i = 1; i < toList.size(); i++) { 1356 // The Email app requires repeated parameter settings instead of 1357 // a single comma-separated list. 1358 uriBuilder.appendQueryParameter("to", toList.get(i)); 1359 } 1360 } 1361 1362 // Add the subject parameter. 1363 if (subject != null) { 1364 uriBuilder.appendQueryParameter("subject", subject); 1365 } 1366 1367 // Add the subject parameter. 1368 if (body != null) { 1369 uriBuilder.appendQueryParameter("body", body); 1370 } 1371 1372 // Add the cc parameters. 1373 if (ccList != null && ccList.size() > 0) { 1374 for (String email : ccList) { 1375 uriBuilder.appendQueryParameter("cc", email); 1376 } 1377 } 1378 1379 // Insert the first email after 'mailto:' in the URI manually since Uri.Builder 1380 // doesn't seem to have a way to do this. 1381 String uri = uriBuilder.toString(); 1382 if (uri.startsWith("mailto:")) { 1383 StringBuilder builder = new StringBuilder(uri); 1384 builder.insert(7, Uri.encode(toList.get(0))); 1385 uri = builder.toString(); 1386 } 1387 1388 // Start the email intent. Email from the account of the calendar owner in case there 1389 // are multiple email accounts. 1390 Intent emailIntent = new Intent(android.content.Intent.ACTION_SENDTO, Uri.parse(uri)); 1391 emailIntent.putExtra("fromAccountString", ownerAccount); 1392 return Intent.createChooser(emailIntent, resources.getString(R.string.email_picker_label)); 1393 } 1394 1395 /** 1396 * Example fake email addresses used as attendee emails are resources like conference rooms, 1397 * or another calendar, etc. These all end in "calendar.google.com". 1398 */ 1399 public static boolean isValidEmail(String email) { 1400 return email != null && !email.endsWith(MACHINE_GENERATED_ADDRESS); 1401 } 1402 1403 /** 1404 * Returns true if: 1405 * (1) the email is not a resource like a conference room or another calendar. 1406 * Catch most of these by filtering out suffix calendar.google.com. 1407 * (2) the email is not equal to the sync account to prevent mailing himself. 1408 */ 1409 public static boolean isEmailableFrom(String email, String syncAccountName) { 1410 return Utils.isValidEmail(email) && !email.equals(syncAccountName); 1411 } 1412 1413 /** 1414 * Inserts a drawable with today's day into the today's icon in the option menu 1415 * @param icon - today's icon from the options menu 1416 */ 1417 public static void setTodayIcon(LayerDrawable icon, Context c, String timezone) { 1418 DayOfMonthDrawable today; 1419 1420 // Reuse current drawable if possible 1421 Drawable currentDrawable = icon.findDrawableByLayerId(R.id.today_icon_day); 1422 if (currentDrawable != null && currentDrawable instanceof DayOfMonthDrawable) { 1423 today = (DayOfMonthDrawable)currentDrawable; 1424 } else { 1425 today = new DayOfMonthDrawable(c); 1426 } 1427 // Set the day and update the icon 1428 Time now = new Time(timezone); 1429 now.setToNow(); 1430 now.normalize(false); 1431 today.setDayOfMonth(now.monthDay); 1432 icon.mutate(); 1433 icon.setDrawableByLayerId(R.id.today_icon_day, today); 1434 } 1435 1436 private static class CalendarBroadcastReceiver extends BroadcastReceiver { 1437 1438 Runnable mCallBack; 1439 1440 public CalendarBroadcastReceiver(Runnable callback) { 1441 super(); 1442 mCallBack = callback; 1443 } 1444 @Override 1445 public void onReceive(Context context, Intent intent) { 1446 if (intent.getAction().equals(Intent.ACTION_DATE_CHANGED) || 1447 intent.getAction().equals(Intent.ACTION_TIME_CHANGED) || 1448 intent.getAction().equals(Intent.ACTION_LOCALE_CHANGED) || 1449 intent.getAction().equals(Intent.ACTION_TIMEZONE_CHANGED)) { 1450 if (mCallBack != null) { 1451 mCallBack.run(); 1452 } 1453 } 1454 } 1455 } 1456 1457 public static BroadcastReceiver setTimeChangesReceiver(Context c, Runnable callback) { 1458 IntentFilter filter = new IntentFilter(); 1459 filter.addAction(Intent.ACTION_TIME_CHANGED); 1460 filter.addAction(Intent.ACTION_DATE_CHANGED); 1461 filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); 1462 filter.addAction(Intent.ACTION_LOCALE_CHANGED); 1463 1464 CalendarBroadcastReceiver r = new CalendarBroadcastReceiver(callback); 1465 c.registerReceiver(r, filter); 1466 return r; 1467 } 1468 1469 public static void clearTimeChangesReceiver(Context c, BroadcastReceiver r) { 1470 c.unregisterReceiver(r); 1471 } 1472 1473 /** 1474 * Get a list of quick responses used for emailing guests from the 1475 * SharedPreferences. If not are found, get the hard coded ones that shipped 1476 * with the app 1477 * 1478 * @param context 1479 * @return a list of quick responses. 1480 */ 1481 public static String[] getQuickResponses(Context context) { 1482 String[] s = Utils.getSharedPreference(context, KEY_QUICK_RESPONSES, (String[]) null); 1483 1484 if (s == null) { 1485 s = context.getResources().getStringArray(R.array.quick_response_defaults); 1486 } 1487 1488 return s; 1489 } 1490 } 1491