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.accounts.Account; 22 import android.app.Activity; 23 import android.app.SearchManager; 24 import android.content.BroadcastReceiver; 25 import android.content.ComponentName; 26 import android.content.ContentResolver; 27 import android.content.Context; 28 import android.content.Intent; 29 import android.content.IntentFilter; 30 import android.content.SharedPreferences; 31 import android.content.pm.PackageManager; 32 import android.content.res.Resources; 33 import android.database.Cursor; 34 import android.database.MatrixCursor; 35 import android.graphics.Color; 36 import android.graphics.drawable.Drawable; 37 import android.graphics.drawable.LayerDrawable; 38 import android.net.Uri; 39 import android.os.Build; 40 import android.os.Bundle; 41 import android.os.Handler; 42 import android.provider.CalendarContract.Calendars; 43 import android.text.Spannable; 44 import android.text.SpannableString; 45 import android.text.Spanned; 46 import android.text.TextUtils; 47 import android.text.format.DateFormat; 48 import android.text.format.DateUtils; 49 import android.text.format.Time; 50 import android.text.style.URLSpan; 51 import android.text.util.Linkify; 52 import android.util.Log; 53 import android.widget.SearchView; 54 55 import com.android.calendar.CalendarController.ViewType; 56 import com.android.calendar.CalendarEventModel.ReminderEntry; 57 import com.android.calendar.CalendarUtils.TimeZoneUtils; 58 59 import java.util.ArrayList; 60 import java.util.Arrays; 61 import java.util.Calendar; 62 import java.util.Formatter; 63 import java.util.HashMap; 64 import java.util.Iterator; 65 import java.util.LinkedHashSet; 66 import java.util.LinkedList; 67 import java.util.List; 68 import java.util.Locale; 69 import java.util.Map; 70 import java.util.Set; 71 import java.util.TimeZone; 72 import java.util.regex.Matcher; 73 import java.util.regex.Pattern; 74 75 public class Utils { 76 private static final boolean DEBUG = false; 77 private static final String TAG = "CalUtils"; 78 79 // Set to 0 until we have UI to perform undo 80 public static final long UNDO_DELAY = 0; 81 82 // For recurring events which instances of the series are being modified 83 public static final int MODIFY_UNINITIALIZED = 0; 84 public static final int MODIFY_SELECTED = 1; 85 public static final int MODIFY_ALL_FOLLOWING = 2; 86 public static final int MODIFY_ALL = 3; 87 88 // When the edit event view finishes it passes back the appropriate exit 89 // code. 90 public static final int DONE_REVERT = 1 << 0; 91 public static final int DONE_SAVE = 1 << 1; 92 public static final int DONE_DELETE = 1 << 2; 93 // And should re run with DONE_EXIT if it should also leave the view, just 94 // exiting is identical to reverting 95 public static final int DONE_EXIT = 1 << 0; 96 97 public static final String OPEN_EMAIL_MARKER = " <"; 98 public static final String CLOSE_EMAIL_MARKER = ">"; 99 100 public static final String INTENT_KEY_DETAIL_VIEW = "DETAIL_VIEW"; 101 public static final String INTENT_KEY_VIEW_TYPE = "VIEW"; 102 public static final String INTENT_VALUE_VIEW_TYPE_DAY = "DAY"; 103 public static final String INTENT_KEY_HOME = "KEY_HOME"; 104 105 public static final int MONDAY_BEFORE_JULIAN_EPOCH = Time.EPOCH_JULIAN_DAY - 3; 106 public static final int DECLINED_EVENT_ALPHA = 0x66; 107 public static final int DECLINED_EVENT_TEXT_ALPHA = 0xC0; 108 109 private static final float SATURATION_ADJUST = 1.3f; 110 private static final float INTENSITY_ADJUST = 0.8f; 111 112 // Defines used by the DNA generation code 113 static final int DAY_IN_MINUTES = 60 * 24; 114 static final int WEEK_IN_MINUTES = DAY_IN_MINUTES * 7; 115 // The work day is being counted as 6am to 8pm 116 static int WORK_DAY_MINUTES = 14 * 60; 117 static int WORK_DAY_START_MINUTES = 6 * 60; 118 static int WORK_DAY_END_MINUTES = 20 * 60; 119 static int WORK_DAY_END_LENGTH = (24 * 60) - WORK_DAY_END_MINUTES; 120 static int CONFLICT_COLOR = 0xFF000000; 121 static boolean mMinutesLoaded = false; 122 123 public static final int YEAR_MIN = 1970; 124 public static final int YEAR_MAX = 2036; 125 126 // The name of the shared preferences file. This name must be maintained for 127 // historical 128 // reasons, as it's what PreferenceManager assigned the first time the file 129 // was created. 130 static final String SHARED_PREFS_NAME = "com.android.calendar_preferences"; 131 132 public static final String KEY_QUICK_RESPONSES = "preferences_quick_responses"; 133 134 public static final String KEY_ALERTS_VIBRATE_WHEN = "preferences_alerts_vibrateWhen"; 135 136 public static final String APPWIDGET_DATA_TYPE = "vnd.android.data/update"; 137 138 static final String MACHINE_GENERATED_ADDRESS = "calendar.google.com"; 139 140 private static final TimeZoneUtils mTZUtils = new TimeZoneUtils(SHARED_PREFS_NAME); 141 private static boolean mAllowWeekForDetailView = false; 142 private static long mTardis = 0; 143 private static String sVersion = null; 144 145 private static final Pattern mWildcardPattern = Pattern.compile("^.*$"); 146 147 /** 148 * A coordinate must be of the following form for Google Maps to correctly use it: 149 * Latitude, Longitude 150 * 151 * This may be in decimal form: 152 * Latitude: {-90 to 90} 153 * Longitude: {-180 to 180} 154 * 155 * Or, in degrees, minutes, and seconds: 156 * Latitude: {-90 to 90} {0 to 59}' {0 to 59}" 157 * Latitude: {-180 to 180} {0 to 59}' {0 to 59}" 158 * + or - degrees may also be represented with N or n, S or s for latitude, and with 159 * E or e, W or w for longitude, where the direction may either precede or follow the value. 160 * 161 * Some examples of coordinates that will be accepted by the regex: 162 * 37.422081, -122.084576 163 * 37.422081,-122.084576 164 * +3725'19.49", -1225'4.47" 165 * 3725'19.49"N, 1225'4.47"W 166 * N 37 25' 19.49", W 122 5' 4.47" 167 **/ 168 private static final String COORD_DEGREES_LATITUDE = 169 "([-+NnSs]" + "(\\s)*)?" 170 + "[1-9]?[0-9](\u00B0)" + "(\\s)*" 171 + "([1-5]?[0-9]\')?" + "(\\s)*" 172 + "([1-5]?[0-9]" + "(\\.[0-9]+)?\")?" 173 + "((\\s)*" + "[NnSs])?"; 174 private static final String COORD_DEGREES_LONGITUDE = 175 "([-+EeWw]" + "(\\s)*)?" 176 + "(1)?[0-9]?[0-9](\u00B0)" + "(\\s)*" 177 + "([1-5]?[0-9]\')?" + "(\\s)*" 178 + "([1-5]?[0-9]" + "(\\.[0-9]+)?\")?" 179 + "((\\s)*" + "[EeWw])?"; 180 private static final String COORD_DEGREES_PATTERN = 181 COORD_DEGREES_LATITUDE 182 + "(\\s)*" + "," + "(\\s)*" 183 + COORD_DEGREES_LONGITUDE; 184 private static final String COORD_DECIMAL_LATITUDE = 185 "[+-]?" 186 + "[1-9]?[0-9]" + "(\\.[0-9]+)" 187 + "(\u00B0)?"; 188 private static final String COORD_DECIMAL_LONGITUDE = 189 "[+-]?" 190 + "(1)?[0-9]?[0-9]" + "(\\.[0-9]+)" 191 + "(\u00B0)?"; 192 private static final String COORD_DECIMAL_PATTERN = 193 COORD_DECIMAL_LATITUDE 194 + "(\\s)*" + "," + "(\\s)*" 195 + COORD_DECIMAL_LONGITUDE; 196 private static final Pattern COORD_PATTERN = 197 Pattern.compile(COORD_DEGREES_PATTERN + "|" + COORD_DECIMAL_PATTERN); 198 199 private static final String NANP_ALLOWED_SYMBOLS = "()+-*#."; 200 private static final int NANP_MIN_DIGITS = 7; 201 private static final int NANP_MAX_DIGITS = 11; 202 203 204 /** 205 * Returns whether the SDK is the Jellybean release or later. 206 */ 207 public static boolean isJellybeanOrLater() { 208 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; 209 } 210 211 /** 212 * Returns whether the SDK is the KeyLimePie release or later. 213 */ 214 public static boolean isKeyLimePieOrLater() { 215 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; 216 } 217 218 public static int getViewTypeFromIntentAndSharedPref(Activity activity) { 219 Intent intent = activity.getIntent(); 220 Bundle extras = intent.getExtras(); 221 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(activity); 222 223 if (TextUtils.equals(intent.getAction(), Intent.ACTION_EDIT)) { 224 return ViewType.EDIT; 225 } 226 if (extras != null) { 227 if (extras.getBoolean(INTENT_KEY_DETAIL_VIEW, false)) { 228 // This is the "detail" view which is either agenda or day view 229 return prefs.getInt(GeneralPreferences.KEY_DETAILED_VIEW, 230 GeneralPreferences.DEFAULT_DETAILED_VIEW); 231 } else if (INTENT_VALUE_VIEW_TYPE_DAY.equals(extras.getString(INTENT_KEY_VIEW_TYPE))) { 232 // Not sure who uses this. This logic came from LaunchActivity 233 return ViewType.DAY; 234 } 235 } 236 237 // Default to the last view 238 return prefs.getInt( 239 GeneralPreferences.KEY_START_VIEW, GeneralPreferences.DEFAULT_START_VIEW); 240 } 241 242 /** 243 * Gets the intent action for telling the widget to update. 244 */ 245 public static String getWidgetUpdateAction(Context context) { 246 return context.getPackageName() + ".APPWIDGET_UPDATE"; 247 } 248 249 /** 250 * Gets the intent action for telling the widget to update. 251 */ 252 public static String getWidgetScheduledUpdateAction(Context context) { 253 return context.getPackageName() + ".APPWIDGET_SCHEDULED_UPDATE"; 254 } 255 256 /** 257 * Gets the intent action for telling the widget to update. 258 */ 259 public static String getSearchAuthority(Context context) { 260 return context.getPackageName() + ".CalendarRecentSuggestionsProvider"; 261 } 262 263 /** 264 * Writes a new home time zone to the db. Updates the home time zone in the 265 * db asynchronously and updates the local cache. Sending a time zone of 266 * **tbd** will cause it to be set to the device's time zone. null or empty 267 * tz will be ignored. 268 * 269 * @param context The calling activity 270 * @param timeZone The time zone to set Calendar to, or **tbd** 271 */ 272 public static void setTimeZone(Context context, String timeZone) { 273 mTZUtils.setTimeZone(context, timeZone); 274 } 275 276 /** 277 * Gets the time zone that Calendar should be displayed in This is a helper 278 * method to get the appropriate time zone for Calendar. If this is the 279 * first time this method has been called it will initiate an asynchronous 280 * query to verify that the data in preferences is correct. The callback 281 * supplied will only be called if this query returns a value other than 282 * what is stored in preferences and should cause the calling activity to 283 * refresh anything that depends on calling this method. 284 * 285 * @param context The calling activity 286 * @param callback The runnable that should execute if a query returns new 287 * values 288 * @return The string value representing the time zone Calendar should 289 * display 290 */ 291 public static String getTimeZone(Context context, Runnable callback) { 292 return mTZUtils.getTimeZone(context, callback); 293 } 294 295 /** 296 * Formats a date or a time range according to the local conventions. 297 * 298 * @param context the context is required only if the time is shown 299 * @param startMillis the start time in UTC milliseconds 300 * @param endMillis the end time in UTC milliseconds 301 * @param flags a bit mask of options See {@link DateUtils#formatDateRange(Context, Formatter, 302 * long, long, int, String) formatDateRange} 303 * @return a string containing the formatted date/time range. 304 */ 305 public static String formatDateRange( 306 Context context, long startMillis, long endMillis, int flags) { 307 return mTZUtils.formatDateRange(context, startMillis, endMillis, flags); 308 } 309 310 public static boolean getDefaultVibrate(Context context, SharedPreferences prefs) { 311 boolean vibrate; 312 if (prefs.contains(KEY_ALERTS_VIBRATE_WHEN)) { 313 // Migrate setting to new 4.2 behavior 314 // 315 // silent and never -> off 316 // always -> on 317 String vibrateWhen = prefs.getString(KEY_ALERTS_VIBRATE_WHEN, null); 318 vibrate = vibrateWhen != null && vibrateWhen.equals(context 319 .getString(R.string.prefDefault_alerts_vibrate_true)); 320 prefs.edit().remove(KEY_ALERTS_VIBRATE_WHEN).commit(); 321 Log.d(TAG, "Migrating KEY_ALERTS_VIBRATE_WHEN(" + vibrateWhen 322 + ") to KEY_ALERTS_VIBRATE = " + vibrate); 323 } else { 324 vibrate = prefs.getBoolean(GeneralPreferences.KEY_ALERTS_VIBRATE, 325 false); 326 } 327 return vibrate; 328 } 329 330 public static String[] getSharedPreference(Context context, String key, String[] defaultValue) { 331 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 332 Set<String> ss = prefs.getStringSet(key, null); 333 if (ss != null) { 334 String strings[] = new String[ss.size()]; 335 return ss.toArray(strings); 336 } 337 return defaultValue; 338 } 339 340 public static String getSharedPreference(Context context, String key, String defaultValue) { 341 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 342 return prefs.getString(key, defaultValue); 343 } 344 345 public static int getSharedPreference(Context context, String key, int defaultValue) { 346 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 347 return prefs.getInt(key, defaultValue); 348 } 349 350 public static boolean getSharedPreference(Context context, String key, boolean defaultValue) { 351 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 352 return prefs.getBoolean(key, defaultValue); 353 } 354 355 /** 356 * Asynchronously sets the preference with the given key to the given value 357 * 358 * @param context the context to use to get preferences from 359 * @param key the key of the preference to set 360 * @param value the value to set 361 */ 362 public static void setSharedPreference(Context context, String key, String value) { 363 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 364 prefs.edit().putString(key, value).apply(); 365 } 366 367 public static void setSharedPreference(Context context, String key, String[] values) { 368 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 369 LinkedHashSet<String> set = new LinkedHashSet<String>(); 370 for (String value : values) { 371 set.add(value); 372 } 373 prefs.edit().putStringSet(key, set).apply(); 374 } 375 376 protected static void tardis() { 377 mTardis = System.currentTimeMillis(); 378 } 379 380 protected static long getTardis() { 381 return mTardis; 382 } 383 384 public static void setSharedPreference(Context context, String key, boolean value) { 385 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 386 SharedPreferences.Editor editor = prefs.edit(); 387 editor.putBoolean(key, value); 388 editor.apply(); 389 } 390 391 static void setSharedPreference(Context context, String key, int value) { 392 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 393 SharedPreferences.Editor editor = prefs.edit(); 394 editor.putInt(key, value); 395 editor.apply(); 396 } 397 398 public static void removeSharedPreference(Context context, String key) { 399 SharedPreferences prefs = context.getSharedPreferences( 400 GeneralPreferences.SHARED_PREFS_NAME, Context.MODE_PRIVATE); 401 prefs.edit().remove(key).apply(); 402 } 403 404 // The backed up ring tone preference should not used because it is a device 405 // specific Uri. The preference now lives in a separate non-backed-up 406 // shared_pref file (SHARED_PREFS_NAME_NO_BACKUP). The preference in the old 407 // backed-up shared_pref file (SHARED_PREFS_NAME) is used only to control the 408 // default value when the ringtone dialog opens up. 409 // 410 // At backup manager "restore" time (which should happen before launcher 411 // comes up for the first time), the value will be set/reset to default 412 // ringtone. 413 public static String getRingTonePreference(Context context) { 414 SharedPreferences prefs = context.getSharedPreferences( 415 GeneralPreferences.SHARED_PREFS_NAME_NO_BACKUP, Context.MODE_PRIVATE); 416 String ringtone = prefs.getString(GeneralPreferences.KEY_ALERTS_RINGTONE, null); 417 418 // If it hasn't been populated yet, that means new code is running for 419 // the first time and restore hasn't happened. Migrate value from 420 // backed-up shared_pref to non-shared_pref. 421 if (ringtone == null) { 422 // Read from the old place with a default of DEFAULT_RINGTONE 423 ringtone = getSharedPreference(context, GeneralPreferences.KEY_ALERTS_RINGTONE, 424 GeneralPreferences.DEFAULT_RINGTONE); 425 426 // Write it to the new place 427 setRingTonePreference(context, ringtone); 428 } 429 430 return ringtone; 431 } 432 433 public static void setRingTonePreference(Context context, String value) { 434 SharedPreferences prefs = context.getSharedPreferences( 435 GeneralPreferences.SHARED_PREFS_NAME_NO_BACKUP, Context.MODE_PRIVATE); 436 prefs.edit().putString(GeneralPreferences.KEY_ALERTS_RINGTONE, value).apply(); 437 } 438 439 /** 440 * Save default agenda/day/week/month view for next time 441 * 442 * @param context 443 * @param viewId {@link CalendarController.ViewType} 444 */ 445 static void setDefaultView(Context context, int viewId) { 446 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 447 SharedPreferences.Editor editor = prefs.edit(); 448 449 boolean validDetailView = false; 450 if (mAllowWeekForDetailView && viewId == CalendarController.ViewType.WEEK) { 451 validDetailView = true; 452 } else { 453 validDetailView = viewId == CalendarController.ViewType.AGENDA 454 || viewId == CalendarController.ViewType.DAY; 455 } 456 457 if (validDetailView) { 458 // Record the detail start view 459 editor.putInt(GeneralPreferences.KEY_DETAILED_VIEW, viewId); 460 } 461 462 // Record the (new) start view 463 editor.putInt(GeneralPreferences.KEY_START_VIEW, viewId); 464 editor.apply(); 465 } 466 467 public static MatrixCursor matrixCursorFromCursor(Cursor cursor) { 468 if (cursor == null) { 469 return null; 470 } 471 472 String[] columnNames = cursor.getColumnNames(); 473 if (columnNames == null) { 474 columnNames = new String[] {}; 475 } 476 MatrixCursor newCursor = new MatrixCursor(columnNames); 477 int numColumns = cursor.getColumnCount(); 478 String data[] = new String[numColumns]; 479 cursor.moveToPosition(-1); 480 while (cursor.moveToNext()) { 481 for (int i = 0; i < numColumns; i++) { 482 data[i] = cursor.getString(i); 483 } 484 newCursor.addRow(data); 485 } 486 return newCursor; 487 } 488 489 /** 490 * Compares two cursors to see if they contain the same data. 491 * 492 * @return Returns true of the cursors contain the same data and are not 493 * null, false otherwise 494 */ 495 public static boolean compareCursors(Cursor c1, Cursor c2) { 496 if (c1 == null || c2 == null) { 497 return false; 498 } 499 500 int numColumns = c1.getColumnCount(); 501 if (numColumns != c2.getColumnCount()) { 502 return false; 503 } 504 505 if (c1.getCount() != c2.getCount()) { 506 return false; 507 } 508 509 c1.moveToPosition(-1); 510 c2.moveToPosition(-1); 511 while (c1.moveToNext() && c2.moveToNext()) { 512 for (int i = 0; i < numColumns; i++) { 513 if (!TextUtils.equals(c1.getString(i), c2.getString(i))) { 514 return false; 515 } 516 } 517 } 518 519 return true; 520 } 521 522 /** 523 * If the given intent specifies a time (in milliseconds since the epoch), 524 * then that time is returned. Otherwise, the current time is returned. 525 */ 526 public static final long timeFromIntentInMillis(Intent intent) { 527 // If the time was specified, then use that. Otherwise, use the current 528 // time. 529 Uri data = intent.getData(); 530 long millis = intent.getLongExtra(EXTRA_EVENT_BEGIN_TIME, -1); 531 if (millis == -1 && data != null && data.isHierarchical()) { 532 List<String> path = data.getPathSegments(); 533 if (path.size() == 2 && path.get(0).equals("time")) { 534 try { 535 millis = Long.valueOf(data.getLastPathSegment()); 536 } catch (NumberFormatException e) { 537 Log.i("Calendar", "timeFromIntentInMillis: Data existed but no valid time " 538 + "found. Using current time."); 539 } 540 } 541 } 542 if (millis <= 0) { 543 millis = System.currentTimeMillis(); 544 } 545 return millis; 546 } 547 548 /** 549 * Formats the given Time object so that it gives the month and year (for 550 * example, "September 2007"). 551 * 552 * @param time the time to format 553 * @return the string containing the weekday and the date 554 */ 555 public static String formatMonthYear(Context context, Time time) { 556 int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_MONTH_DAY 557 | DateUtils.FORMAT_SHOW_YEAR; 558 long millis = time.toMillis(true); 559 return formatDateRange(context, millis, millis, flags); 560 } 561 562 /** 563 * Returns a list joined together by the provided delimiter, for example, 564 * ["a", "b", "c"] could be joined into "a,b,c" 565 * 566 * @param things the things to join together 567 * @param delim the delimiter to use 568 * @return a string contained the things joined together 569 */ 570 public static String join(List<?> things, String delim) { 571 StringBuilder builder = new StringBuilder(); 572 boolean first = true; 573 for (Object thing : things) { 574 if (first) { 575 first = false; 576 } else { 577 builder.append(delim); 578 } 579 builder.append(thing.toString()); 580 } 581 return builder.toString(); 582 } 583 584 /** 585 * Returns the week since {@link Time#EPOCH_JULIAN_DAY} (Jan 1, 1970) 586 * adjusted for first day of week. 587 * 588 * This takes a julian day and the week start day and calculates which 589 * week since {@link Time#EPOCH_JULIAN_DAY} that day occurs in, starting 590 * at 0. *Do not* use this to compute the ISO week number for the year. 591 * 592 * @param julianDay The julian day to calculate the week number for 593 * @param firstDayOfWeek Which week day is the first day of the week, 594 * see {@link Time#SUNDAY} 595 * @return Weeks since the epoch 596 */ 597 public static int getWeeksSinceEpochFromJulianDay(int julianDay, int firstDayOfWeek) { 598 int diff = Time.THURSDAY - firstDayOfWeek; 599 if (diff < 0) { 600 diff += 7; 601 } 602 int refDay = Time.EPOCH_JULIAN_DAY - diff; 603 return (julianDay - refDay) / 7; 604 } 605 606 /** 607 * Takes a number of weeks since the epoch and calculates the Julian day of 608 * the Monday for that week. 609 * 610 * This assumes that the week containing the {@link Time#EPOCH_JULIAN_DAY} 611 * is considered week 0. It returns the Julian day for the Monday 612 * {@code week} weeks after the Monday of the week containing the epoch. 613 * 614 * @param week Number of weeks since the epoch 615 * @return The julian day for the Monday of the given week since the epoch 616 */ 617 public static int getJulianMondayFromWeeksSinceEpoch(int week) { 618 return MONDAY_BEFORE_JULIAN_EPOCH + week * 7; 619 } 620 621 /** 622 * Get first day of week as android.text.format.Time constant. 623 * 624 * @return the first day of week in android.text.format.Time 625 */ 626 public static int getFirstDayOfWeek(Context context) { 627 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 628 String pref = prefs.getString( 629 GeneralPreferences.KEY_WEEK_START_DAY, GeneralPreferences.WEEK_START_DEFAULT); 630 631 int startDay; 632 if (GeneralPreferences.WEEK_START_DEFAULT.equals(pref)) { 633 startDay = Calendar.getInstance().getFirstDayOfWeek(); 634 } else { 635 startDay = Integer.parseInt(pref); 636 } 637 638 if (startDay == Calendar.SATURDAY) { 639 return Time.SATURDAY; 640 } else if (startDay == Calendar.MONDAY) { 641 return Time.MONDAY; 642 } else { 643 return Time.SUNDAY; 644 } 645 } 646 647 /** 648 * Get first day of week as java.util.Calendar constant. 649 * 650 * @return the first day of week as a java.util.Calendar constant 651 */ 652 public static int getFirstDayOfWeekAsCalendar(Context context) { 653 return convertDayOfWeekFromTimeToCalendar(getFirstDayOfWeek(context)); 654 } 655 656 /** 657 * Converts the day of the week from android.text.format.Time to java.util.Calendar 658 */ 659 public static int convertDayOfWeekFromTimeToCalendar(int timeDayOfWeek) { 660 switch (timeDayOfWeek) { 661 case Time.MONDAY: 662 return Calendar.MONDAY; 663 case Time.TUESDAY: 664 return Calendar.TUESDAY; 665 case Time.WEDNESDAY: 666 return Calendar.WEDNESDAY; 667 case Time.THURSDAY: 668 return Calendar.THURSDAY; 669 case Time.FRIDAY: 670 return Calendar.FRIDAY; 671 case Time.SATURDAY: 672 return Calendar.SATURDAY; 673 case Time.SUNDAY: 674 return Calendar.SUNDAY; 675 default: 676 throw new IllegalArgumentException("Argument must be between Time.SUNDAY and " + 677 "Time.SATURDAY"); 678 } 679 } 680 681 /** 682 * @return true when week number should be shown. 683 */ 684 public static boolean getShowWeekNumber(Context context) { 685 final SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 686 return prefs.getBoolean( 687 GeneralPreferences.KEY_SHOW_WEEK_NUM, GeneralPreferences.DEFAULT_SHOW_WEEK_NUM); 688 } 689 690 /** 691 * @return true when declined events should be hidden. 692 */ 693 public static boolean getHideDeclinedEvents(Context context) { 694 final SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 695 return prefs.getBoolean(GeneralPreferences.KEY_HIDE_DECLINED, false); 696 } 697 698 public static int getDaysPerWeek(Context context) { 699 final SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 700 return prefs.getInt(GeneralPreferences.KEY_DAYS_PER_WEEK, 7); 701 } 702 703 /** 704 * Determine whether the column position is Saturday or not. 705 * 706 * @param column the column position 707 * @param firstDayOfWeek the first day of week in android.text.format.Time 708 * @return true if the column is Saturday position 709 */ 710 public static boolean isSaturday(int column, int firstDayOfWeek) { 711 return (firstDayOfWeek == Time.SUNDAY && column == 6) 712 || (firstDayOfWeek == Time.MONDAY && column == 5) 713 || (firstDayOfWeek == Time.SATURDAY && column == 0); 714 } 715 716 /** 717 * Determine whether the column position is Sunday or not. 718 * 719 * @param column the column position 720 * @param firstDayOfWeek the first day of week in android.text.format.Time 721 * @return true if the column is Sunday position 722 */ 723 public static boolean isSunday(int column, int firstDayOfWeek) { 724 return (firstDayOfWeek == Time.SUNDAY && column == 0) 725 || (firstDayOfWeek == Time.MONDAY && column == 6) 726 || (firstDayOfWeek == Time.SATURDAY && column == 1); 727 } 728 729 /** 730 * Convert given UTC time into current local time. This assumes it is for an 731 * allday event and will adjust the time to be on a midnight boundary. 732 * 733 * @param recycle Time object to recycle, otherwise null. 734 * @param utcTime Time to convert, in UTC. 735 * @param tz The time zone to convert this time to. 736 */ 737 public static long convertAlldayUtcToLocal(Time recycle, long utcTime, String tz) { 738 if (recycle == null) { 739 recycle = new Time(); 740 } 741 recycle.timezone = Time.TIMEZONE_UTC; 742 recycle.set(utcTime); 743 recycle.timezone = tz; 744 return recycle.normalize(true); 745 } 746 747 public static long convertAlldayLocalToUTC(Time recycle, long localTime, String tz) { 748 if (recycle == null) { 749 recycle = new Time(); 750 } 751 recycle.timezone = tz; 752 recycle.set(localTime); 753 recycle.timezone = Time.TIMEZONE_UTC; 754 return recycle.normalize(true); 755 } 756 757 /** 758 * Finds and returns the next midnight after "theTime" in milliseconds UTC 759 * 760 * @param recycle - Time object to recycle, otherwise null. 761 * @param theTime - Time used for calculations (in UTC) 762 * @param tz The time zone to convert this time to. 763 */ 764 public static long getNextMidnight(Time recycle, long theTime, String tz) { 765 if (recycle == null) { 766 recycle = new Time(); 767 } 768 recycle.timezone = tz; 769 recycle.set(theTime); 770 recycle.monthDay ++; 771 recycle.hour = 0; 772 recycle.minute = 0; 773 recycle.second = 0; 774 return recycle.normalize(true); 775 } 776 777 /** 778 * Scan through a cursor of calendars and check if names are duplicated. 779 * This travels a cursor containing calendar display names and fills in the 780 * provided map with whether or not each name is repeated. 781 * 782 * @param isDuplicateName The map to put the duplicate check results in. 783 * @param cursor The query of calendars to check 784 * @param nameIndex The column of the query that contains the display name 785 */ 786 public static void checkForDuplicateNames( 787 Map<String, Boolean> isDuplicateName, Cursor cursor, int nameIndex) { 788 isDuplicateName.clear(); 789 cursor.moveToPosition(-1); 790 while (cursor.moveToNext()) { 791 String displayName = cursor.getString(nameIndex); 792 // Set it to true if we've seen this name before, false otherwise 793 if (displayName != null) { 794 isDuplicateName.put(displayName, isDuplicateName.containsKey(displayName)); 795 } 796 } 797 } 798 799 /** 800 * Null-safe object comparison 801 * 802 * @param s1 803 * @param s2 804 * @return 805 */ 806 public static boolean equals(Object o1, Object o2) { 807 return o1 == null ? o2 == null : o1.equals(o2); 808 } 809 810 public static void setAllowWeekForDetailView(boolean allowWeekView) { 811 mAllowWeekForDetailView = allowWeekView; 812 } 813 814 public static boolean getAllowWeekForDetailView() { 815 return mAllowWeekForDetailView; 816 } 817 818 public static boolean getConfigBool(Context c, int key) { 819 return c.getResources().getBoolean(key); 820 } 821 822 /** 823 * For devices with Jellybean or later, darkens the given color to ensure that white text is 824 * clearly visible on top of it. For devices prior to Jellybean, does nothing, as the 825 * sync adapter handles the color change. 826 * 827 * @param color 828 */ 829 public static int getDisplayColorFromColor(int color) { 830 if (!isJellybeanOrLater()) { 831 return color; 832 } 833 834 float[] hsv = new float[3]; 835 Color.colorToHSV(color, hsv); 836 hsv[1] = Math.min(hsv[1] * SATURATION_ADJUST, 1.0f); 837 hsv[2] = hsv[2] * INTENSITY_ADJUST; 838 return Color.HSVToColor(hsv); 839 } 840 841 // This takes a color and computes what it would look like blended with 842 // white. The result is the color that should be used for declined events. 843 public static int getDeclinedColorFromColor(int color) { 844 int bg = 0xffffffff; 845 int a = DECLINED_EVENT_ALPHA; 846 int r = (((color & 0x00ff0000) * a) + ((bg & 0x00ff0000) * (0xff - a))) & 0xff000000; 847 int g = (((color & 0x0000ff00) * a) + ((bg & 0x0000ff00) * (0xff - a))) & 0x00ff0000; 848 int b = (((color & 0x000000ff) * a) + ((bg & 0x000000ff) * (0xff - a))) & 0x0000ff00; 849 return (0xff000000) | ((r | g | b) >> 8); 850 } 851 852 public static void trySyncAndDisableUpgradeReceiver(Context context) { 853 final PackageManager pm = context.getPackageManager(); 854 ComponentName upgradeComponent = new ComponentName(context, UpgradeReceiver.class); 855 if (pm.getComponentEnabledSetting(upgradeComponent) == 856 PackageManager.COMPONENT_ENABLED_STATE_DISABLED) { 857 // The upgrade receiver has been disabled, which means this code has been run before, 858 // so no need to sync. 859 return; 860 } 861 862 Bundle extras = new Bundle(); 863 extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); 864 ContentResolver.requestSync( 865 null /* no account */, 866 Calendars.CONTENT_URI.getAuthority(), 867 extras); 868 869 // Now unregister the receiver so that we won't continue to sync every time. 870 pm.setComponentEnabledSetting(upgradeComponent, 871 PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); 872 } 873 874 // A single strand represents one color of events. Events are divided up by 875 // color to make them convenient to draw. The black strand is special in 876 // that it holds conflicting events as well as color settings for allday on 877 // each day. 878 public static class DNAStrand { 879 public float[] points; 880 public int[] allDays; // color for the allday, 0 means no event 881 int position; 882 public int color; 883 int count; 884 } 885 886 // A segment is a single continuous length of time occupied by a single 887 // color. Segments should never span multiple days. 888 private static class DNASegment { 889 int startMinute; // in minutes since the start of the week 890 int endMinute; 891 int color; // Calendar color or black for conflicts 892 int day; // quick reference to the day this segment is on 893 } 894 895 /** 896 * Converts a list of events to a list of segments to draw. Assumes list is 897 * ordered by start time of the events. The function processes events for a 898 * range of days from firstJulianDay to firstJulianDay + dayXs.length - 1. 899 * The algorithm goes over all the events and creates a set of segments 900 * ordered by start time. This list of segments is then converted into a 901 * HashMap of strands which contain the draw points and are organized by 902 * color. The strands can then be drawn by setting the paint color to each 903 * strand's color and calling drawLines on its set of points. The points are 904 * set up using the following parameters. 905 * <ul> 906 * <li>Events between midnight and WORK_DAY_START_MINUTES are compressed 907 * into the first 1/8th of the space between top and bottom.</li> 908 * <li>Events between WORK_DAY_END_MINUTES and the following midnight are 909 * compressed into the last 1/8th of the space between top and bottom</li> 910 * <li>Events between WORK_DAY_START_MINUTES and WORK_DAY_END_MINUTES use 911 * the remaining 3/4ths of the space</li> 912 * <li>All segments drawn will maintain at least minPixels height, except 913 * for conflicts in the first or last 1/8th, which may be smaller</li> 914 * </ul> 915 * 916 * @param firstJulianDay The julian day of the first day of events 917 * @param events A list of events sorted by start time 918 * @param top The lowest y value the dna should be drawn at 919 * @param bottom The highest y value the dna should be drawn at 920 * @param dayXs An array of x values to draw the dna at, one for each day 921 * @param conflictColor the color to use for conflicts 922 * @return 923 */ 924 public static HashMap<Integer, DNAStrand> createDNAStrands(int firstJulianDay, 925 ArrayList<Event> events, int top, int bottom, int minPixels, int[] dayXs, 926 Context context) { 927 928 if (!mMinutesLoaded) { 929 if (context == null) { 930 Log.wtf(TAG, "No context and haven't loaded parameters yet! Can't create DNA."); 931 } 932 Resources res = context.getResources(); 933 CONFLICT_COLOR = res.getColor(R.color.month_dna_conflict_time_color); 934 WORK_DAY_START_MINUTES = res.getInteger(R.integer.work_start_minutes); 935 WORK_DAY_END_MINUTES = res.getInteger(R.integer.work_end_minutes); 936 WORK_DAY_END_LENGTH = DAY_IN_MINUTES - WORK_DAY_END_MINUTES; 937 WORK_DAY_MINUTES = WORK_DAY_END_MINUTES - WORK_DAY_START_MINUTES; 938 mMinutesLoaded = true; 939 } 940 941 if (events == null || events.isEmpty() || dayXs == null || dayXs.length < 1 942 || bottom - top < 8 || minPixels < 0) { 943 Log.e(TAG, 944 "Bad values for createDNAStrands! events:" + events + " dayXs:" 945 + Arrays.toString(dayXs) + " bot-top:" + (bottom - top) + " minPixels:" 946 + minPixels); 947 return null; 948 } 949 950 LinkedList<DNASegment> segments = new LinkedList<DNASegment>(); 951 HashMap<Integer, DNAStrand> strands = new HashMap<Integer, DNAStrand>(); 952 // add a black strand by default, other colors will get added in 953 // the loop 954 DNAStrand blackStrand = new DNAStrand(); 955 blackStrand.color = CONFLICT_COLOR; 956 strands.put(CONFLICT_COLOR, blackStrand); 957 // the min length is the number of minutes that will occupy 958 // MIN_SEGMENT_PIXELS in the 'work day' time slot. This computes the 959 // minutes/pixel * minpx where the number of pixels are 3/4 the total 960 // dna height: 4*(mins/(px * 3/4)) 961 int minMinutes = minPixels * 4 * WORK_DAY_MINUTES / (3 * (bottom - top)); 962 963 // There are slightly fewer than half as many pixels in 1/6 the space, 964 // so round to 2.5x for the min minutes in the non-work area 965 int minOtherMinutes = minMinutes * 5 / 2; 966 int lastJulianDay = firstJulianDay + dayXs.length - 1; 967 968 Event event = new Event(); 969 // Go through all the events for the week 970 for (Event currEvent : events) { 971 // if this event is outside the weeks range skip it 972 if (currEvent.endDay < firstJulianDay || currEvent.startDay > lastJulianDay) { 973 continue; 974 } 975 if (currEvent.drawAsAllday()) { 976 addAllDayToStrands(currEvent, strands, firstJulianDay, dayXs.length); 977 continue; 978 } 979 // Copy the event over so we can clip its start and end to our range 980 currEvent.copyTo(event); 981 if (event.startDay < firstJulianDay) { 982 event.startDay = firstJulianDay; 983 event.startTime = 0; 984 } 985 // If it starts after the work day make sure the start is at least 986 // minPixels from midnight 987 if (event.startTime > DAY_IN_MINUTES - minOtherMinutes) { 988 event.startTime = DAY_IN_MINUTES - minOtherMinutes; 989 } 990 if (event.endDay > lastJulianDay) { 991 event.endDay = lastJulianDay; 992 event.endTime = DAY_IN_MINUTES - 1; 993 } 994 // If the end time is before the work day make sure it ends at least 995 // minPixels after midnight 996 if (event.endTime < minOtherMinutes) { 997 event.endTime = minOtherMinutes; 998 } 999 // If the start and end are on the same day make sure they are at 1000 // least minPixels apart. This only needs to be done for times 1001 // outside the work day as the min distance for within the work day 1002 // is enforced in the segment code. 1003 if (event.startDay == event.endDay && 1004 event.endTime - event.startTime < minOtherMinutes) { 1005 // If it's less than minPixels in an area before the work 1006 // day 1007 if (event.startTime < WORK_DAY_START_MINUTES) { 1008 // extend the end to the first easy guarantee that it's 1009 // minPixels 1010 event.endTime = Math.min(event.startTime + minOtherMinutes, 1011 WORK_DAY_START_MINUTES + minMinutes); 1012 // if it's in the area after the work day 1013 } else if (event.endTime > WORK_DAY_END_MINUTES) { 1014 // First try shifting the end but not past midnight 1015 event.endTime = Math.min(event.endTime + minOtherMinutes, DAY_IN_MINUTES - 1); 1016 // if it's still too small move the start back 1017 if (event.endTime - event.startTime < minOtherMinutes) { 1018 event.startTime = event.endTime - minOtherMinutes; 1019 } 1020 } 1021 } 1022 1023 // This handles adding the first segment 1024 if (segments.size() == 0) { 1025 addNewSegment(segments, event, strands, firstJulianDay, 0, minMinutes); 1026 continue; 1027 } 1028 // Now compare our current start time to the end time of the last 1029 // segment in the list 1030 DNASegment lastSegment = segments.getLast(); 1031 int startMinute = (event.startDay - firstJulianDay) * DAY_IN_MINUTES + event.startTime; 1032 int endMinute = Math.max((event.endDay - firstJulianDay) * DAY_IN_MINUTES 1033 + event.endTime, startMinute + minMinutes); 1034 1035 if (startMinute < 0) { 1036 startMinute = 0; 1037 } 1038 if (endMinute >= WEEK_IN_MINUTES) { 1039 endMinute = WEEK_IN_MINUTES - 1; 1040 } 1041 // If we start before the last segment in the list ends we need to 1042 // start going through the list as this may conflict with other 1043 // events 1044 if (startMinute < lastSegment.endMinute) { 1045 int i = segments.size(); 1046 // find the last segment this event intersects with 1047 while (--i >= 0 && endMinute < segments.get(i).startMinute); 1048 1049 DNASegment currSegment; 1050 // for each segment this event intersects with 1051 for (; i >= 0 && startMinute <= (currSegment = segments.get(i)).endMinute; i--) { 1052 // if the segment is already a conflict ignore it 1053 if (currSegment.color == CONFLICT_COLOR) { 1054 continue; 1055 } 1056 // if the event ends before the segment and wouldn't create 1057 // a segment that is too small split off the right side 1058 if (endMinute < currSegment.endMinute - minMinutes) { 1059 DNASegment rhs = new DNASegment(); 1060 rhs.endMinute = currSegment.endMinute; 1061 rhs.color = currSegment.color; 1062 rhs.startMinute = endMinute + 1; 1063 rhs.day = currSegment.day; 1064 currSegment.endMinute = endMinute; 1065 segments.add(i + 1, rhs); 1066 strands.get(rhs.color).count++; 1067 if (DEBUG) { 1068 Log.d(TAG, "Added rhs, curr:" + currSegment.toString() + " i:" 1069 + segments.get(i).toString()); 1070 } 1071 } 1072 // if the event starts after the segment and wouldn't create 1073 // a segment that is too small split off the left side 1074 if (startMinute > currSegment.startMinute + minMinutes) { 1075 DNASegment lhs = new DNASegment(); 1076 lhs.startMinute = currSegment.startMinute; 1077 lhs.color = currSegment.color; 1078 lhs.endMinute = startMinute - 1; 1079 lhs.day = currSegment.day; 1080 currSegment.startMinute = startMinute; 1081 // increment i so that we are at the right position when 1082 // referencing the segments to the right and left of the 1083 // current segment. 1084 segments.add(i++, lhs); 1085 strands.get(lhs.color).count++; 1086 if (DEBUG) { 1087 Log.d(TAG, "Added lhs, curr:" + currSegment.toString() + " i:" 1088 + segments.get(i).toString()); 1089 } 1090 } 1091 // if the right side is black merge this with the segment to 1092 // the right if they're on the same day and overlap 1093 if (i + 1 < segments.size()) { 1094 DNASegment rhs = segments.get(i + 1); 1095 if (rhs.color == CONFLICT_COLOR && currSegment.day == rhs.day 1096 && rhs.startMinute <= currSegment.endMinute + 1) { 1097 rhs.startMinute = Math.min(currSegment.startMinute, rhs.startMinute); 1098 segments.remove(currSegment); 1099 strands.get(currSegment.color).count--; 1100 // point at the new current segment 1101 currSegment = rhs; 1102 } 1103 } 1104 // if the left side is black merge this with the segment to 1105 // the left if they're on the same day and overlap 1106 if (i - 1 >= 0) { 1107 DNASegment lhs = segments.get(i - 1); 1108 if (lhs.color == CONFLICT_COLOR && currSegment.day == lhs.day 1109 && lhs.endMinute >= currSegment.startMinute - 1) { 1110 lhs.endMinute = Math.max(currSegment.endMinute, lhs.endMinute); 1111 segments.remove(currSegment); 1112 strands.get(currSegment.color).count--; 1113 // point at the new current segment 1114 currSegment = lhs; 1115 // point i at the new current segment in case new 1116 // code is added 1117 i--; 1118 } 1119 } 1120 // if we're still not black, decrement the count for the 1121 // color being removed, change this to black, and increment 1122 // the black count 1123 if (currSegment.color != CONFLICT_COLOR) { 1124 strands.get(currSegment.color).count--; 1125 currSegment.color = CONFLICT_COLOR; 1126 strands.get(CONFLICT_COLOR).count++; 1127 } 1128 } 1129 1130 } 1131 // If this event extends beyond the last segment add a new segment 1132 if (endMinute > lastSegment.endMinute) { 1133 addNewSegment(segments, event, strands, firstJulianDay, lastSegment.endMinute, 1134 minMinutes); 1135 } 1136 } 1137 weaveDNAStrands(segments, firstJulianDay, strands, top, bottom, dayXs); 1138 return strands; 1139 } 1140 1141 // This figures out allDay colors as allDay events are found 1142 private static void addAllDayToStrands(Event event, HashMap<Integer, DNAStrand> strands, 1143 int firstJulianDay, int numDays) { 1144 DNAStrand strand = getOrCreateStrand(strands, CONFLICT_COLOR); 1145 // if we haven't initialized the allDay portion create it now 1146 if (strand.allDays == null) { 1147 strand.allDays = new int[numDays]; 1148 } 1149 1150 // For each day this event is on update the color 1151 int end = Math.min(event.endDay - firstJulianDay, numDays - 1); 1152 for (int i = Math.max(event.startDay - firstJulianDay, 0); i <= end; i++) { 1153 if (strand.allDays[i] != 0) { 1154 // if this day already had a color, it is now a conflict 1155 strand.allDays[i] = CONFLICT_COLOR; 1156 } else { 1157 // else it's just the color of the event 1158 strand.allDays[i] = event.color; 1159 } 1160 } 1161 } 1162 1163 // This processes all the segments, sorts them by color, and generates a 1164 // list of points to draw 1165 private static void weaveDNAStrands(LinkedList<DNASegment> segments, int firstJulianDay, 1166 HashMap<Integer, DNAStrand> strands, int top, int bottom, int[] dayXs) { 1167 // First, get rid of any colors that ended up with no segments 1168 Iterator<DNAStrand> strandIterator = strands.values().iterator(); 1169 while (strandIterator.hasNext()) { 1170 DNAStrand strand = strandIterator.next(); 1171 if (strand.count < 1 && strand.allDays == null) { 1172 strandIterator.remove(); 1173 continue; 1174 } 1175 strand.points = new float[strand.count * 4]; 1176 strand.position = 0; 1177 } 1178 // Go through each segment and compute its points 1179 for (DNASegment segment : segments) { 1180 // Add the points to the strand of that color 1181 DNAStrand strand = strands.get(segment.color); 1182 int dayIndex = segment.day - firstJulianDay; 1183 int dayStartMinute = segment.startMinute % DAY_IN_MINUTES; 1184 int dayEndMinute = segment.endMinute % DAY_IN_MINUTES; 1185 int height = bottom - top; 1186 int workDayHeight = height * 3 / 4; 1187 int remainderHeight = (height - workDayHeight) / 2; 1188 1189 int x = dayXs[dayIndex]; 1190 int y0 = 0; 1191 int y1 = 0; 1192 1193 y0 = top + getPixelOffsetFromMinutes(dayStartMinute, workDayHeight, remainderHeight); 1194 y1 = top + getPixelOffsetFromMinutes(dayEndMinute, workDayHeight, remainderHeight); 1195 if (DEBUG) { 1196 Log.d(TAG, "Adding " + Integer.toHexString(segment.color) + " at x,y0,y1: " + x 1197 + " " + y0 + " " + y1 + " for " + dayStartMinute + " " + dayEndMinute); 1198 } 1199 strand.points[strand.position++] = x; 1200 strand.points[strand.position++] = y0; 1201 strand.points[strand.position++] = x; 1202 strand.points[strand.position++] = y1; 1203 } 1204 } 1205 1206 /** 1207 * Compute a pixel offset from the top for a given minute from the work day 1208 * height and the height of the top area. 1209 */ 1210 private static int getPixelOffsetFromMinutes(int minute, int workDayHeight, 1211 int remainderHeight) { 1212 int y; 1213 if (minute < WORK_DAY_START_MINUTES) { 1214 y = minute * remainderHeight / WORK_DAY_START_MINUTES; 1215 } else if (minute < WORK_DAY_END_MINUTES) { 1216 y = remainderHeight + (minute - WORK_DAY_START_MINUTES) * workDayHeight 1217 / WORK_DAY_MINUTES; 1218 } else { 1219 y = remainderHeight + workDayHeight + (minute - WORK_DAY_END_MINUTES) * remainderHeight 1220 / WORK_DAY_END_LENGTH; 1221 } 1222 return y; 1223 } 1224 1225 /** 1226 * Add a new segment based on the event provided. This will handle splitting 1227 * segments across day boundaries and ensures a minimum size for segments. 1228 */ 1229 private static void addNewSegment(LinkedList<DNASegment> segments, Event event, 1230 HashMap<Integer, DNAStrand> strands, int firstJulianDay, int minStart, int minMinutes) { 1231 if (event.startDay > event.endDay) { 1232 Log.wtf(TAG, "Event starts after it ends: " + event.toString()); 1233 } 1234 // If this is a multiday event split it up by day 1235 if (event.startDay != event.endDay) { 1236 Event lhs = new Event(); 1237 lhs.color = event.color; 1238 lhs.startDay = event.startDay; 1239 // the first day we want the start time to be the actual start time 1240 lhs.startTime = event.startTime; 1241 lhs.endDay = lhs.startDay; 1242 lhs.endTime = DAY_IN_MINUTES - 1; 1243 // Nearly recursive iteration! 1244 while (lhs.startDay != event.endDay) { 1245 addNewSegment(segments, lhs, strands, firstJulianDay, minStart, minMinutes); 1246 // The days in between are all day, even though that shouldn't 1247 // actually happen due to the allday filtering 1248 lhs.startDay++; 1249 lhs.endDay = lhs.startDay; 1250 lhs.startTime = 0; 1251 minStart = 0; 1252 } 1253 // The last day we want the end time to be the actual end time 1254 lhs.endTime = event.endTime; 1255 event = lhs; 1256 } 1257 // Create the new segment and compute its fields 1258 DNASegment segment = new DNASegment(); 1259 int dayOffset = (event.startDay - firstJulianDay) * DAY_IN_MINUTES; 1260 int endOfDay = dayOffset + DAY_IN_MINUTES - 1; 1261 // clip the start if needed 1262 segment.startMinute = Math.max(dayOffset + event.startTime, minStart); 1263 // and extend the end if it's too small, but not beyond the end of the 1264 // day 1265 int minEnd = Math.min(segment.startMinute + minMinutes, endOfDay); 1266 segment.endMinute = Math.max(dayOffset + event.endTime, minEnd); 1267 if (segment.endMinute > endOfDay) { 1268 segment.endMinute = endOfDay; 1269 } 1270 1271 segment.color = event.color; 1272 segment.day = event.startDay; 1273 segments.add(segment); 1274 // increment the count for the correct color or add a new strand if we 1275 // don't have that color yet 1276 DNAStrand strand = getOrCreateStrand(strands, segment.color); 1277 strand.count++; 1278 } 1279 1280 /** 1281 * Try to get a strand of the given color. Create it if it doesn't exist. 1282 */ 1283 private static DNAStrand getOrCreateStrand(HashMap<Integer, DNAStrand> strands, int color) { 1284 DNAStrand strand = strands.get(color); 1285 if (strand == null) { 1286 strand = new DNAStrand(); 1287 strand.color = color; 1288 strand.count = 0; 1289 strands.put(strand.color, strand); 1290 } 1291 return strand; 1292 } 1293 1294 /** 1295 * Sends an intent to launch the top level Calendar view. 1296 * 1297 * @param context 1298 */ 1299 public static void returnToCalendarHome(Context context) { 1300 Intent launchIntent = new Intent(context, AllInOneActivity.class); 1301 launchIntent.setAction(Intent.ACTION_DEFAULT); 1302 launchIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); 1303 launchIntent.putExtra(INTENT_KEY_HOME, true); 1304 context.startActivity(launchIntent); 1305 } 1306 1307 /** 1308 * This sets up a search view to use Calendar's search suggestions provider 1309 * and to allow refining the search. 1310 * 1311 * @param view The {@link SearchView} to set up 1312 * @param act The activity using the view 1313 */ 1314 public static void setUpSearchView(SearchView view, Activity act) { 1315 SearchManager searchManager = (SearchManager) act.getSystemService(Context.SEARCH_SERVICE); 1316 view.setSearchableInfo(searchManager.getSearchableInfo(act.getComponentName())); 1317 view.setQueryRefinementEnabled(true); 1318 } 1319 1320 /** 1321 * Given a context and a time in millis since unix epoch figures out the 1322 * correct week of the year for that time. 1323 * 1324 * @param millisSinceEpoch 1325 * @return 1326 */ 1327 public static int getWeekNumberFromTime(long millisSinceEpoch, Context context) { 1328 Time weekTime = new Time(getTimeZone(context, null)); 1329 weekTime.set(millisSinceEpoch); 1330 weekTime.normalize(true); 1331 int firstDayOfWeek = getFirstDayOfWeek(context); 1332 // if the date is on Saturday or Sunday and the start of the week 1333 // isn't Monday we may need to shift the date to be in the correct 1334 // week 1335 if (weekTime.weekDay == Time.SUNDAY 1336 && (firstDayOfWeek == Time.SUNDAY || firstDayOfWeek == Time.SATURDAY)) { 1337 weekTime.monthDay++; 1338 weekTime.normalize(true); 1339 } else if (weekTime.weekDay == Time.SATURDAY && firstDayOfWeek == Time.SATURDAY) { 1340 weekTime.monthDay += 2; 1341 weekTime.normalize(true); 1342 } 1343 return weekTime.getWeekNumber(); 1344 } 1345 1346 /** 1347 * Formats a day of the week string. This is either just the name of the day 1348 * or a combination of yesterday/today/tomorrow and the day of the week. 1349 * 1350 * @param julianDay The julian day to get the string for 1351 * @param todayJulianDay The julian day for today's date 1352 * @param millis A utc millis since epoch time that falls on julian day 1353 * @param context The calling context, used to get the timezone and do the 1354 * formatting 1355 * @return 1356 */ 1357 public static String getDayOfWeekString(int julianDay, int todayJulianDay, long millis, 1358 Context context) { 1359 getTimeZone(context, null); 1360 int flags = DateUtils.FORMAT_SHOW_WEEKDAY; 1361 String dayViewText; 1362 if (julianDay == todayJulianDay) { 1363 dayViewText = context.getString(R.string.agenda_today, 1364 mTZUtils.formatDateRange(context, millis, millis, flags).toString()); 1365 } else if (julianDay == todayJulianDay - 1) { 1366 dayViewText = context.getString(R.string.agenda_yesterday, 1367 mTZUtils.formatDateRange(context, millis, millis, flags).toString()); 1368 } else if (julianDay == todayJulianDay + 1) { 1369 dayViewText = context.getString(R.string.agenda_tomorrow, 1370 mTZUtils.formatDateRange(context, millis, millis, flags).toString()); 1371 } else { 1372 dayViewText = mTZUtils.formatDateRange(context, millis, millis, flags).toString(); 1373 } 1374 dayViewText = dayViewText.toUpperCase(); 1375 return dayViewText; 1376 } 1377 1378 // Calculate the time until midnight + 1 second and set the handler to 1379 // do run the runnable 1380 public static void setMidnightUpdater(Handler h, Runnable r, String timezone) { 1381 if (h == null || r == null || timezone == null) { 1382 return; 1383 } 1384 long now = System.currentTimeMillis(); 1385 Time time = new Time(timezone); 1386 time.set(now); 1387 long runInMillis = (24 * 3600 - time.hour * 3600 - time.minute * 60 - 1388 time.second + 1) * 1000; 1389 h.removeCallbacks(r); 1390 h.postDelayed(r, runInMillis); 1391 } 1392 1393 // Stop the midnight update thread 1394 public static void resetMidnightUpdater(Handler h, Runnable r) { 1395 if (h == null || r == null) { 1396 return; 1397 } 1398 h.removeCallbacks(r); 1399 } 1400 1401 /** 1402 * Returns a string description of the specified time interval. 1403 */ 1404 public static String getDisplayedDatetime(long startMillis, long endMillis, long currentMillis, 1405 String localTimezone, boolean allDay, Context context) { 1406 // Configure date/time formatting. 1407 int flagsDate = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_WEEKDAY; 1408 int flagsTime = DateUtils.FORMAT_SHOW_TIME; 1409 if (DateFormat.is24HourFormat(context)) { 1410 flagsTime |= DateUtils.FORMAT_24HOUR; 1411 } 1412 1413 Time currentTime = new Time(localTimezone); 1414 currentTime.set(currentMillis); 1415 Resources resources = context.getResources(); 1416 String datetimeString = null; 1417 if (allDay) { 1418 // All day events require special timezone adjustment. 1419 long localStartMillis = convertAlldayUtcToLocal(null, startMillis, localTimezone); 1420 long localEndMillis = convertAlldayUtcToLocal(null, endMillis, localTimezone); 1421 if (singleDayEvent(localStartMillis, localEndMillis, currentTime.gmtoff)) { 1422 // If possible, use "Today" or "Tomorrow" instead of a full date string. 1423 int todayOrTomorrow = isTodayOrTomorrow(context.getResources(), 1424 localStartMillis, currentMillis, currentTime.gmtoff); 1425 if (TODAY == todayOrTomorrow) { 1426 datetimeString = resources.getString(R.string.today); 1427 } else if (TOMORROW == todayOrTomorrow) { 1428 datetimeString = resources.getString(R.string.tomorrow); 1429 } 1430 } 1431 if (datetimeString == null) { 1432 // For multi-day allday events or single-day all-day events that are not 1433 // today or tomorrow, use framework formatter. 1434 Formatter f = new Formatter(new StringBuilder(50), Locale.getDefault()); 1435 datetimeString = DateUtils.formatDateRange(context, f, startMillis, 1436 endMillis, flagsDate, Time.TIMEZONE_UTC).toString(); 1437 } 1438 } else { 1439 if (singleDayEvent(startMillis, endMillis, currentTime.gmtoff)) { 1440 // Format the time. 1441 String timeString = Utils.formatDateRange(context, startMillis, endMillis, 1442 flagsTime); 1443 1444 // If possible, use "Today" or "Tomorrow" instead of a full date string. 1445 int todayOrTomorrow = isTodayOrTomorrow(context.getResources(), startMillis, 1446 currentMillis, currentTime.gmtoff); 1447 if (TODAY == todayOrTomorrow) { 1448 // Example: "Today at 1:00pm - 2:00 pm" 1449 datetimeString = resources.getString(R.string.today_at_time_fmt, 1450 timeString); 1451 } else if (TOMORROW == todayOrTomorrow) { 1452 // Example: "Tomorrow at 1:00pm - 2:00 pm" 1453 datetimeString = resources.getString(R.string.tomorrow_at_time_fmt, 1454 timeString); 1455 } else { 1456 // Format the full date. Example: "Thursday, April 12, 1:00pm - 2:00pm" 1457 String dateString = Utils.formatDateRange(context, startMillis, endMillis, 1458 flagsDate); 1459 datetimeString = resources.getString(R.string.date_time_fmt, dateString, 1460 timeString); 1461 } 1462 } else { 1463 // For multiday events, shorten day/month names. 1464 // Example format: "Fri Apr 6, 5:00pm - Sun, Apr 8, 6:00pm" 1465 int flagsDatetime = flagsDate | flagsTime | DateUtils.FORMAT_ABBREV_MONTH | 1466 DateUtils.FORMAT_ABBREV_WEEKDAY; 1467 datetimeString = Utils.formatDateRange(context, startMillis, endMillis, 1468 flagsDatetime); 1469 } 1470 } 1471 return datetimeString; 1472 } 1473 1474 /** 1475 * Returns the timezone to display in the event info, if the local timezone is different 1476 * from the event timezone. Otherwise returns null. 1477 */ 1478 public static String getDisplayedTimezone(long startMillis, String localTimezone, 1479 String eventTimezone) { 1480 String tzDisplay = null; 1481 if (!TextUtils.equals(localTimezone, eventTimezone)) { 1482 // Figure out if this is in DST 1483 TimeZone tz = TimeZone.getTimeZone(localTimezone); 1484 if (tz == null || tz.getID().equals("GMT")) { 1485 tzDisplay = localTimezone; 1486 } else { 1487 Time startTime = new Time(localTimezone); 1488 startTime.set(startMillis); 1489 tzDisplay = tz.getDisplayName(startTime.isDst != 0, TimeZone.SHORT); 1490 } 1491 } 1492 return tzDisplay; 1493 } 1494 1495 /** 1496 * Returns whether the specified time interval is in a single day. 1497 */ 1498 private static boolean singleDayEvent(long startMillis, long endMillis, long localGmtOffset) { 1499 if (startMillis == endMillis) { 1500 return true; 1501 } 1502 1503 // An event ending at midnight should still be a single-day event, so check 1504 // time end-1. 1505 int startDay = Time.getJulianDay(startMillis, localGmtOffset); 1506 int endDay = Time.getJulianDay(endMillis - 1, localGmtOffset); 1507 return startDay == endDay; 1508 } 1509 1510 // Using int constants as a return value instead of an enum to minimize resources. 1511 private static final int TODAY = 1; 1512 private static final int TOMORROW = 2; 1513 private static final int NONE = 0; 1514 1515 /** 1516 * Returns TODAY or TOMORROW if applicable. Otherwise returns NONE. 1517 */ 1518 private static int isTodayOrTomorrow(Resources r, long dayMillis, 1519 long currentMillis, long localGmtOffset) { 1520 int startDay = Time.getJulianDay(dayMillis, localGmtOffset); 1521 int currentDay = Time.getJulianDay(currentMillis, localGmtOffset); 1522 1523 int days = startDay - currentDay; 1524 if (days == 1) { 1525 return TOMORROW; 1526 } else if (days == 0) { 1527 return TODAY; 1528 } else { 1529 return NONE; 1530 } 1531 } 1532 1533 /** 1534 * Create an intent for emailing attendees of an event. 1535 * 1536 * @param resources The resources for translating strings. 1537 * @param eventTitle The title of the event to use as the email subject. 1538 * @param body The default text for the email body. 1539 * @param toEmails The list of emails for the 'to' line. 1540 * @param ccEmails The list of emails for the 'cc' line. 1541 * @param ownerAccount The owner account to use as the email sender. 1542 */ 1543 public static Intent createEmailAttendeesIntent(Resources resources, String eventTitle, 1544 String body, List<String> toEmails, List<String> ccEmails, String ownerAccount) { 1545 List<String> toList = toEmails; 1546 List<String> ccList = ccEmails; 1547 if (toEmails.size() <= 0) { 1548 if (ccEmails.size() <= 0) { 1549 // TODO: Return a SEND intent if no one to email to, to at least populate 1550 // a draft email with the subject (and no recipients). 1551 throw new IllegalArgumentException("Both toEmails and ccEmails are empty."); 1552 } 1553 1554 // Email app does not work with no "to" recipient. Move all 'cc' to 'to' 1555 // in this case. 1556 toList = ccEmails; 1557 ccList = null; 1558 } 1559 1560 // Use the event title as the email subject (prepended with 'Re: '). 1561 String subject = null; 1562 if (eventTitle != null) { 1563 subject = resources.getString(R.string.email_subject_prefix) + eventTitle; 1564 } 1565 1566 // Use the SENDTO intent with a 'mailto' URI, because using SEND will cause 1567 // the picker to show apps like text messaging, which does not make sense 1568 // for email addresses. We put all data in the URI instead of using the extra 1569 // Intent fields (ie. EXTRA_CC, etc) because some email apps might not handle 1570 // those (though gmail does). 1571 Uri.Builder uriBuilder = new Uri.Builder(); 1572 uriBuilder.scheme("mailto"); 1573 1574 // We will append the first email to the 'mailto' field later (because the 1575 // current state of the Email app requires it). Add the remaining 'to' values 1576 // here. When the email codebase is updated, we can simplify this. 1577 if (toList.size() > 1) { 1578 for (int i = 1; i < toList.size(); i++) { 1579 // The Email app requires repeated parameter settings instead of 1580 // a single comma-separated list. 1581 uriBuilder.appendQueryParameter("to", toList.get(i)); 1582 } 1583 } 1584 1585 // Add the subject parameter. 1586 if (subject != null) { 1587 uriBuilder.appendQueryParameter("subject", subject); 1588 } 1589 1590 // Add the subject parameter. 1591 if (body != null) { 1592 uriBuilder.appendQueryParameter("body", body); 1593 } 1594 1595 // Add the cc parameters. 1596 if (ccList != null && ccList.size() > 0) { 1597 for (String email : ccList) { 1598 uriBuilder.appendQueryParameter("cc", email); 1599 } 1600 } 1601 1602 // Insert the first email after 'mailto:' in the URI manually since Uri.Builder 1603 // doesn't seem to have a way to do this. 1604 String uri = uriBuilder.toString(); 1605 if (uri.startsWith("mailto:")) { 1606 StringBuilder builder = new StringBuilder(uri); 1607 builder.insert(7, Uri.encode(toList.get(0))); 1608 uri = builder.toString(); 1609 } 1610 1611 // Start the email intent. Email from the account of the calendar owner in case there 1612 // are multiple email accounts. 1613 Intent emailIntent = new Intent(android.content.Intent.ACTION_SENDTO, Uri.parse(uri)); 1614 emailIntent.putExtra("fromAccountString", ownerAccount); 1615 1616 // Workaround a Email bug that overwrites the body with this intent extra. If not 1617 // set, it clears the body. 1618 if (body != null) { 1619 emailIntent.putExtra(Intent.EXTRA_TEXT, body); 1620 } 1621 1622 return Intent.createChooser(emailIntent, resources.getString(R.string.email_picker_label)); 1623 } 1624 1625 /** 1626 * Example fake email addresses used as attendee emails are resources like conference rooms, 1627 * or another calendar, etc. These all end in "calendar.google.com". 1628 */ 1629 public static boolean isValidEmail(String email) { 1630 return email != null && !email.endsWith(MACHINE_GENERATED_ADDRESS); 1631 } 1632 1633 /** 1634 * Returns true if: 1635 * (1) the email is not a resource like a conference room or another calendar. 1636 * Catch most of these by filtering out suffix calendar.google.com. 1637 * (2) the email is not equal to the sync account to prevent mailing himself. 1638 */ 1639 public static boolean isEmailableFrom(String email, String syncAccountName) { 1640 return Utils.isValidEmail(email) && !email.equals(syncAccountName); 1641 } 1642 1643 /** 1644 * Inserts a drawable with today's day into the today's icon in the option menu 1645 * @param icon - today's icon from the options menu 1646 */ 1647 public static void setTodayIcon(LayerDrawable icon, Context c, String timezone) { 1648 DayOfMonthDrawable today; 1649 1650 // Reuse current drawable if possible 1651 Drawable currentDrawable = icon.findDrawableByLayerId(R.id.today_icon_day); 1652 if (currentDrawable != null && currentDrawable instanceof DayOfMonthDrawable) { 1653 today = (DayOfMonthDrawable)currentDrawable; 1654 } else { 1655 today = new DayOfMonthDrawable(c); 1656 } 1657 // Set the day and update the icon 1658 Time now = new Time(timezone); 1659 now.setToNow(); 1660 now.normalize(false); 1661 today.setDayOfMonth(now.monthDay); 1662 icon.mutate(); 1663 icon.setDrawableByLayerId(R.id.today_icon_day, today); 1664 } 1665 1666 private static class CalendarBroadcastReceiver extends BroadcastReceiver { 1667 1668 Runnable mCallBack; 1669 1670 public CalendarBroadcastReceiver(Runnable callback) { 1671 super(); 1672 mCallBack = callback; 1673 } 1674 @Override 1675 public void onReceive(Context context, Intent intent) { 1676 if (intent.getAction().equals(Intent.ACTION_DATE_CHANGED) || 1677 intent.getAction().equals(Intent.ACTION_TIME_CHANGED) || 1678 intent.getAction().equals(Intent.ACTION_LOCALE_CHANGED) || 1679 intent.getAction().equals(Intent.ACTION_TIMEZONE_CHANGED)) { 1680 if (mCallBack != null) { 1681 mCallBack.run(); 1682 } 1683 } 1684 } 1685 } 1686 1687 public static BroadcastReceiver setTimeChangesReceiver(Context c, Runnable callback) { 1688 IntentFilter filter = new IntentFilter(); 1689 filter.addAction(Intent.ACTION_TIME_CHANGED); 1690 filter.addAction(Intent.ACTION_DATE_CHANGED); 1691 filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); 1692 filter.addAction(Intent.ACTION_LOCALE_CHANGED); 1693 1694 CalendarBroadcastReceiver r = new CalendarBroadcastReceiver(callback); 1695 c.registerReceiver(r, filter); 1696 return r; 1697 } 1698 1699 public static void clearTimeChangesReceiver(Context c, BroadcastReceiver r) { 1700 c.unregisterReceiver(r); 1701 } 1702 1703 /** 1704 * Get a list of quick responses used for emailing guests from the 1705 * SharedPreferences. If not are found, get the hard coded ones that shipped 1706 * with the app 1707 * 1708 * @param context 1709 * @return a list of quick responses. 1710 */ 1711 public static String[] getQuickResponses(Context context) { 1712 String[] s = Utils.getSharedPreference(context, KEY_QUICK_RESPONSES, (String[]) null); 1713 1714 if (s == null) { 1715 s = context.getResources().getStringArray(R.array.quick_response_defaults); 1716 } 1717 1718 return s; 1719 } 1720 1721 /** 1722 * Return the app version code. 1723 */ 1724 public static String getVersionCode(Context context) { 1725 if (sVersion == null) { 1726 try { 1727 sVersion = context.getPackageManager().getPackageInfo( 1728 context.getPackageName(), 0).versionName; 1729 } catch (PackageManager.NameNotFoundException e) { 1730 // Can't find version; just leave it blank. 1731 Log.e(TAG, "Error finding package " + context.getApplicationInfo().packageName); 1732 } 1733 } 1734 return sVersion; 1735 } 1736 1737 /** 1738 * Checks the server for an updated list of Calendars (in the background). 1739 * 1740 * If a Calendar is added on the web (and it is selected and not 1741 * hidden) then it will be added to the list of calendars on the phone 1742 * (when this finishes). When a new calendar from the 1743 * web is added to the phone, then the events for that calendar are also 1744 * downloaded from the web. 1745 * 1746 * This sync is done automatically in the background when the 1747 * SelectCalendars activity and fragment are started. 1748 * 1749 * @param account - The account to sync. May be null to sync all accounts. 1750 */ 1751 public static void startCalendarMetafeedSync(Account account) { 1752 Bundle extras = new Bundle(); 1753 extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); 1754 extras.putBoolean("metafeedonly", true); 1755 ContentResolver.requestSync(account, Calendars.CONTENT_URI.getAuthority(), extras); 1756 } 1757 1758 /** 1759 * Replaces stretches of text that look like addresses and phone numbers with clickable 1760 * links. If lastDitchGeo is true, then if no links are found in the textview, the entire 1761 * string will be converted to a single geo link. Any spans that may have previously been 1762 * in the text will be cleared out. 1763 * <p> 1764 * This is really just an enhanced version of Linkify.addLinks(). 1765 * 1766 * @param text - The string to search for links. 1767 * @param lastDitchGeo - If no links are found, turn the entire string into one geo link. 1768 * @return Spannable object containing the list of URL spans found. 1769 */ 1770 public static Spannable extendedLinkify(String text, boolean lastDitchGeo) { 1771 // We use a copy of the string argument so it's available for later if necessary. 1772 Spannable spanText = SpannableString.valueOf(text); 1773 1774 /* 1775 * If the text includes a street address like "1600 Amphitheater Parkway, 94043", 1776 * the current Linkify code will identify "94043" as a phone number and invite 1777 * you to dial it (and not provide a map link for the address). For outside US, 1778 * use Linkify result iff it spans the entire text. Otherwise send the user to maps. 1779 */ 1780 String defaultPhoneRegion = System.getProperty("user.region", "US"); 1781 if (!defaultPhoneRegion.equals("US")) { 1782 Linkify.addLinks(spanText, Linkify.ALL); 1783 1784 // If Linkify links the entire text, use that result. 1785 URLSpan[] spans = spanText.getSpans(0, spanText.length(), URLSpan.class); 1786 if (spans.length == 1) { 1787 int linkStart = spanText.getSpanStart(spans[0]); 1788 int linkEnd = spanText.getSpanEnd(spans[0]); 1789 if (linkStart <= indexFirstNonWhitespaceChar(spanText) && 1790 linkEnd >= indexLastNonWhitespaceChar(spanText) + 1) { 1791 return spanText; 1792 } 1793 } 1794 1795 // Otherwise, to be cautious and to try to prevent false positives, reset the spannable. 1796 spanText = SpannableString.valueOf(text); 1797 // If lastDitchGeo is true, default the entire string to geo. 1798 if (lastDitchGeo && !text.isEmpty()) { 1799 Linkify.addLinks(spanText, mWildcardPattern, "geo:0,0?q="); 1800 } 1801 return spanText; 1802 } 1803 1804 /* 1805 * For within US, we want to have better recognition of phone numbers without losing 1806 * any of the existing annotations. Ideally this would be addressed by improving Linkify. 1807 * For now we manage it as a second pass over the text. 1808 * 1809 * URIs and e-mail addresses are pretty easy to pick out of text. Phone numbers 1810 * are a bit tricky because they have radically different formats in different 1811 * countries, in terms of both the digits and the way in which they are commonly 1812 * written or presented (e.g. the punctuation and spaces in "(650) 555-1212"). 1813 * The expected format of a street address is defined in WebView.findAddress(). It's 1814 * pretty narrowly defined, so it won't often match. 1815 * 1816 * The RFC 3966 specification defines the format of a "tel:" URI. 1817 * 1818 * Start by letting Linkify find anything that isn't a phone number. We have to let it 1819 * run first because every invocation removes all previous URLSpan annotations. 1820 * 1821 * Ideally we'd use the external/libphonenumber routines, but those aren't available 1822 * to unbundled applications. 1823 */ 1824 boolean linkifyFoundLinks = Linkify.addLinks(spanText, 1825 Linkify.ALL & ~(Linkify.PHONE_NUMBERS)); 1826 1827 /* 1828 * Get a list of any spans created by Linkify, for the coordinate overlapping span check. 1829 */ 1830 URLSpan[] existingSpans = spanText.getSpans(0, spanText.length(), URLSpan.class); 1831 1832 /* 1833 * Check for coordinates. 1834 * This must be done before phone numbers because longitude may look like a phone number. 1835 */ 1836 Matcher coordMatcher = COORD_PATTERN.matcher(spanText); 1837 int coordCount = 0; 1838 while (coordMatcher.find()) { 1839 int start = coordMatcher.start(); 1840 int end = coordMatcher.end(); 1841 if (spanWillOverlap(spanText, existingSpans, start, end)) { 1842 continue; 1843 } 1844 1845 URLSpan span = new URLSpan("geo:0,0?q=" + coordMatcher.group()); 1846 spanText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1847 coordCount++; 1848 } 1849 1850 /* 1851 * Update the list of existing spans, for the phone number overlapping span check. 1852 */ 1853 existingSpans = spanText.getSpans(0, spanText.length(), URLSpan.class); 1854 1855 /* 1856 * Search for phone numbers. 1857 * 1858 * Some URIs contain strings of digits that look like phone numbers. If both the URI 1859 * scanner and the phone number scanner find them, we want the URI link to win. Since 1860 * the URI scanner runs first, we just need to avoid creating overlapping spans. 1861 */ 1862 int[] phoneSequences = findNanpPhoneNumbers(text); 1863 1864 /* 1865 * Insert spans for the numbers we found. We generate "tel:" URIs. 1866 */ 1867 int phoneCount = 0; 1868 for (int match = 0; match < phoneSequences.length / 2; match++) { 1869 int start = phoneSequences[match*2]; 1870 int end = phoneSequences[match*2 + 1]; 1871 1872 if (spanWillOverlap(spanText, existingSpans, start, end)) { 1873 continue; 1874 } 1875 1876 /* 1877 * The Linkify code takes the matching span and strips out everything that isn't a 1878 * digit or '+' sign. We do the same here. Extension numbers will get appended 1879 * without a separator, but the dialer wasn't doing anything useful with ";ext=" 1880 * anyway. 1881 */ 1882 1883 //String dialStr = phoneUtil.format(match.number(), 1884 // PhoneNumberUtil.PhoneNumberFormat.RFC3966); 1885 StringBuilder dialBuilder = new StringBuilder(); 1886 for (int i = start; i < end; i++) { 1887 char ch = spanText.charAt(i); 1888 if (ch == '+' || Character.isDigit(ch)) { 1889 dialBuilder.append(ch); 1890 } 1891 } 1892 URLSpan span = new URLSpan("tel:" + dialBuilder.toString()); 1893 1894 spanText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1895 phoneCount++; 1896 } 1897 1898 /* 1899 * If lastDitchGeo, and no other links have been found, set the entire string as a geo link. 1900 */ 1901 if (lastDitchGeo && !text.isEmpty() && 1902 !linkifyFoundLinks && phoneCount == 0 && coordCount == 0) { 1903 if (Log.isLoggable(TAG, Log.VERBOSE)) { 1904 Log.v(TAG, "No linkification matches, using geo default"); 1905 } 1906 Linkify.addLinks(spanText, mWildcardPattern, "geo:0,0?q="); 1907 } 1908 1909 return spanText; 1910 } 1911 1912 private static int indexFirstNonWhitespaceChar(CharSequence str) { 1913 for (int i = 0; i < str.length(); i++) { 1914 if (!Character.isWhitespace(str.charAt(i))) { 1915 return i; 1916 } 1917 } 1918 return -1; 1919 } 1920 1921 private static int indexLastNonWhitespaceChar(CharSequence str) { 1922 for (int i = str.length() - 1; i >= 0; i--) { 1923 if (!Character.isWhitespace(str.charAt(i))) { 1924 return i; 1925 } 1926 } 1927 return -1; 1928 } 1929 1930 /** 1931 * Finds North American Numbering Plan (NANP) phone numbers in the input text. 1932 * 1933 * @param text The text to scan. 1934 * @return A list of [start, end) pairs indicating the positions of phone numbers in the input. 1935 */ 1936 // @VisibleForTesting 1937 static int[] findNanpPhoneNumbers(CharSequence text) { 1938 ArrayList<Integer> list = new ArrayList<Integer>(); 1939 1940 int startPos = 0; 1941 int endPos = text.length() - NANP_MIN_DIGITS + 1; 1942 if (endPos < 0) { 1943 return new int[] {}; 1944 } 1945 1946 /* 1947 * We can't just strip the whitespace out and crunch it down, because the whitespace 1948 * is significant. March through, trying to figure out where numbers start and end. 1949 */ 1950 while (startPos < endPos) { 1951 // skip whitespace 1952 while (Character.isWhitespace(text.charAt(startPos)) && startPos < endPos) { 1953 startPos++; 1954 } 1955 if (startPos == endPos) { 1956 break; 1957 } 1958 1959 // check for a match at this position 1960 int matchEnd = findNanpMatchEnd(text, startPos); 1961 if (matchEnd > startPos) { 1962 list.add(startPos); 1963 list.add(matchEnd); 1964 startPos = matchEnd; // skip past match 1965 } else { 1966 // skip to next whitespace char 1967 while (!Character.isWhitespace(text.charAt(startPos)) && startPos < endPos) { 1968 startPos++; 1969 } 1970 } 1971 } 1972 1973 int[] result = new int[list.size()]; 1974 for (int i = list.size() - 1; i >= 0; i--) { 1975 result[i] = list.get(i); 1976 } 1977 return result; 1978 } 1979 1980 /** 1981 * Checks to see if there is a valid phone number in the input, starting at the specified 1982 * offset. If so, the index of the last character + 1 is returned. The input is assumed 1983 * to begin with a non-whitespace character. 1984 * 1985 * @return Exclusive end position, or -1 if not a match. 1986 */ 1987 private static int findNanpMatchEnd(CharSequence text, int startPos) { 1988 /* 1989 * A few interesting cases: 1990 * 94043 # too short, ignore 1991 * 123456789012 # too long, ignore 1992 * +1 (650) 555-1212 # 11 digits, spaces 1993 * (650) 555 5555 # Second space, only when first is present. 1994 * (650) 555-1212, (650) 555-1213 # two numbers, return first 1995 * 1-650-555-1212 # 11 digits with leading '1' 1996 * *#650.555.1212#*! # 10 digits, include #*, ignore trailing '!' 1997 * 555.1212 # 7 digits 1998 * 1999 * For the most part we want to break on whitespace, but it's common to leave a space 2000 * between the initial '1' and/or after the area code. 2001 */ 2002 2003 // Check for "tel:" URI prefix. 2004 if (text.length() > startPos+4 2005 && text.subSequence(startPos, startPos+4).toString().equalsIgnoreCase("tel:")) { 2006 startPos += 4; 2007 } 2008 2009 int endPos = text.length(); 2010 int curPos = startPos; 2011 int foundDigits = 0; 2012 char firstDigit = 'x'; 2013 boolean foundWhiteSpaceAfterAreaCode = false; 2014 2015 while (curPos <= endPos) { 2016 char ch; 2017 if (curPos < endPos) { 2018 ch = text.charAt(curPos); 2019 } else { 2020 ch = 27; // fake invalid symbol at end to trigger loop break 2021 } 2022 2023 if (Character.isDigit(ch)) { 2024 if (foundDigits == 0) { 2025 firstDigit = ch; 2026 } 2027 foundDigits++; 2028 if (foundDigits > NANP_MAX_DIGITS) { 2029 // too many digits, stop early 2030 return -1; 2031 } 2032 } else if (Character.isWhitespace(ch)) { 2033 if ( (firstDigit == '1' && foundDigits == 4) || 2034 (foundDigits == 3)) { 2035 foundWhiteSpaceAfterAreaCode = true; 2036 } else if (firstDigit == '1' && foundDigits == 1) { 2037 } else if (foundWhiteSpaceAfterAreaCode 2038 && ( (firstDigit == '1' && (foundDigits == 7)) || (foundDigits == 6))) { 2039 } else { 2040 break; 2041 } 2042 } else if (NANP_ALLOWED_SYMBOLS.indexOf(ch) == -1) { 2043 break; 2044 } 2045 // else it's an allowed symbol 2046 2047 curPos++; 2048 } 2049 2050 if ((firstDigit != '1' && (foundDigits == 7 || foundDigits == 10)) || 2051 (firstDigit == '1' && foundDigits == 11)) { 2052 // match 2053 return curPos; 2054 } 2055 2056 return -1; 2057 } 2058 2059 /** 2060 * Determines whether a new span at [start,end) will overlap with any existing span. 2061 */ 2062 private static boolean spanWillOverlap(Spannable spanText, URLSpan[] spanList, int start, 2063 int end) { 2064 if (start == end) { 2065 // empty span, ignore 2066 return false; 2067 } 2068 for (URLSpan span : spanList) { 2069 int existingStart = spanText.getSpanStart(span); 2070 int existingEnd = spanText.getSpanEnd(span); 2071 if ((start >= existingStart && start < existingEnd) || 2072 end > existingStart && end <= existingEnd) { 2073 if (Log.isLoggable(TAG, Log.VERBOSE)) { 2074 CharSequence seq = spanText.subSequence(start, end); 2075 Log.v(TAG, "Not linkifying " + seq + " as phone number due to overlap"); 2076 } 2077 return true; 2078 } 2079 } 2080 2081 return false; 2082 } 2083 2084 /** 2085 * @param bundle The incoming bundle that contains the reminder info. 2086 * @return ArrayList<ReminderEntry> of the reminder minutes and methods. 2087 */ 2088 public static ArrayList<ReminderEntry> readRemindersFromBundle(Bundle bundle) { 2089 ArrayList<ReminderEntry> reminders = null; 2090 2091 ArrayList<Integer> reminderMinutes = bundle.getIntegerArrayList( 2092 EventInfoFragment.BUNDLE_KEY_REMINDER_MINUTES); 2093 ArrayList<Integer> reminderMethods = bundle.getIntegerArrayList( 2094 EventInfoFragment.BUNDLE_KEY_REMINDER_METHODS); 2095 if (reminderMinutes == null || reminderMethods == null) { 2096 if (reminderMinutes != null || reminderMethods != null) { 2097 String nullList = (reminderMinutes == null? 2098 "reminderMinutes" : "reminderMethods"); 2099 Log.d(TAG, String.format("Error resolving reminders: %s was null", 2100 nullList)); 2101 } 2102 return null; 2103 } 2104 2105 int numReminders = reminderMinutes.size(); 2106 if (numReminders == reminderMethods.size()) { 2107 // Only if the size of the reminder minutes we've read in is 2108 // the same as the size of the reminder methods. Otherwise, 2109 // something went wrong with bundling them. 2110 reminders = new ArrayList<ReminderEntry>(numReminders); 2111 for (int reminder_i = 0; reminder_i < numReminders; 2112 reminder_i++) { 2113 int minutes = reminderMinutes.get(reminder_i); 2114 int method = reminderMethods.get(reminder_i); 2115 reminders.add(ReminderEntry.valueOf(minutes, method)); 2116 } 2117 } else { 2118 Log.d(TAG, String.format("Error resolving reminders." + 2119 " Found %d reminderMinutes, but %d reminderMethods.", 2120 numReminders, reminderMethods.size())); 2121 } 2122 2123 return reminders; 2124 } 2125 2126 } 2127