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