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