| /* |
| * Copyright (C) 2006 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.calendar; |
| |
| import static android.provider.CalendarContract.EXTRA_EVENT_BEGIN_TIME; |
| |
| import android.accounts.Account; |
| import android.app.Activity; |
| import android.app.SearchManager; |
| import android.content.BroadcastReceiver; |
| import android.content.ContentResolver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.SharedPreferences; |
| import android.content.pm.PackageManager; |
| import android.content.res.Resources; |
| import android.database.Cursor; |
| import android.database.MatrixCursor; |
| import android.graphics.Color; |
| import android.graphics.drawable.Drawable; |
| import android.graphics.drawable.LayerDrawable; |
| import android.net.Uri; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.provider.CalendarContract.Calendars; |
| import android.text.Spannable; |
| import android.text.SpannableString; |
| import android.text.Spanned; |
| import android.text.TextUtils; |
| import android.text.format.DateFormat; |
| import android.text.format.DateUtils; |
| import android.text.format.Time; |
| import android.text.style.URLSpan; |
| import android.text.util.Linkify; |
| import android.util.Log; |
| import android.widget.SearchView; |
| |
| import com.android.calendar.CalendarController.ViewType; |
| import com.android.calendar.CalendarEventModel.ReminderEntry; |
| import com.android.calendar.CalendarUtils.TimeZoneUtils; |
| |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Calendar; |
| import java.util.Formatter; |
| import java.util.HashMap; |
| import java.util.Iterator; |
| import java.util.LinkedHashSet; |
| import java.util.LinkedList; |
| import java.util.List; |
| import java.util.Locale; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.TimeZone; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| public class Utils { |
| private static final boolean DEBUG = false; |
| private static final String TAG = "CalUtils"; |
| |
| // Set to 0 until we have UI to perform undo |
| public static final long UNDO_DELAY = 0; |
| |
| // For recurring events which instances of the series are being modified |
| public static final int MODIFY_UNINITIALIZED = 0; |
| public static final int MODIFY_SELECTED = 1; |
| public static final int MODIFY_ALL_FOLLOWING = 2; |
| public static final int MODIFY_ALL = 3; |
| |
| // When the edit event view finishes it passes back the appropriate exit |
| // code. |
| public static final int DONE_REVERT = 1 << 0; |
| public static final int DONE_SAVE = 1 << 1; |
| public static final int DONE_DELETE = 1 << 2; |
| // And should re run with DONE_EXIT if it should also leave the view, just |
| // exiting is identical to reverting |
| public static final int DONE_EXIT = 1 << 0; |
| |
| public static final String OPEN_EMAIL_MARKER = " <"; |
| public static final String CLOSE_EMAIL_MARKER = ">"; |
| |
| public static final String INTENT_KEY_DETAIL_VIEW = "DETAIL_VIEW"; |
| public static final String INTENT_KEY_VIEW_TYPE = "VIEW"; |
| public static final String INTENT_VALUE_VIEW_TYPE_DAY = "DAY"; |
| public static final String INTENT_KEY_HOME = "KEY_HOME"; |
| |
| public static final int MONDAY_BEFORE_JULIAN_EPOCH = Time.EPOCH_JULIAN_DAY - 3; |
| public static final int DECLINED_EVENT_ALPHA = 0x66; |
| public static final int DECLINED_EVENT_TEXT_ALPHA = 0xC0; |
| |
| private static final float SATURATION_ADJUST = 1.3f; |
| private static final float INTENSITY_ADJUST = 0.8f; |
| |
| // Defines used by the DNA generation code |
| static final int DAY_IN_MINUTES = 60 * 24; |
| static final int WEEK_IN_MINUTES = DAY_IN_MINUTES * 7; |
| // The work day is being counted as 6am to 8pm |
| static int WORK_DAY_MINUTES = 14 * 60; |
| static int WORK_DAY_START_MINUTES = 6 * 60; |
| static int WORK_DAY_END_MINUTES = 20 * 60; |
| static int WORK_DAY_END_LENGTH = (24 * 60) - WORK_DAY_END_MINUTES; |
| static int CONFLICT_COLOR = 0xFF000000; |
| static boolean mMinutesLoaded = false; |
| |
| public static final int YEAR_MIN = 1970; |
| public static final int YEAR_MAX = 2036; |
| |
| // The name of the shared preferences file. This name must be maintained for |
| // historical |
| // reasons, as it's what PreferenceManager assigned the first time the file |
| // was created. |
| static final String SHARED_PREFS_NAME = "com.android.calendar_preferences"; |
| |
| public static final String KEY_QUICK_RESPONSES = "preferences_quick_responses"; |
| |
| public static final String KEY_ALERTS_VIBRATE_WHEN = "preferences_alerts_vibrateWhen"; |
| |
| public static final String APPWIDGET_DATA_TYPE = "vnd.android.data/update"; |
| |
| static final String MACHINE_GENERATED_ADDRESS = "calendar.google.com"; |
| |
| private static final TimeZoneUtils mTZUtils = new TimeZoneUtils(SHARED_PREFS_NAME); |
| private static boolean mAllowWeekForDetailView = false; |
| private static long mTardis = 0; |
| private static String sVersion = null; |
| |
| private static final Pattern mWildcardPattern = Pattern.compile("^.*$"); |
| |
| /** |
| * A coordinate must be of the following form for Google Maps to correctly use it: |
| * Latitude, Longitude |
| * |
| * This may be in decimal form: |
| * Latitude: {-90 to 90} |
| * Longitude: {-180 to 180} |
| * |
| * Or, in degrees, minutes, and seconds: |
| * Latitude: {-90 to 90}° {0 to 59}' {0 to 59}" |
| * Latitude: {-180 to 180}° {0 to 59}' {0 to 59}" |
| * + or - degrees may also be represented with N or n, S or s for latitude, and with |
| * E or e, W or w for longitude, where the direction may either precede or follow the value. |
| * |
| * Some examples of coordinates that will be accepted by the regex: |
| * 37.422081°, -122.084576° |
| * 37.422081,-122.084576 |
| * +37°25'19.49", -122°5'4.47" |
| * 37°25'19.49"N, 122°5'4.47"W |
| * N 37° 25' 19.49", W 122° 5' 4.47" |
| **/ |
| private static final String COORD_DEGREES_LATITUDE = |
| "([-+NnSs]" + "(\\s)*)?" |
| + "[1-9]?[0-9](\u00B0)" + "(\\s)*" |
| + "([1-5]?[0-9]\')?" + "(\\s)*" |
| + "([1-5]?[0-9]" + "(\\.[0-9]+)?\")?" |
| + "((\\s)*" + "[NnSs])?"; |
| private static final String COORD_DEGREES_LONGITUDE = |
| "([-+EeWw]" + "(\\s)*)?" |
| + "(1)?[0-9]?[0-9](\u00B0)" + "(\\s)*" |
| + "([1-5]?[0-9]\')?" + "(\\s)*" |
| + "([1-5]?[0-9]" + "(\\.[0-9]+)?\")?" |
| + "((\\s)*" + "[EeWw])?"; |
| private static final String COORD_DEGREES_PATTERN = |
| COORD_DEGREES_LATITUDE |
| + "(\\s)*" + "," + "(\\s)*" |
| + COORD_DEGREES_LONGITUDE; |
| private static final String COORD_DECIMAL_LATITUDE = |
| "[+-]?" |
| + "[1-9]?[0-9]" + "(\\.[0-9]+)" |
| + "(\u00B0)?"; |
| private static final String COORD_DECIMAL_LONGITUDE = |
| "[+-]?" |
| + "(1)?[0-9]?[0-9]" + "(\\.[0-9]+)" |
| + "(\u00B0)?"; |
| private static final String COORD_DECIMAL_PATTERN = |
| COORD_DECIMAL_LATITUDE |
| + "(\\s)*" + "," + "(\\s)*" |
| + COORD_DECIMAL_LONGITUDE; |
| private static final Pattern COORD_PATTERN = |
| Pattern.compile(COORD_DEGREES_PATTERN + "|" + COORD_DECIMAL_PATTERN); |
| |
| private static final String NANP_ALLOWED_SYMBOLS = "()+-*#."; |
| private static final int NANP_MIN_DIGITS = 7; |
| private static final int NANP_MAX_DIGITS = 11; |
| |
| |
| /** |
| * Returns whether the SDK is the Jellybean release or later. |
| */ |
| public static boolean isJellybeanOrLater() { |
| return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; |
| } |
| |
| public static int getViewTypeFromIntentAndSharedPref(Activity activity) { |
| Intent intent = activity.getIntent(); |
| Bundle extras = intent.getExtras(); |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(activity); |
| |
| if (TextUtils.equals(intent.getAction(), Intent.ACTION_EDIT)) { |
| return ViewType.EDIT; |
| } |
| if (extras != null) { |
| if (extras.getBoolean(INTENT_KEY_DETAIL_VIEW, false)) { |
| // This is the "detail" view which is either agenda or day view |
| return prefs.getInt(GeneralPreferences.KEY_DETAILED_VIEW, |
| GeneralPreferences.DEFAULT_DETAILED_VIEW); |
| } else if (INTENT_VALUE_VIEW_TYPE_DAY.equals(extras.getString(INTENT_KEY_VIEW_TYPE))) { |
| // Not sure who uses this. This logic came from LaunchActivity |
| return ViewType.DAY; |
| } |
| } |
| |
| // Default to the last view |
| return prefs.getInt( |
| GeneralPreferences.KEY_START_VIEW, GeneralPreferences.DEFAULT_START_VIEW); |
| } |
| |
| /** |
| * Gets the intent action for telling the widget to update. |
| */ |
| public static String getWidgetUpdateAction(Context context) { |
| return context.getPackageName() + ".APPWIDGET_UPDATE"; |
| } |
| |
| /** |
| * Gets the intent action for telling the widget to update. |
| */ |
| public static String getWidgetScheduledUpdateAction(Context context) { |
| return context.getPackageName() + ".APPWIDGET_SCHEDULED_UPDATE"; |
| } |
| |
| /** |
| * Gets the intent action for telling the widget to update. |
| */ |
| public static String getSearchAuthority(Context context) { |
| return context.getPackageName() + ".CalendarRecentSuggestionsProvider"; |
| } |
| |
| /** |
| * Writes a new home time zone to the db. Updates the home time zone in the |
| * db asynchronously and updates the local cache. Sending a time zone of |
| * **tbd** will cause it to be set to the device's time zone. null or empty |
| * tz will be ignored. |
| * |
| * @param context The calling activity |
| * @param timeZone The time zone to set Calendar to, or **tbd** |
| */ |
| public static void setTimeZone(Context context, String timeZone) { |
| mTZUtils.setTimeZone(context, timeZone); |
| } |
| |
| /** |
| * Gets the time zone that Calendar should be displayed in This is a helper |
| * method to get the appropriate time zone for Calendar. If this is the |
| * first time this method has been called it will initiate an asynchronous |
| * query to verify that the data in preferences is correct. The callback |
| * supplied will only be called if this query returns a value other than |
| * what is stored in preferences and should cause the calling activity to |
| * refresh anything that depends on calling this method. |
| * |
| * @param context The calling activity |
| * @param callback The runnable that should execute if a query returns new |
| * values |
| * @return The string value representing the time zone Calendar should |
| * display |
| */ |
| public static String getTimeZone(Context context, Runnable callback) { |
| return mTZUtils.getTimeZone(context, callback); |
| } |
| |
| /** |
| * Formats a date or a time range according to the local conventions. |
| * |
| * @param context the context is required only if the time is shown |
| * @param startMillis the start time in UTC milliseconds |
| * @param endMillis the end time in UTC milliseconds |
| * @param flags a bit mask of options See {@link DateUtils#formatDateRange(Context, Formatter, |
| * long, long, int, String) formatDateRange} |
| * @return a string containing the formatted date/time range. |
| */ |
| public static String formatDateRange( |
| Context context, long startMillis, long endMillis, int flags) { |
| return mTZUtils.formatDateRange(context, startMillis, endMillis, flags); |
| } |
| |
| public static boolean getDefaultVibrate(Context context, SharedPreferences prefs) { |
| boolean vibrate; |
| if (prefs.contains(KEY_ALERTS_VIBRATE_WHEN)) { |
| // Migrate setting to new 4.2 behavior |
| // |
| // silent and never -> off |
| // always -> on |
| String vibrateWhen = prefs.getString(KEY_ALERTS_VIBRATE_WHEN, null); |
| vibrate = vibrateWhen != null && vibrateWhen.equals(context |
| .getString(R.string.prefDefault_alerts_vibrate_true)); |
| prefs.edit().remove(KEY_ALERTS_VIBRATE_WHEN).commit(); |
| Log.d(TAG, "Migrating KEY_ALERTS_VIBRATE_WHEN(" + vibrateWhen |
| + ") to KEY_ALERTS_VIBRATE = " + vibrate); |
| } else { |
| vibrate = prefs.getBoolean(GeneralPreferences.KEY_ALERTS_VIBRATE, |
| false); |
| } |
| return vibrate; |
| } |
| |
| public static String[] getSharedPreference(Context context, String key, String[] defaultValue) { |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| Set<String> ss = prefs.getStringSet(key, null); |
| if (ss != null) { |
| String strings[] = new String[ss.size()]; |
| return ss.toArray(strings); |
| } |
| return defaultValue; |
| } |
| |
| public static String getSharedPreference(Context context, String key, String defaultValue) { |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| return prefs.getString(key, defaultValue); |
| } |
| |
| public static int getSharedPreference(Context context, String key, int defaultValue) { |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| return prefs.getInt(key, defaultValue); |
| } |
| |
| public static boolean getSharedPreference(Context context, String key, boolean defaultValue) { |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| return prefs.getBoolean(key, defaultValue); |
| } |
| |
| /** |
| * Asynchronously sets the preference with the given key to the given value |
| * |
| * @param context the context to use to get preferences from |
| * @param key the key of the preference to set |
| * @param value the value to set |
| */ |
| public static void setSharedPreference(Context context, String key, String value) { |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| prefs.edit().putString(key, value).apply(); |
| } |
| |
| public static void setSharedPreference(Context context, String key, String[] values) { |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| LinkedHashSet<String> set = new LinkedHashSet<String>(); |
| for (String value : values) { |
| set.add(value); |
| } |
| prefs.edit().putStringSet(key, set).apply(); |
| } |
| |
| protected static void tardis() { |
| mTardis = System.currentTimeMillis(); |
| } |
| |
| protected static long getTardis() { |
| return mTardis; |
| } |
| |
| public static void setSharedPreference(Context context, String key, boolean value) { |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| SharedPreferences.Editor editor = prefs.edit(); |
| editor.putBoolean(key, value); |
| editor.apply(); |
| } |
| |
| static void setSharedPreference(Context context, String key, int value) { |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| SharedPreferences.Editor editor = prefs.edit(); |
| editor.putInt(key, value); |
| editor.apply(); |
| } |
| |
| public static void removeSharedPreference(Context context, String key) { |
| SharedPreferences prefs = context.getSharedPreferences( |
| GeneralPreferences.SHARED_PREFS_NAME, Context.MODE_PRIVATE); |
| prefs.edit().remove(key).apply(); |
| } |
| |
| // The backed up ring tone preference should not used because it is a device |
| // specific Uri. The preference now lives in a separate non-backed-up |
| // shared_pref file (SHARED_PREFS_NAME_NO_BACKUP). The preference in the old |
| // backed-up shared_pref file (SHARED_PREFS_NAME) is used only to control the |
| // default value when the ringtone dialog opens up. |
| // |
| // At backup manager "restore" time (which should happen before launcher |
| // comes up for the first time), the value will be set/reset to default |
| // ringtone. |
| public static String getRingTonePreference(Context context) { |
| SharedPreferences prefs = context.getSharedPreferences( |
| GeneralPreferences.SHARED_PREFS_NAME_NO_BACKUP, Context.MODE_PRIVATE); |
| String ringtone = prefs.getString(GeneralPreferences.KEY_ALERTS_RINGTONE, null); |
| |
| // If it hasn't been populated yet, that means new code is running for |
| // the first time and restore hasn't happened. Migrate value from |
| // backed-up shared_pref to non-shared_pref. |
| if (ringtone == null) { |
| // Read from the old place with a default of DEFAULT_RINGTONE |
| ringtone = getSharedPreference(context, GeneralPreferences.KEY_ALERTS_RINGTONE, |
| GeneralPreferences.DEFAULT_RINGTONE); |
| |
| // Write it to the new place |
| setRingTonePreference(context, ringtone); |
| } |
| |
| return ringtone; |
| } |
| |
| public static void setRingTonePreference(Context context, String value) { |
| SharedPreferences prefs = context.getSharedPreferences( |
| GeneralPreferences.SHARED_PREFS_NAME_NO_BACKUP, Context.MODE_PRIVATE); |
| prefs.edit().putString(GeneralPreferences.KEY_ALERTS_RINGTONE, value).apply(); |
| } |
| |
| /** |
| * Save default agenda/day/week/month view for next time |
| * |
| * @param context |
| * @param viewId {@link CalendarController.ViewType} |
| */ |
| static void setDefaultView(Context context, int viewId) { |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| SharedPreferences.Editor editor = prefs.edit(); |
| |
| boolean validDetailView = false; |
| if (mAllowWeekForDetailView && viewId == CalendarController.ViewType.WEEK) { |
| validDetailView = true; |
| } else { |
| validDetailView = viewId == CalendarController.ViewType.AGENDA |
| || viewId == CalendarController.ViewType.DAY; |
| } |
| |
| if (validDetailView) { |
| // Record the detail start view |
| editor.putInt(GeneralPreferences.KEY_DETAILED_VIEW, viewId); |
| } |
| |
| // Record the (new) start view |
| editor.putInt(GeneralPreferences.KEY_START_VIEW, viewId); |
| editor.apply(); |
| } |
| |
| public static MatrixCursor matrixCursorFromCursor(Cursor cursor) { |
| if (cursor == null) { |
| return null; |
| } |
| |
| String[] columnNames = cursor.getColumnNames(); |
| if (columnNames == null) { |
| columnNames = new String[] {}; |
| } |
| MatrixCursor newCursor = new MatrixCursor(columnNames); |
| int numColumns = cursor.getColumnCount(); |
| String data[] = new String[numColumns]; |
| cursor.moveToPosition(-1); |
| while (cursor.moveToNext()) { |
| for (int i = 0; i < numColumns; i++) { |
| data[i] = cursor.getString(i); |
| } |
| newCursor.addRow(data); |
| } |
| return newCursor; |
| } |
| |
| /** |
| * Compares two cursors to see if they contain the same data. |
| * |
| * @return Returns true of the cursors contain the same data and are not |
| * null, false otherwise |
| */ |
| public static boolean compareCursors(Cursor c1, Cursor c2) { |
| if (c1 == null || c2 == null) { |
| return false; |
| } |
| |
| int numColumns = c1.getColumnCount(); |
| if (numColumns != c2.getColumnCount()) { |
| return false; |
| } |
| |
| if (c1.getCount() != c2.getCount()) { |
| return false; |
| } |
| |
| c1.moveToPosition(-1); |
| c2.moveToPosition(-1); |
| while (c1.moveToNext() && c2.moveToNext()) { |
| for (int i = 0; i < numColumns; i++) { |
| if (!TextUtils.equals(c1.getString(i), c2.getString(i))) { |
| return false; |
| } |
| } |
| } |
| |
| return true; |
| } |
| |
| /** |
| * If the given intent specifies a time (in milliseconds since the epoch), |
| * then that time is returned. Otherwise, the current time is returned. |
| */ |
| public static final long timeFromIntentInMillis(Intent intent) { |
| // If the time was specified, then use that. Otherwise, use the current |
| // time. |
| Uri data = intent.getData(); |
| long millis = intent.getLongExtra(EXTRA_EVENT_BEGIN_TIME, -1); |
| if (millis == -1 && data != null && data.isHierarchical()) { |
| List<String> path = data.getPathSegments(); |
| if (path.size() == 2 && path.get(0).equals("time")) { |
| try { |
| millis = Long.valueOf(data.getLastPathSegment()); |
| } catch (NumberFormatException e) { |
| Log.i("Calendar", "timeFromIntentInMillis: Data existed but no valid time " |
| + "found. Using current time."); |
| } |
| } |
| } |
| if (millis <= 0) { |
| millis = System.currentTimeMillis(); |
| } |
| return millis; |
| } |
| |
| /** |
| * Formats the given Time object so that it gives the month and year (for |
| * example, "September 2007"). |
| * |
| * @param time the time to format |
| * @return the string containing the weekday and the date |
| */ |
| public static String formatMonthYear(Context context, Time time) { |
| int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_MONTH_DAY |
| | DateUtils.FORMAT_SHOW_YEAR; |
| long millis = time.toMillis(true); |
| return formatDateRange(context, millis, millis, flags); |
| } |
| |
| /** |
| * Returns a list joined together by the provided delimiter, for example, |
| * ["a", "b", "c"] could be joined into "a,b,c" |
| * |
| * @param things the things to join together |
| * @param delim the delimiter to use |
| * @return a string contained the things joined together |
| */ |
| public static String join(List<?> things, String delim) { |
| StringBuilder builder = new StringBuilder(); |
| boolean first = true; |
| for (Object thing : things) { |
| if (first) { |
| first = false; |
| } else { |
| builder.append(delim); |
| } |
| builder.append(thing.toString()); |
| } |
| return builder.toString(); |
| } |
| |
| /** |
| * Returns the week since {@link Time#EPOCH_JULIAN_DAY} (Jan 1, 1970) |
| * adjusted for first day of week. |
| * |
| * This takes a julian day and the week start day and calculates which |
| * week since {@link Time#EPOCH_JULIAN_DAY} that day occurs in, starting |
| * at 0. *Do not* use this to compute the ISO week number for the year. |
| * |
| * @param julianDay The julian day to calculate the week number for |
| * @param firstDayOfWeek Which week day is the first day of the week, |
| * see {@link Time#SUNDAY} |
| * @return Weeks since the epoch |
| */ |
| public static int getWeeksSinceEpochFromJulianDay(int julianDay, int firstDayOfWeek) { |
| int diff = Time.THURSDAY - firstDayOfWeek; |
| if (diff < 0) { |
| diff += 7; |
| } |
| int refDay = Time.EPOCH_JULIAN_DAY - diff; |
| return (julianDay - refDay) / 7; |
| } |
| |
| /** |
| * Takes a number of weeks since the epoch and calculates the Julian day of |
| * the Monday for that week. |
| * |
| * This assumes that the week containing the {@link Time#EPOCH_JULIAN_DAY} |
| * is considered week 0. It returns the Julian day for the Monday |
| * {@code week} weeks after the Monday of the week containing the epoch. |
| * |
| * @param week Number of weeks since the epoch |
| * @return The julian day for the Monday of the given week since the epoch |
| */ |
| public static int getJulianMondayFromWeeksSinceEpoch(int week) { |
| return MONDAY_BEFORE_JULIAN_EPOCH + week * 7; |
| } |
| |
| /** |
| * Get first day of week as android.text.format.Time constant. |
| * |
| * @return the first day of week in android.text.format.Time |
| */ |
| public static int getFirstDayOfWeek(Context context) { |
| SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| String pref = prefs.getString( |
| GeneralPreferences.KEY_WEEK_START_DAY, GeneralPreferences.WEEK_START_DEFAULT); |
| |
| int startDay; |
| if (GeneralPreferences.WEEK_START_DEFAULT.equals(pref)) { |
| startDay = Calendar.getInstance().getFirstDayOfWeek(); |
| } else { |
| startDay = Integer.parseInt(pref); |
| } |
| |
| if (startDay == Calendar.SATURDAY) { |
| return Time.SATURDAY; |
| } else if (startDay == Calendar.MONDAY) { |
| return Time.MONDAY; |
| } else { |
| return Time.SUNDAY; |
| } |
| } |
| |
| /** |
| * Get first day of week as java.util.Calendar constant. |
| * |
| * @return the first day of week as a java.util.Calendar constant |
| */ |
| public static int getFirstDayOfWeekAsCalendar(Context context) { |
| return convertDayOfWeekFromTimeToCalendar(getFirstDayOfWeek(context)); |
| } |
| |
| /** |
| * Converts the day of the week from android.text.format.Time to java.util.Calendar |
| */ |
| public static int convertDayOfWeekFromTimeToCalendar(int timeDayOfWeek) { |
| switch (timeDayOfWeek) { |
| case Time.MONDAY: |
| return Calendar.MONDAY; |
| case Time.TUESDAY: |
| return Calendar.TUESDAY; |
| case Time.WEDNESDAY: |
| return Calendar.WEDNESDAY; |
| case Time.THURSDAY: |
| return Calendar.THURSDAY; |
| case Time.FRIDAY: |
| return Calendar.FRIDAY; |
| case Time.SATURDAY: |
| return Calendar.SATURDAY; |
| case Time.SUNDAY: |
| return Calendar.SUNDAY; |
| default: |
| throw new IllegalArgumentException("Argument must be between Time.SUNDAY and " + |
| "Time.SATURDAY"); |
| } |
| } |
| |
| /** |
| * @return true when week number should be shown. |
| */ |
| public static boolean getShowWeekNumber(Context context) { |
| final SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| return prefs.getBoolean( |
| GeneralPreferences.KEY_SHOW_WEEK_NUM, GeneralPreferences.DEFAULT_SHOW_WEEK_NUM); |
| } |
| |
| /** |
| * @return true when declined events should be hidden. |
| */ |
| public static boolean getHideDeclinedEvents(Context context) { |
| final SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| return prefs.getBoolean(GeneralPreferences.KEY_HIDE_DECLINED, false); |
| } |
| |
| public static int getDaysPerWeek(Context context) { |
| final SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); |
| return prefs.getInt(GeneralPreferences.KEY_DAYS_PER_WEEK, 7); |
| } |
| |
| /** |
| * Determine whether the column position is Saturday or not. |
| * |
| * @param column the column position |
| * @param firstDayOfWeek the first day of week in android.text.format.Time |
| * @return true if the column is Saturday position |
| */ |
| public static boolean isSaturday(int column, int firstDayOfWeek) { |
| return (firstDayOfWeek == Time.SUNDAY && column == 6) |
| || (firstDayOfWeek == Time.MONDAY && column == 5) |
| || (firstDayOfWeek == Time.SATURDAY && column == 0); |
| } |
| |
| /** |
| * Determine whether the column position is Sunday or not. |
| * |
| * @param column the column position |
| * @param firstDayOfWeek the first day of week in android.text.format.Time |
| * @return true if the column is Sunday position |
| */ |
| public static boolean isSunday(int column, int firstDayOfWeek) { |
| return (firstDayOfWeek == Time.SUNDAY && column == 0) |
| || (firstDayOfWeek == Time.MONDAY && column == 6) |
| || (firstDayOfWeek == Time.SATURDAY && column == 1); |
| } |
| |
| /** |
| * Convert given UTC time into current local time. This assumes it is for an |
| * allday event and will adjust the time to be on a midnight boundary. |
| * |
| * @param recycle Time object to recycle, otherwise null. |
| * @param utcTime Time to convert, in UTC. |
| * @param tz The time zone to convert this time to. |
| */ |
| public static long convertAlldayUtcToLocal(Time recycle, long utcTime, String tz) { |
| if (recycle == null) { |
| recycle = new Time(); |
| } |
| recycle.timezone = Time.TIMEZONE_UTC; |
| recycle.set(utcTime); |
| recycle.timezone = tz; |
| return recycle.normalize(true); |
| } |
| |
| public static long convertAlldayLocalToUTC(Time recycle, long localTime, String tz) { |
| if (recycle == null) { |
| recycle = new Time(); |
| } |
| recycle.timezone = tz; |
| recycle.set(localTime); |
| recycle.timezone = Time.TIMEZONE_UTC; |
| return recycle.normalize(true); |
| } |
| |
| /** |
| * Finds and returns the next midnight after "theTime" in milliseconds UTC |
| * |
| * @param recycle - Time object to recycle, otherwise null. |
| * @param theTime - Time used for calculations (in UTC) |
| * @param tz The time zone to convert this time to. |
| */ |
| public static long getNextMidnight(Time recycle, long theTime, String tz) { |
| if (recycle == null) { |
| recycle = new Time(); |
| } |
| recycle.timezone = tz; |
| recycle.set(theTime); |
| recycle.monthDay ++; |
| recycle.hour = 0; |
| recycle.minute = 0; |
| recycle.second = 0; |
| return recycle.normalize(true); |
| } |
| |
| /** |
| * Scan through a cursor of calendars and check if names are duplicated. |
| * This travels a cursor containing calendar display names and fills in the |
| * provided map with whether or not each name is repeated. |
| * |
| * @param isDuplicateName The map to put the duplicate check results in. |
| * @param cursor The query of calendars to check |
| * @param nameIndex The column of the query that contains the display name |
| */ |
| public static void checkForDuplicateNames( |
| Map<String, Boolean> isDuplicateName, Cursor cursor, int nameIndex) { |
| isDuplicateName.clear(); |
| cursor.moveToPosition(-1); |
| while (cursor.moveToNext()) { |
| String displayName = cursor.getString(nameIndex); |
| // Set it to true if we've seen this name before, false otherwise |
| if (displayName != null) { |
| isDuplicateName.put(displayName, isDuplicateName.containsKey(displayName)); |
| } |
| } |
| } |
| |
| /** |
| * Null-safe object comparison |
| * |
| * @param s1 |
| * @param s2 |
| * @return |
| */ |
| public static boolean equals(Object o1, Object o2) { |
| return o1 == null ? o2 == null : o1.equals(o2); |
| } |
| |
| public static void setAllowWeekForDetailView(boolean allowWeekView) { |
| mAllowWeekForDetailView = allowWeekView; |
| } |
| |
| public static boolean getAllowWeekForDetailView() { |
| return mAllowWeekForDetailView; |
| } |
| |
| public static boolean getConfigBool(Context c, int key) { |
| return c.getResources().getBoolean(key); |
| } |
| |
| /** |
| * For devices with Jellybean or later, darkens the given color to ensure that white text is |
| * clearly visible on top of it. For devices prior to Jellybean, does nothing, as the |
| * sync adapter handles the color change. |
| * |
| * @param color |
| */ |
| public static int getDisplayColorFromColor(int color) { |
| if (!isJellybeanOrLater()) { |
| return color; |
| } |
| |
| float[] hsv = new float[3]; |
| Color.colorToHSV(color, hsv); |
| hsv[1] = Math.min(hsv[1] * SATURATION_ADJUST, 1.0f); |
| hsv[2] = hsv[2] * INTENSITY_ADJUST; |
| return Color.HSVToColor(hsv); |
| } |
| |
| // This takes a color and computes what it would look like blended with |
| // white. The result is the color that should be used for declined events. |
| public static int getDeclinedColorFromColor(int color) { |
| int bg = 0xffffffff; |
| int a = DECLINED_EVENT_ALPHA; |
| int r = (((color & 0x00ff0000) * a) + ((bg & 0x00ff0000) * (0xff - a))) & 0xff000000; |
| int g = (((color & 0x0000ff00) * a) + ((bg & 0x0000ff00) * (0xff - a))) & 0x00ff0000; |
| int b = (((color & 0x000000ff) * a) + ((bg & 0x000000ff) * (0xff - a))) & 0x0000ff00; |
| return (0xff000000) | ((r | g | b) >> 8); |
| } |
| |
| // A single strand represents one color of events. Events are divided up by |
| // color to make them convenient to draw. The black strand is special in |
| // that it holds conflicting events as well as color settings for allday on |
| // each day. |
| public static class DNAStrand { |
| public float[] points; |
| public int[] allDays; // color for the allday, 0 means no event |
| int position; |
| public int color; |
| int count; |
| } |
| |
| // A segment is a single continuous length of time occupied by a single |
| // color. Segments should never span multiple days. |
| private static class DNASegment { |
| int startMinute; // in minutes since the start of the week |
| int endMinute; |
| int color; // Calendar color or black for conflicts |
| int day; // quick reference to the day this segment is on |
| } |
| |
| /** |
| * Converts a list of events to a list of segments to draw. Assumes list is |
| * ordered by start time of the events. The function processes events for a |
| * range of days from firstJulianDay to firstJulianDay + dayXs.length - 1. |
| * The algorithm goes over all the events and creates a set of segments |
| * ordered by start time. This list of segments is then converted into a |
| * HashMap of strands which contain the draw points and are organized by |
| * color. The strands can then be drawn by setting the paint color to each |
| * strand's color and calling drawLines on its set of points. The points are |
| * set up using the following parameters. |
| * <ul> |
| * <li>Events between midnight and WORK_DAY_START_MINUTES are compressed |
| * into the first 1/8th of the space between top and bottom.</li> |
| * <li>Events between WORK_DAY_END_MINUTES and the following midnight are |
| * compressed into the last 1/8th of the space between top and bottom</li> |
| * <li>Events between WORK_DAY_START_MINUTES and WORK_DAY_END_MINUTES use |
| * the remaining 3/4ths of the space</li> |
| * <li>All segments drawn will maintain at least minPixels height, except |
| * for conflicts in the first or last 1/8th, which may be smaller</li> |
| * </ul> |
| * |
| * @param firstJulianDay The julian day of the first day of events |
| * @param events A list of events sorted by start time |
| * @param top The lowest y value the dna should be drawn at |
| * @param bottom The highest y value the dna should be drawn at |
| * @param dayXs An array of x values to draw the dna at, one for each day |
| * @param conflictColor the color to use for conflicts |
| * @return |
| */ |
| public static HashMap<Integer, DNAStrand> createDNAStrands(int firstJulianDay, |
| ArrayList<Event> events, int top, int bottom, int minPixels, int[] dayXs, |
| Context context) { |
| |
| if (!mMinutesLoaded) { |
| if (context == null) { |
| Log.wtf(TAG, "No context and haven't loaded parameters yet! Can't create DNA."); |
| } |
| Resources res = context.getResources(); |
| CONFLICT_COLOR = res.getColor(R.color.month_dna_conflict_time_color); |
| WORK_DAY_START_MINUTES = res.getInteger(R.integer.work_start_minutes); |
| WORK_DAY_END_MINUTES = res.getInteger(R.integer.work_end_minutes); |
| WORK_DAY_END_LENGTH = DAY_IN_MINUTES - WORK_DAY_END_MINUTES; |
| WORK_DAY_MINUTES = WORK_DAY_END_MINUTES - WORK_DAY_START_MINUTES; |
| mMinutesLoaded = true; |
| } |
| |
| if (events == null || events.isEmpty() || dayXs == null || dayXs.length < 1 |
| || bottom - top < 8 || minPixels < 0) { |
| Log.e(TAG, |
| "Bad values for createDNAStrands! events:" + events + " dayXs:" |
| + Arrays.toString(dayXs) + " bot-top:" + (bottom - top) + " minPixels:" |
| + minPixels); |
| return null; |
| } |
| |
| LinkedList<DNASegment> segments = new LinkedList<DNASegment>(); |
| HashMap<Integer, DNAStrand> strands = new HashMap<Integer, DNAStrand>(); |
| // add a black strand by default, other colors will get added in |
| // the loop |
| DNAStrand blackStrand = new DNAStrand(); |
| blackStrand.color = CONFLICT_COLOR; |
| strands.put(CONFLICT_COLOR, blackStrand); |
| // the min length is the number of minutes that will occupy |
| // MIN_SEGMENT_PIXELS in the 'work day' time slot. This computes the |
| // minutes/pixel * minpx where the number of pixels are 3/4 the total |
| // dna height: 4*(mins/(px * 3/4)) |
| int minMinutes = minPixels * 4 * WORK_DAY_MINUTES / (3 * (bottom - top)); |
| |
| // There are slightly fewer than half as many pixels in 1/6 the space, |
| // so round to 2.5x for the min minutes in the non-work area |
| int minOtherMinutes = minMinutes * 5 / 2; |
| int lastJulianDay = firstJulianDay + dayXs.length - 1; |
| |
| Event event = new Event(); |
| // Go through all the events for the week |
| for (Event currEvent : events) { |
| // if this event is outside the weeks range skip it |
| if (currEvent.endDay < firstJulianDay || currEvent.startDay > lastJulianDay) { |
| continue; |
| } |
| if (currEvent.drawAsAllday()) { |
| addAllDayToStrands(currEvent, strands, firstJulianDay, dayXs.length); |
| continue; |
| } |
| // Copy the event over so we can clip its start and end to our range |
| currEvent.copyTo(event); |
| if (event.startDay < firstJulianDay) { |
| event.startDay = firstJulianDay; |
| event.startTime = 0; |
| } |
| // If it starts after the work day make sure the start is at least |
| // minPixels from midnight |
| if (event.startTime > DAY_IN_MINUTES - minOtherMinutes) { |
| event.startTime = DAY_IN_MINUTES - minOtherMinutes; |
| } |
| if (event.endDay > lastJulianDay) { |
| event.endDay = lastJulianDay; |
| event.endTime = DAY_IN_MINUTES - 1; |
| } |
| // If the end time is before the work day make sure it ends at least |
| // minPixels after midnight |
| if (event.endTime < minOtherMinutes) { |
| event.endTime = minOtherMinutes; |
| } |
| // If the start and end are on the same day make sure they are at |
| // least minPixels apart. This only needs to be done for times |
| // outside the work day as the min distance for within the work day |
| // is enforced in the segment code. |
| if (event.startDay == event.endDay && |
| event.endTime - event.startTime < minOtherMinutes) { |
| // If it's less than minPixels in an area before the work |
| // day |
| if (event.startTime < WORK_DAY_START_MINUTES) { |
| // extend the end to the first easy guarantee that it's |
| // minPixels |
| event.endTime = Math.min(event.startTime + minOtherMinutes, |
| WORK_DAY_START_MINUTES + minMinutes); |
| // if it's in the area after the work day |
| } else if (event.endTime > WORK_DAY_END_MINUTES) { |
| // First try shifting the end but not past midnight |
| event.endTime = Math.min(event.endTime + minOtherMinutes, DAY_IN_MINUTES - 1); |
| // if it's still too small move the start back |
| if (event.endTime - event.startTime < minOtherMinutes) { |
| event.startTime = event.endTime - minOtherMinutes; |
| } |
| } |
| } |
| |
| // This handles adding the first segment |
| if (segments.size() == 0) { |
| addNewSegment(segments, event, strands, firstJulianDay, 0, minMinutes); |
| continue; |
| } |
| // Now compare our current start time to the end time of the last |
| // segment in the list |
| DNASegment lastSegment = segments.getLast(); |
| int startMinute = (event.startDay - firstJulianDay) * DAY_IN_MINUTES + event.startTime; |
| int endMinute = Math.max((event.endDay - firstJulianDay) * DAY_IN_MINUTES |
| + event.endTime, startMinute + minMinutes); |
| |
| if (startMinute < 0) { |
| startMinute = 0; |
| } |
| if (endMinute >= WEEK_IN_MINUTES) { |
| endMinute = WEEK_IN_MINUTES - 1; |
| } |
| // If we start before the last segment in the list ends we need to |
| // start going through the list as this may conflict with other |
| // events |
| if (startMinute < lastSegment.endMinute) { |
| int i = segments.size(); |
| // find the last segment this event intersects with |
| while (--i >= 0 && endMinute < segments.get(i).startMinute); |
| |
| DNASegment currSegment; |
| // for each segment this event intersects with |
| for (; i >= 0 && startMinute <= (currSegment = segments.get(i)).endMinute; i--) { |
| // if the segment is already a conflict ignore it |
| if (currSegment.color == CONFLICT_COLOR) { |
| continue; |
| } |
| // if the event ends before the segment and wouldn't create |
| // a segment that is too small split off the right side |
| if (endMinute < currSegment.endMinute - minMinutes) { |
| DNASegment rhs = new DNASegment(); |
| rhs.endMinute = currSegment.endMinute; |
| rhs.color = currSegment.color; |
| rhs.startMinute = endMinute + 1; |
| rhs.day = currSegment.day; |
| currSegment.endMinute = endMinute; |
| segments.add(i + 1, rhs); |
| strands.get(rhs.color).count++; |
| if (DEBUG) { |
| Log.d(TAG, "Added rhs, curr:" + currSegment.toString() + " i:" |
| + segments.get(i).toString()); |
| } |
| } |
| // if the event starts after the segment and wouldn't create |
| // a segment that is too small split off the left side |
| if (startMinute > currSegment.startMinute + minMinutes) { |
| DNASegment lhs = new DNASegment(); |
| lhs.startMinute = currSegment.startMinute; |
| lhs.color = currSegment.color; |
| lhs.endMinute = startMinute - 1; |
| lhs.day = currSegment.day; |
| currSegment.startMinute = startMinute; |
| // increment i so that we are at the right position when |
| // referencing the segments to the right and left of the |
| // current segment. |
| segments.add(i++, lhs); |
| strands.get(lhs.color).count++; |
| if (DEBUG) { |
| Log.d(TAG, "Added lhs, curr:" + currSegment.toString() + " i:" |
| + segments.get(i).toString()); |
| } |
| } |
| // if the right side is black merge this with the segment to |
| // the right if they're on the same day and overlap |
| if (i + 1 < segments.size()) { |
| DNASegment rhs = segments.get(i + 1); |
| if (rhs.color == CONFLICT_COLOR && currSegment.day == rhs.day |
| && rhs.startMinute <= currSegment.endMinute + 1) { |
| rhs.startMinute = Math.min(currSegment.startMinute, rhs.startMinute); |
| segments.remove(currSegment); |
| strands.get(currSegment.color).count--; |
| // point at the new current segment |
| currSegment = rhs; |
| } |
| } |
| // if the left side is black merge this with the segment to |
| // the left if they're on the same day and overlap |
| if (i - 1 >= 0) { |
| DNASegment lhs = segments.get(i - 1); |
| if (lhs.color == CONFLICT_COLOR && currSegment.day == lhs.day |
| && lhs.endMinute >= currSegment.startMinute - 1) { |
| lhs.endMinute = Math.max(currSegment.endMinute, lhs.endMinute); |
| segments.remove(currSegment); |
| strands.get(currSegment.color).count--; |
| // point at the new current segment |
| currSegment = lhs; |
| // point i at the new current segment in case new |
| // code is added |
| i--; |
| } |
| } |
| // if we're still not black, decrement the count for the |
| // color being removed, change this to black, and increment |
| // the black count |
| if (currSegment.color != CONFLICT_COLOR) { |
| strands.get(currSegment.color).count--; |
| currSegment.color = CONFLICT_COLOR; |
| strands.get(CONFLICT_COLOR).count++; |
| } |
| } |
| |
| } |
| // If this event extends beyond the last segment add a new segment |
| if (endMinute > lastSegment.endMinute) { |
| addNewSegment(segments, event, strands, firstJulianDay, lastSegment.endMinute, |
| minMinutes); |
| } |
| } |
| weaveDNAStrands(segments, firstJulianDay, strands, top, bottom, dayXs); |
| return strands; |
| } |
| |
| // This figures out allDay colors as allDay events are found |
| private static void addAllDayToStrands(Event event, HashMap<Integer, DNAStrand> strands, |
| int firstJulianDay, int numDays) { |
| DNAStrand strand = getOrCreateStrand(strands, CONFLICT_COLOR); |
| // if we haven't initialized the allDay portion create it now |
| if (strand.allDays == null) { |
| strand.allDays = new int[numDays]; |
| } |
| |
| // For each day this event is on update the color |
| int end = Math.min(event.endDay - firstJulianDay, numDays - 1); |
| for (int i = Math.max(event.startDay - firstJulianDay, 0); i <= end; i++) { |
| if (strand.allDays[i] != 0) { |
| // if this day already had a color, it is now a conflict |
| strand.allDays[i] = CONFLICT_COLOR; |
| } else { |
| // else it's just the color of the event |
| strand.allDays[i] = event.color; |
| } |
| } |
| } |
| |
| // This processes all the segments, sorts them by color, and generates a |
| // list of points to draw |
| private static void weaveDNAStrands(LinkedList<DNASegment> segments, int firstJulianDay, |
| HashMap<Integer, DNAStrand> strands, int top, int bottom, int[] dayXs) { |
| // First, get rid of any colors that ended up with no segments |
| Iterator<DNAStrand> strandIterator = strands.values().iterator(); |
| while (strandIterator.hasNext()) { |
| DNAStrand strand = strandIterator.next(); |
| if (strand.count < 1 && strand.allDays == null) { |
| strandIterator.remove(); |
| continue; |
| } |
| strand.points = new float[strand.count * 4]; |
| strand.position = 0; |
| } |
| // Go through each segment and compute its points |
| for (DNASegment segment : segments) { |
| // Add the points to the strand of that color |
| DNAStrand strand = strands.get(segment.color); |
| int dayIndex = segment.day - firstJulianDay; |
| int dayStartMinute = segment.startMinute % DAY_IN_MINUTES; |
| int dayEndMinute = segment.endMinute % DAY_IN_MINUTES; |
| int height = bottom - top; |
| int workDayHeight = height * 3 / 4; |
| int remainderHeight = (height - workDayHeight) / 2; |
| |
| int x = dayXs[dayIndex]; |
| int y0 = 0; |
| int y1 = 0; |
| |
| y0 = top + getPixelOffsetFromMinutes(dayStartMinute, workDayHeight, remainderHeight); |
| y1 = top + getPixelOffsetFromMinutes(dayEndMinute, workDayHeight, remainderHeight); |
| if (DEBUG) { |
| Log.d(TAG, "Adding " + Integer.toHexString(segment.color) + " at x,y0,y1: " + x |
| + " " + y0 + " " + y1 + " for " + dayStartMinute + " " + dayEndMinute); |
| } |
| strand.points[strand.position++] = x; |
| strand.points[strand.position++] = y0; |
| strand.points[strand.position++] = x; |
| strand.points[strand.position++] = y1; |
| } |
| } |
| |
| /** |
| * Compute a pixel offset from the top for a given minute from the work day |
| * height and the height of the top area. |
| */ |
| private static int getPixelOffsetFromMinutes(int minute, int workDayHeight, |
| int remainderHeight) { |
| int y; |
| if (minute < WORK_DAY_START_MINUTES) { |
| y = minute * remainderHeight / WORK_DAY_START_MINUTES; |
| } else if (minute < WORK_DAY_END_MINUTES) { |
| y = remainderHeight + (minute - WORK_DAY_START_MINUTES) * workDayHeight |
| / WORK_DAY_MINUTES; |
| } else { |
| y = remainderHeight + workDayHeight + (minute - WORK_DAY_END_MINUTES) * remainderHeight |
| / WORK_DAY_END_LENGTH; |
| } |
| return y; |
| } |
| |
| /** |
| * Add a new segment based on the event provided. This will handle splitting |
| * segments across day boundaries and ensures a minimum size for segments. |
| */ |
| private static void addNewSegment(LinkedList<DNASegment> segments, Event event, |
| HashMap<Integer, DNAStrand> strands, int firstJulianDay, int minStart, int minMinutes) { |
| if (event.startDay > event.endDay) { |
| Log.wtf(TAG, "Event starts after it ends: " + event.toString()); |
| } |
| // If this is a multiday event split it up by day |
| if (event.startDay != event.endDay) { |
| Event lhs = new Event(); |
| lhs.color = event.color; |
| lhs.startDay = event.startDay; |
| // the first day we want the start time to be the actual start time |
| lhs.startTime = event.startTime; |
| lhs.endDay = lhs.startDay; |
| lhs.endTime = DAY_IN_MINUTES - 1; |
| // Nearly recursive iteration! |
| while (lhs.startDay != event.endDay) { |
| addNewSegment(segments, lhs, strands, firstJulianDay, minStart, minMinutes); |
| // The days in between are all day, even though that shouldn't |
| // actually happen due to the allday filtering |
| lhs.startDay++; |
| lhs.endDay = lhs.startDay; |
| lhs.startTime = 0; |
| minStart = 0; |
| } |
| // The last day we want the end time to be the actual end time |
| lhs.endTime = event.endTime; |
| event = lhs; |
| } |
| // Create the new segment and compute its fields |
| DNASegment segment = new DNASegment(); |
| int dayOffset = (event.startDay - firstJulianDay) * DAY_IN_MINUTES; |
| int endOfDay = dayOffset + DAY_IN_MINUTES - 1; |
| // clip the start if needed |
| segment.startMinute = Math.max(dayOffset + event.startTime, minStart); |
| // and extend the end if it's too small, but not beyond the end of the |
| // day |
| int minEnd = Math.min(segment.startMinute + minMinutes, endOfDay); |
| segment.endMinute = Math.max(dayOffset + event.endTime, minEnd); |
| if (segment.endMinute > endOfDay) { |
| segment.endMinute = endOfDay; |
| } |
| |
| segment.color = event.color; |
| segment.day = event.startDay; |
| segments.add(segment); |
| // increment the count for the correct color or add a new strand if we |
| // don't have that color yet |
| DNAStrand strand = getOrCreateStrand(strands, segment.color); |
| strand.count++; |
| } |
| |
| /** |
| * Try to get a strand of the given color. Create it if it doesn't exist. |
| */ |
| private static DNAStrand getOrCreateStrand(HashMap<Integer, DNAStrand> strands, int color) { |
| DNAStrand strand = strands.get(color); |
| if (strand == null) { |
| strand = new DNAStrand(); |
| strand.color = color; |
| strand.count = 0; |
| strands.put(strand.color, strand); |
| } |
| return strand; |
| } |
| |
| /** |
| * Sends an intent to launch the top level Calendar view. |
| * |
| * @param context |
| */ |
| public static void returnToCalendarHome(Context context) { |
| Intent launchIntent = new Intent(context, AllInOneActivity.class); |
| launchIntent.setAction(Intent.ACTION_DEFAULT); |
| launchIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); |
| launchIntent.putExtra(INTENT_KEY_HOME, true); |
| context.startActivity(launchIntent); |
| } |
| |
| /** |
| * This sets up a search view to use Calendar's search suggestions provider |
| * and to allow refining the search. |
| * |
| * @param view The {@link SearchView} to set up |
| * @param act The activity using the view |
| */ |
| public static void setUpSearchView(SearchView view, Activity act) { |
| SearchManager searchManager = (SearchManager) act.getSystemService(Context.SEARCH_SERVICE); |
| view.setSearchableInfo(searchManager.getSearchableInfo(act.getComponentName())); |
| view.setQueryRefinementEnabled(true); |
| } |
| |
| /** |
| * Given a context and a time in millis since unix epoch figures out the |
| * correct week of the year for that time. |
| * |
| * @param millisSinceEpoch |
| * @return |
| */ |
| public static int getWeekNumberFromTime(long millisSinceEpoch, Context context) { |
| Time weekTime = new Time(getTimeZone(context, null)); |
| weekTime.set(millisSinceEpoch); |
| weekTime.normalize(true); |
| int firstDayOfWeek = getFirstDayOfWeek(context); |
| // if the date is on Saturday or Sunday and the start of the week |
| // isn't Monday we may need to shift the date to be in the correct |
| // week |
| if (weekTime.weekDay == Time.SUNDAY |
| && (firstDayOfWeek == Time.SUNDAY || firstDayOfWeek == Time.SATURDAY)) { |
| weekTime.monthDay++; |
| weekTime.normalize(true); |
| } else if (weekTime.weekDay == Time.SATURDAY && firstDayOfWeek == Time.SATURDAY) { |
| weekTime.monthDay += 2; |
| weekTime.normalize(true); |
| } |
| return weekTime.getWeekNumber(); |
| } |
| |
| /** |
| * Formats a day of the week string. This is either just the name of the day |
| * or a combination of yesterday/today/tomorrow and the day of the week. |
| * |
| * @param julianDay The julian day to get the string for |
| * @param todayJulianDay The julian day for today's date |
| * @param millis A utc millis since epoch time that falls on julian day |
| * @param context The calling context, used to get the timezone and do the |
| * formatting |
| * @return |
| */ |
| public static String getDayOfWeekString(int julianDay, int todayJulianDay, long millis, |
| Context context) { |
| getTimeZone(context, null); |
| int flags = DateUtils.FORMAT_SHOW_WEEKDAY; |
| String dayViewText; |
| if (julianDay == todayJulianDay) { |
| dayViewText = context.getString(R.string.agenda_today, |
| mTZUtils.formatDateRange(context, millis, millis, flags).toString()); |
| } else if (julianDay == todayJulianDay - 1) { |
| dayViewText = context.getString(R.string.agenda_yesterday, |
| mTZUtils.formatDateRange(context, millis, millis, flags).toString()); |
| } else if (julianDay == todayJulianDay + 1) { |
| dayViewText = context.getString(R.string.agenda_tomorrow, |
| mTZUtils.formatDateRange(context, millis, millis, flags).toString()); |
| } else { |
| dayViewText = mTZUtils.formatDateRange(context, millis, millis, flags).toString(); |
| } |
| dayViewText = dayViewText.toUpperCase(); |
| return dayViewText; |
| } |
| |
| // Calculate the time until midnight + 1 second and set the handler to |
| // do run the runnable |
| public static void setMidnightUpdater(Handler h, Runnable r, String timezone) { |
| if (h == null || r == null || timezone == null) { |
| return; |
| } |
| long now = System.currentTimeMillis(); |
| Time time = new Time(timezone); |
| time.set(now); |
| long runInMillis = (24 * 3600 - time.hour * 3600 - time.minute * 60 - |
| time.second + 1) * 1000; |
| h.removeCallbacks(r); |
| h.postDelayed(r, runInMillis); |
| } |
| |
| // Stop the midnight update thread |
| public static void resetMidnightUpdater(Handler h, Runnable r) { |
| if (h == null || r == null) { |
| return; |
| } |
| h.removeCallbacks(r); |
| } |
| |
| /** |
| * Returns a string description of the specified time interval. |
| */ |
| public static String getDisplayedDatetime(long startMillis, long endMillis, long currentMillis, |
| String localTimezone, boolean allDay, Context context) { |
| // Configure date/time formatting. |
| int flagsDate = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_WEEKDAY; |
| int flagsTime = DateUtils.FORMAT_SHOW_TIME; |
| if (DateFormat.is24HourFormat(context)) { |
| flagsTime |= DateUtils.FORMAT_24HOUR; |
| } |
| |
| Time currentTime = new Time(localTimezone); |
| currentTime.set(currentMillis); |
| Resources resources = context.getResources(); |
| String datetimeString = null; |
| if (allDay) { |
| // All day events require special timezone adjustment. |
| long localStartMillis = convertAlldayUtcToLocal(null, startMillis, localTimezone); |
| long localEndMillis = convertAlldayUtcToLocal(null, endMillis, localTimezone); |
| if (singleDayEvent(localStartMillis, localEndMillis, currentTime.gmtoff)) { |
| // If possible, use "Today" or "Tomorrow" instead of a full date string. |
| int todayOrTomorrow = isTodayOrTomorrow(context.getResources(), |
| localStartMillis, currentMillis, currentTime.gmtoff); |
| if (TODAY == todayOrTomorrow) { |
| datetimeString = resources.getString(R.string.today); |
| } else if (TOMORROW == todayOrTomorrow) { |
| datetimeString = resources.getString(R.string.tomorrow); |
| } |
| } |
| if (datetimeString == null) { |
| // For multi-day allday events or single-day all-day events that are not |
| // today or tomorrow, use framework formatter. |
| Formatter f = new Formatter(new StringBuilder(50), Locale.getDefault()); |
| datetimeString = DateUtils.formatDateRange(context, f, startMillis, |
| endMillis, flagsDate, Time.TIMEZONE_UTC).toString(); |
| } |
| } else { |
| if (singleDayEvent(startMillis, endMillis, currentTime.gmtoff)) { |
| // Format the time. |
| String timeString = Utils.formatDateRange(context, startMillis, endMillis, |
| flagsTime); |
| |
| // If possible, use "Today" or "Tomorrow" instead of a full date string. |
| int todayOrTomorrow = isTodayOrTomorrow(context.getResources(), startMillis, |
| currentMillis, currentTime.gmtoff); |
| if (TODAY == todayOrTomorrow) { |
| // Example: "Today at 1:00pm - 2:00 pm" |
| datetimeString = resources.getString(R.string.today_at_time_fmt, |
| timeString); |
| } else if (TOMORROW == todayOrTomorrow) { |
| // Example: "Tomorrow at 1:00pm - 2:00 pm" |
| datetimeString = resources.getString(R.string.tomorrow_at_time_fmt, |
| timeString); |
| } else { |
| // Format the full date. Example: "Thursday, April 12, 1:00pm - 2:00pm" |
| String dateString = Utils.formatDateRange(context, startMillis, endMillis, |
| flagsDate); |
| datetimeString = resources.getString(R.string.date_time_fmt, dateString, |
| timeString); |
| } |
| } else { |
| // For multiday events, shorten day/month names. |
| // Example format: "Fri Apr 6, 5:00pm - Sun, Apr 8, 6:00pm" |
| int flagsDatetime = flagsDate | flagsTime | DateUtils.FORMAT_ABBREV_MONTH | |
| DateUtils.FORMAT_ABBREV_WEEKDAY; |
| datetimeString = Utils.formatDateRange(context, startMillis, endMillis, |
| flagsDatetime); |
| } |
| } |
| return datetimeString; |
| } |
| |
| /** |
| * Returns the timezone to display in the event info, if the local timezone is different |
| * from the event timezone. Otherwise returns null. |
| */ |
| public static String getDisplayedTimezone(long startMillis, String localTimezone, |
| String eventTimezone) { |
| String tzDisplay = null; |
| if (!TextUtils.equals(localTimezone, eventTimezone)) { |
| // Figure out if this is in DST |
| TimeZone tz = TimeZone.getTimeZone(localTimezone); |
| if (tz == null || tz.getID().equals("GMT")) { |
| tzDisplay = localTimezone; |
| } else { |
| Time startTime = new Time(localTimezone); |
| startTime.set(startMillis); |
| tzDisplay = tz.getDisplayName(startTime.isDst != 0, TimeZone.SHORT); |
| } |
| } |
| return tzDisplay; |
| } |
| |
| /** |
| * Returns whether the specified time interval is in a single day. |
| */ |
| private static boolean singleDayEvent(long startMillis, long endMillis, long localGmtOffset) { |
| if (startMillis == endMillis) { |
| return true; |
| } |
| |
| // An event ending at midnight should still be a single-day event, so check |
| // time end-1. |
| int startDay = Time.getJulianDay(startMillis, localGmtOffset); |
| int endDay = Time.getJulianDay(endMillis - 1, localGmtOffset); |
| return startDay == endDay; |
| } |
| |
| // Using int constants as a return value instead of an enum to minimize resources. |
| private static final int TODAY = 1; |
| private static final int TOMORROW = 2; |
| private static final int NONE = 0; |
| |
| /** |
| * Returns TODAY or TOMORROW if applicable. Otherwise returns NONE. |
| */ |
| private static int isTodayOrTomorrow(Resources r, long dayMillis, |
| long currentMillis, long localGmtOffset) { |
| int startDay = Time.getJulianDay(dayMillis, localGmtOffset); |
| int currentDay = Time.getJulianDay(currentMillis, localGmtOffset); |
| |
| int days = startDay - currentDay; |
| if (days == 1) { |
| return TOMORROW; |
| } else if (days == 0) { |
| return TODAY; |
| } else { |
| return NONE; |
| } |
| } |
| |
| /** |
| * Create an intent for emailing attendees of an event. |
| * |
| * @param resources The resources for translating strings. |
| * @param eventTitle The title of the event to use as the email subject. |
| * @param body The default text for the email body. |
| * @param toEmails The list of emails for the 'to' line. |
| * @param ccEmails The list of emails for the 'cc' line. |
| * @param ownerAccount The owner account to use as the email sender. |
| */ |
| public static Intent createEmailAttendeesIntent(Resources resources, String eventTitle, |
| String body, List<String> toEmails, List<String> ccEmails, String ownerAccount) { |
| List<String> toList = toEmails; |
| List<String> ccList = ccEmails; |
| if (toEmails.size() <= 0) { |
| if (ccEmails.size() <= 0) { |
| // TODO: Return a SEND intent if no one to email to, to at least populate |
| // a draft email with the subject (and no recipients). |
| throw new IllegalArgumentException("Both toEmails and ccEmails are empty."); |
| } |
| |
| // Email app does not work with no "to" recipient. Move all 'cc' to 'to' |
| // in this case. |
| toList = ccEmails; |
| ccList = null; |
| } |
| |
| // Use the event title as the email subject (prepended with 'Re: '). |
| String subject = null; |
| if (eventTitle != null) { |
| subject = resources.getString(R.string.email_subject_prefix) + eventTitle; |
| } |
| |
| // Use the SENDTO intent with a 'mailto' URI, because using SEND will cause |
| // the picker to show apps like text messaging, which does not make sense |
| // for email addresses. We put all data in the URI instead of using the extra |
| // Intent fields (ie. EXTRA_CC, etc) because some email apps might not handle |
| // those (though gmail does). |
| Uri.Builder uriBuilder = new Uri.Builder(); |
| uriBuilder.scheme("mailto"); |
| |
| // We will append the first email to the 'mailto' field later (because the |
| // current state of the Email app requires it). Add the remaining 'to' values |
| // here. When the email codebase is updated, we can simplify this. |
| if (toList.size() > 1) { |
| for (int i = 1; i < toList.size(); i++) { |
| // The Email app requires repeated parameter settings instead of |
| // a single comma-separated list. |
| uriBuilder.appendQueryParameter("to", toList.get(i)); |
| } |
| } |
| |
| // Add the subject parameter. |
| if (subject != null) { |
| uriBuilder.appendQueryParameter("subject", subject); |
| } |
| |
| // Add the subject parameter. |
| if (body != null) { |
| uriBuilder.appendQueryParameter("body", body); |
| } |
| |
| // Add the cc parameters. |
| if (ccList != null && ccList.size() > 0) { |
| for (String email : ccList) { |
| uriBuilder.appendQueryParameter("cc", email); |
| } |
| } |
| |
| // Insert the first email after 'mailto:' in the URI manually since Uri.Builder |
| // doesn't seem to have a way to do this. |
| String uri = uriBuilder.toString(); |
| if (uri.startsWith("mailto:")) { |
| StringBuilder builder = new StringBuilder(uri); |
| builder.insert(7, Uri.encode(toList.get(0))); |
| uri = builder.toString(); |
| } |
| |
| // Start the email intent. Email from the account of the calendar owner in case there |
| // are multiple email accounts. |
| Intent emailIntent = new Intent(android.content.Intent.ACTION_SENDTO, Uri.parse(uri)); |
| emailIntent.putExtra("fromAccountString", ownerAccount); |
| |
| // Workaround a Email bug that overwrites the body with this intent extra. If not |
| // set, it clears the body. |
| if (body != null) { |
| emailIntent.putExtra(Intent.EXTRA_TEXT, body); |
| } |
| |
| return Intent.createChooser(emailIntent, resources.getString(R.string.email_picker_label)); |
| } |
| |
| /** |
| * Example fake email addresses used as attendee emails are resources like conference rooms, |
| * or another calendar, etc. These all end in "calendar.google.com". |
| */ |
| public static boolean isValidEmail(String email) { |
| return email != null && !email.endsWith(MACHINE_GENERATED_ADDRESS); |
| } |
| |
| /** |
| * Returns true if: |
| * (1) the email is not a resource like a conference room or another calendar. |
| * Catch most of these by filtering out suffix calendar.google.com. |
| * (2) the email is not equal to the sync account to prevent mailing himself. |
| */ |
| public static boolean isEmailableFrom(String email, String syncAccountName) { |
| return Utils.isValidEmail(email) && !email.equals(syncAccountName); |
| } |
| |
| /** |
| * Inserts a drawable with today's day into the today's icon in the option menu |
| * @param icon - today's icon from the options menu |
| */ |
| public static void setTodayIcon(LayerDrawable icon, Context c, String timezone) { |
| DayOfMonthDrawable today; |
| |
| // Reuse current drawable if possible |
| Drawable currentDrawable = icon.findDrawableByLayerId(R.id.today_icon_day); |
| if (currentDrawable != null && currentDrawable instanceof DayOfMonthDrawable) { |
| today = (DayOfMonthDrawable)currentDrawable; |
| } else { |
| today = new DayOfMonthDrawable(c); |
| } |
| // Set the day and update the icon |
| Time now = new Time(timezone); |
| now.setToNow(); |
| now.normalize(false); |
| today.setDayOfMonth(now.monthDay); |
| icon.mutate(); |
| icon.setDrawableByLayerId(R.id.today_icon_day, today); |
| } |
| |
| private static class CalendarBroadcastReceiver extends BroadcastReceiver { |
| |
| Runnable mCallBack; |
| |
| public CalendarBroadcastReceiver(Runnable callback) { |
| super(); |
| mCallBack = callback; |
| } |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| if (intent.getAction().equals(Intent.ACTION_DATE_CHANGED) || |
| intent.getAction().equals(Intent.ACTION_TIME_CHANGED) || |
| intent.getAction().equals(Intent.ACTION_LOCALE_CHANGED) || |
| intent.getAction().equals(Intent.ACTION_TIMEZONE_CHANGED)) { |
| if (mCallBack != null) { |
| mCallBack.run(); |
| } |
| } |
| } |
| } |
| |
| public static BroadcastReceiver setTimeChangesReceiver(Context c, Runnable callback) { |
| IntentFilter filter = new IntentFilter(); |
| filter.addAction(Intent.ACTION_TIME_CHANGED); |
| filter.addAction(Intent.ACTION_DATE_CHANGED); |
| filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); |
| filter.addAction(Intent.ACTION_LOCALE_CHANGED); |
| |
| CalendarBroadcastReceiver r = new CalendarBroadcastReceiver(callback); |
| c.registerReceiver(r, filter); |
| return r; |
| } |
| |
| public static void clearTimeChangesReceiver(Context c, BroadcastReceiver r) { |
| c.unregisterReceiver(r); |
| } |
| |
| /** |
| * Get a list of quick responses used for emailing guests from the |
| * SharedPreferences. If not are found, get the hard coded ones that shipped |
| * with the app |
| * |
| * @param context |
| * @return a list of quick responses. |
| */ |
| public static String[] getQuickResponses(Context context) { |
| String[] s = Utils.getSharedPreference(context, KEY_QUICK_RESPONSES, (String[]) null); |
| |
| if (s == null) { |
| s = context.getResources().getStringArray(R.array.quick_response_defaults); |
| } |
| |
| return s; |
| } |
| |
| /** |
| * Return the app version code. |
| */ |
| public static String getVersionCode(Context context) { |
| if (sVersion == null) { |
| try { |
| sVersion = context.getPackageManager().getPackageInfo( |
| context.getPackageName(), 0).versionName; |
| } catch (PackageManager.NameNotFoundException e) { |
| // Can't find version; just leave it blank. |
| Log.e(TAG, "Error finding package " + context.getApplicationInfo().packageName); |
| } |
| } |
| return sVersion; |
| } |
| |
| /** |
| * Checks the server for an updated list of Calendars (in the background). |
| * |
| * If a Calendar is added on the web (and it is selected and not |
| * hidden) then it will be added to the list of calendars on the phone |
| * (when this finishes). When a new calendar from the |
| * web is added to the phone, then the events for that calendar are also |
| * downloaded from the web. |
| * |
| * This sync is done automatically in the background when the |
| * SelectCalendars activity and fragment are started. |
| * |
| * @param account - The account to sync. May be null to sync all accounts. |
| */ |
| public static void startCalendarMetafeedSync(Account account) { |
| Bundle extras = new Bundle(); |
| extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); |
| extras.putBoolean("metafeedonly", true); |
| ContentResolver.requestSync(account, Calendars.CONTENT_URI.getAuthority(), extras); |
| } |
| |
| /** |
| * Replaces stretches of text that look like addresses and phone numbers with clickable |
| * links. If lastDitchGeo is true, then if no links are found in the textview, the entire |
| * string will be converted to a single geo link. Any spans that may have previously been |
| * in the text will be cleared out. |
| * <p> |
| * This is really just an enhanced version of Linkify.addLinks(). |
| * |
| * @param text - The string to search for links. |
| * @param lastDitchGeo - If no links are found, turn the entire string into one geo link. |
| * @return Spannable object containing the list of URL spans found. |
| */ |
| public static Spannable extendedLinkify(String text, boolean lastDitchGeo) { |
| // We use a copy of the string argument so it's available for later if necessary. |
| Spannable spanText = SpannableString.valueOf(text); |
| |
| /* |
| * If the text includes a street address like "1600 Amphitheater Parkway, 94043", |
| * the current Linkify code will identify "94043" as a phone number and invite |
| * you to dial it (and not provide a map link for the address). For outside US, |
| * use Linkify result iff it spans the entire text. Otherwise send the user to maps. |
| */ |
| String defaultPhoneRegion = System.getProperty("user.region", "US"); |
| if (!defaultPhoneRegion.equals("US")) { |
| Linkify.addLinks(spanText, Linkify.ALL); |
| |
| // If Linkify links the entire text, use that result. |
| URLSpan[] spans = spanText.getSpans(0, spanText.length(), URLSpan.class); |
| if (spans.length == 1) { |
| int linkStart = spanText.getSpanStart(spans[0]); |
| int linkEnd = spanText.getSpanEnd(spans[0]); |
| if (linkStart <= indexFirstNonWhitespaceChar(spanText) && |
| linkEnd >= indexLastNonWhitespaceChar(spanText) + 1) { |
| return spanText; |
| } |
| } |
| |
| // Otherwise, to be cautious and to try to prevent false positives, reset the spannable. |
| spanText = SpannableString.valueOf(text); |
| // If lastDitchGeo is true, default the entire string to geo. |
| if (lastDitchGeo && !text.isEmpty()) { |
| Linkify.addLinks(spanText, mWildcardPattern, "geo:0,0?q="); |
| } |
| return spanText; |
| } |
| |
| /* |
| * For within US, we want to have better recognition of phone numbers without losing |
| * any of the existing annotations. Ideally this would be addressed by improving Linkify. |
| * For now we manage it as a second pass over the text. |
| * |
| * URIs and e-mail addresses are pretty easy to pick out of text. Phone numbers |
| * are a bit tricky because they have radically different formats in different |
| * countries, in terms of both the digits and the way in which they are commonly |
| * written or presented (e.g. the punctuation and spaces in "(650) 555-1212"). |
| * The expected format of a street address is defined in WebView.findAddress(). It's |
| * pretty narrowly defined, so it won't often match. |
| * |
| * The RFC 3966 specification defines the format of a "tel:" URI. |
| * |
| * Start by letting Linkify find anything that isn't a phone number. We have to let it |
| * run first because every invocation removes all previous URLSpan annotations. |
| * |
| * Ideally we'd use the external/libphonenumber routines, but those aren't available |
| * to unbundled applications. |
| */ |
| boolean linkifyFoundLinks = Linkify.addLinks(spanText, |
| Linkify.ALL & ~(Linkify.PHONE_NUMBERS)); |
| |
| /* |
| * Get a list of any spans created by Linkify, for the coordinate overlapping span check. |
| */ |
| URLSpan[] existingSpans = spanText.getSpans(0, spanText.length(), URLSpan.class); |
| |
| /* |
| * Check for coordinates. |
| * This must be done before phone numbers because longitude may look like a phone number. |
| */ |
| Matcher coordMatcher = COORD_PATTERN.matcher(spanText); |
| int coordCount = 0; |
| while (coordMatcher.find()) { |
| int start = coordMatcher.start(); |
| int end = coordMatcher.end(); |
| if (spanWillOverlap(spanText, existingSpans, start, end)) { |
| continue; |
| } |
| |
| URLSpan span = new URLSpan("geo:0,0?q=" + coordMatcher.group()); |
| spanText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); |
| coordCount++; |
| } |
| |
| /* |
| * Update the list of existing spans, for the phone number overlapping span check. |
| */ |
| existingSpans = spanText.getSpans(0, spanText.length(), URLSpan.class); |
| |
| /* |
| * Search for phone numbers. |
| * |
| * Some URIs contain strings of digits that look like phone numbers. If both the URI |
| * scanner and the phone number scanner find them, we want the URI link to win. Since |
| * the URI scanner runs first, we just need to avoid creating overlapping spans. |
| */ |
| int[] phoneSequences = findNanpPhoneNumbers(text); |
| |
| /* |
| * Insert spans for the numbers we found. We generate "tel:" URIs. |
| */ |
| int phoneCount = 0; |
| for (int match = 0; match < phoneSequences.length / 2; match++) { |
| int start = phoneSequences[match*2]; |
| int end = phoneSequences[match*2 + 1]; |
| |
| if (spanWillOverlap(spanText, existingSpans, start, end)) { |
| continue; |
| } |
| |
| /* |
| * The Linkify code takes the matching span and strips out everything that isn't a |
| * digit or '+' sign. We do the same here. Extension numbers will get appended |
| * without a separator, but the dialer wasn't doing anything useful with ";ext=" |
| * anyway. |
| */ |
| |
| //String dialStr = phoneUtil.format(match.number(), |
| // PhoneNumberUtil.PhoneNumberFormat.RFC3966); |
| StringBuilder dialBuilder = new StringBuilder(); |
| for (int i = start; i < end; i++) { |
| char ch = spanText.charAt(i); |
| if (ch == '+' || Character.isDigit(ch)) { |
| dialBuilder.append(ch); |
| } |
| } |
| URLSpan span = new URLSpan("tel:" + dialBuilder.toString()); |
| |
| spanText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); |
| phoneCount++; |
| } |
| |
| /* |
| * If lastDitchGeo, and no other links have been found, set the entire string as a geo link. |
| */ |
| if (lastDitchGeo && !text.isEmpty() && |
| !linkifyFoundLinks && phoneCount == 0 && coordCount == 0) { |
| if (Log.isLoggable(TAG, Log.VERBOSE)) { |
| Log.v(TAG, "No linkification matches, using geo default"); |
| } |
| Linkify.addLinks(spanText, mWildcardPattern, "geo:0,0?q="); |
| } |
| |
| return spanText; |
| } |
| |
| private static int indexFirstNonWhitespaceChar(CharSequence str) { |
| for (int i = 0; i < str.length(); i++) { |
| if (!Character.isWhitespace(str.charAt(i))) { |
| return i; |
| } |
| } |
| return -1; |
| } |
| |
| private static int indexLastNonWhitespaceChar(CharSequence str) { |
| for (int i = str.length() - 1; i >= 0; i--) { |
| if (!Character.isWhitespace(str.charAt(i))) { |
| return i; |
| } |
| } |
| return -1; |
| } |
| |
| /** |
| * Finds North American Numbering Plan (NANP) phone numbers in the input text. |
| * |
| * @param text The text to scan. |
| * @return A list of [start, end) pairs indicating the positions of phone numbers in the input. |
| */ |
| // @VisibleForTesting |
| static int[] findNanpPhoneNumbers(CharSequence text) { |
| ArrayList<Integer> list = new ArrayList<Integer>(); |
| |
| int startPos = 0; |
| int endPos = text.length() - NANP_MIN_DIGITS + 1; |
| if (endPos < 0) { |
| return new int[] {}; |
| } |
| |
| /* |
| * We can't just strip the whitespace out and crunch it down, because the whitespace |
| * is significant. March through, trying to figure out where numbers start and end. |
| */ |
| while (startPos < endPos) { |
| // skip whitespace |
| while (Character.isWhitespace(text.charAt(startPos)) && startPos < endPos) { |
| startPos++; |
| } |
| if (startPos == endPos) { |
| break; |
| } |
| |
| // check for a match at this position |
| int matchEnd = findNanpMatchEnd(text, startPos); |
| if (matchEnd > startPos) { |
| list.add(startPos); |
| list.add(matchEnd); |
| startPos = matchEnd; // skip past match |
| } else { |
| // skip to next whitespace char |
| while (!Character.isWhitespace(text.charAt(startPos)) && startPos < endPos) { |
| startPos++; |
| } |
| } |
| } |
| |
| int[] result = new int[list.size()]; |
| for (int i = list.size() - 1; i >= 0; i--) { |
| result[i] = list.get(i); |
| } |
| return result; |
| } |
| |
| /** |
| * Checks to see if there is a valid phone number in the input, starting at the specified |
| * offset. If so, the index of the last character + 1 is returned. The input is assumed |
| * to begin with a non-whitespace character. |
| * |
| * @return Exclusive end position, or -1 if not a match. |
| */ |
| private static int findNanpMatchEnd(CharSequence text, int startPos) { |
| /* |
| * A few interesting cases: |
| * 94043 # too short, ignore |
| * 123456789012 # too long, ignore |
| * +1 (650) 555-1212 # 11 digits, spaces |
| * (650) 555 5555 # Second space, only when first is present. |
| * (650) 555-1212, (650) 555-1213 # two numbers, return first |
| * 1-650-555-1212 # 11 digits with leading '1' |
| * *#650.555.1212#*! # 10 digits, include #*, ignore trailing '!' |
| * 555.1212 # 7 digits |
| * |
| * For the most part we want to break on whitespace, but it's common to leave a space |
| * between the initial '1' and/or after the area code. |
| */ |
| |
| // Check for "tel:" URI prefix. |
| if (text.length() > startPos+4 |
| && text.subSequence(startPos, startPos+4).toString().equalsIgnoreCase("tel:")) { |
| startPos += 4; |
| } |
| |
| int endPos = text.length(); |
| int curPos = startPos; |
| int foundDigits = 0; |
| char firstDigit = 'x'; |
| boolean foundWhiteSpaceAfterAreaCode = false; |
| |
| while (curPos <= endPos) { |
| char ch; |
| if (curPos < endPos) { |
| ch = text.charAt(curPos); |
| } else { |
| ch = 27; // fake invalid symbol at end to trigger loop break |
| } |
| |
| if (Character.isDigit(ch)) { |
| if (foundDigits == 0) { |
| firstDigit = ch; |
| } |
| foundDigits++; |
| if (foundDigits > NANP_MAX_DIGITS) { |
| // too many digits, stop early |
| return -1; |
| } |
| } else if (Character.isWhitespace(ch)) { |
| if ( (firstDigit == '1' && foundDigits == 4) || |
| (foundDigits == 3)) { |
| foundWhiteSpaceAfterAreaCode = true; |
| } else if (firstDigit == '1' && foundDigits == 1) { |
| } else if (foundWhiteSpaceAfterAreaCode |
| && ( (firstDigit == '1' && (foundDigits == 7)) || (foundDigits == 6))) { |
| } else { |
| break; |
| } |
| } else if (NANP_ALLOWED_SYMBOLS.indexOf(ch) == -1) { |
| break; |
| } |
| // else it's an allowed symbol |
| |
| curPos++; |
| } |
| |
| if ((firstDigit != '1' && (foundDigits == 7 || foundDigits == 10)) || |
| (firstDigit == '1' && foundDigits == 11)) { |
| // match |
| return curPos; |
| } |
| |
| return -1; |
| } |
| |
| /** |
| * Determines whether a new span at [start,end) will overlap with any existing span. |
| */ |
| private static boolean spanWillOverlap(Spannable spanText, URLSpan[] spanList, int start, |
| int end) { |
| if (start == end) { |
| // empty span, ignore |
| return false; |
| } |
| for (URLSpan span : spanList) { |
| int existingStart = spanText.getSpanStart(span); |
| int existingEnd = spanText.getSpanEnd(span); |
| if ((start >= existingStart && start < existingEnd) || |
| end > existingStart && end <= existingEnd) { |
| if (Log.isLoggable(TAG, Log.VERBOSE)) { |
| CharSequence seq = spanText.subSequence(start, end); |
| Log.v(TAG, "Not linkifying " + seq + " as phone number due to overlap"); |
| } |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| /** |
| * @param bundle The incoming bundle that contains the reminder info. |
| * @return ArrayList<ReminderEntry> of the reminder minutes and methods. |
| */ |
| public static ArrayList<ReminderEntry> readRemindersFromBundle(Bundle bundle) { |
| ArrayList<ReminderEntry> reminders = null; |
| |
| ArrayList<Integer> reminderMinutes = bundle.getIntegerArrayList( |
| EventInfoFragment.BUNDLE_KEY_REMINDER_MINUTES); |
| ArrayList<Integer> reminderMethods = bundle.getIntegerArrayList( |
| EventInfoFragment.BUNDLE_KEY_REMINDER_METHODS); |
| if (reminderMinutes == null || reminderMethods == null) { |
| if (reminderMinutes != null || reminderMethods != null) { |
| String nullList = (reminderMinutes == null? |
| "reminderMinutes" : "reminderMethods"); |
| Log.d(TAG, String.format("Error resolving reminders: %s was null", |
| nullList)); |
| } |
| return null; |
| } |
| |
| int numReminders = reminderMinutes.size(); |
| if (numReminders == reminderMethods.size()) { |
| // Only if the size of the reminder minutes we've read in is |
| // the same as the size of the reminder methods. Otherwise, |
| // something went wrong with bundling them. |
| reminders = new ArrayList<ReminderEntry>(numReminders); |
| for (int reminder_i = 0; reminder_i < numReminders; |
| reminder_i++) { |
| int minutes = reminderMinutes.get(reminder_i); |
| int method = reminderMethods.get(reminder_i); |
| reminders.add(ReminderEntry.valueOf(minutes, method)); |
| } |
| } else { |
| Log.d(TAG, String.format("Error resolving reminders." + |
| " Found %d reminderMinutes, but %d reminderMethods.", |
| numReminders, reminderMethods.size())); |
| } |
| |
| return reminders; |
| } |
| |
| } |