diff options
author | Sam Blitzstein <sblitz@google.com> | 2012-11-13 10:02:41 -0800 |
---|---|---|
committer | Sam Blitzstein <sblitz@google.com> | 2012-11-15 14:30:10 -0800 |
commit | 7e19bf984bc280c0cc034adf1dfa8840c75a698d (patch) | |
tree | c3dc355674a9bc86629961f2025ac0bd5a416aee | |
parent | ad83b9aa618d52952f98bf8d7ec876140e7fb404 (diff) | |
download | android_packages_apps_Calendar-7e19bf984bc280c0cc034adf1dfa8840c75a698d.tar.gz android_packages_apps_Calendar-7e19bf984bc280c0cc034adf1dfa8840c75a698d.tar.bz2 android_packages_apps_Calendar-7e19bf984bc280c0cc034adf1dfa8840c75a698d.zip |
Added new notification actions for map and call.
Bug: 7525552
Change-Id: I6a175a270f4049897ee73c005f4f73420fc635d9
-rw-r--r-- | res/drawable-hdpi/ic_call.png | bin | 0 -> 1998 bytes | |||
-rw-r--r-- | res/drawable-hdpi/ic_map.png | bin | 0 -> 1191 bytes | |||
-rw-r--r-- | res/drawable-mdpi/ic_call.png | bin | 0 -> 1470 bytes | |||
-rw-r--r-- | res/drawable-mdpi/ic_map.png | bin | 0 -> 893 bytes | |||
-rw-r--r-- | res/drawable-xhdpi/ic_call.png | bin | 0 -> 2929 bytes | |||
-rw-r--r-- | res/drawable-xhdpi/ic_map.png | bin | 0 -> 1584 bytes | |||
-rw-r--r-- | res/layout/notification.xml | 18 | ||||
-rw-r--r-- | res/values/strings.xml | 5 | ||||
-rw-r--r-- | src/com/android/calendar/EventInfoFragment.java | 328 | ||||
-rw-r--r-- | src/com/android/calendar/Utils.java | 337 | ||||
-rw-r--r-- | src/com/android/calendar/alerts/AlertReceiver.java | 282 | ||||
-rw-r--r-- | tests/src/com/android/calendar/UtilsTests.java | 2 |
12 files changed, 622 insertions, 350 deletions
diff --git a/res/drawable-hdpi/ic_call.png b/res/drawable-hdpi/ic_call.png Binary files differnew file mode 100644 index 00000000..4a3a05f3 --- /dev/null +++ b/res/drawable-hdpi/ic_call.png diff --git a/res/drawable-hdpi/ic_map.png b/res/drawable-hdpi/ic_map.png Binary files differnew file mode 100644 index 00000000..2daebc56 --- /dev/null +++ b/res/drawable-hdpi/ic_map.png diff --git a/res/drawable-mdpi/ic_call.png b/res/drawable-mdpi/ic_call.png Binary files differnew file mode 100644 index 00000000..a10fd770 --- /dev/null +++ b/res/drawable-mdpi/ic_call.png diff --git a/res/drawable-mdpi/ic_map.png b/res/drawable-mdpi/ic_map.png Binary files differnew file mode 100644 index 00000000..9f52789c --- /dev/null +++ b/res/drawable-mdpi/ic_map.png diff --git a/res/drawable-xhdpi/ic_call.png b/res/drawable-xhdpi/ic_call.png Binary files differnew file mode 100644 index 00000000..564e1892 --- /dev/null +++ b/res/drawable-xhdpi/ic_call.png diff --git a/res/drawable-xhdpi/ic_map.png b/res/drawable-xhdpi/ic_map.png Binary files differnew file mode 100644 index 00000000..d4c57200 --- /dev/null +++ b/res/drawable-xhdpi/ic_map.png diff --git a/res/layout/notification.xml b/res/layout/notification.xml index 4a723c75..b56d5c68 100644 --- a/res/layout/notification.xml +++ b/res/layout/notification.xml @@ -56,6 +56,24 @@ </LinearLayout> <ImageButton + android:id="@+id/map_button" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|right" + android:src="@drawable/ic_map" + android:background="?android:attr/selectableItemBackground" + android:padding="8dip" + android:contentDescription="@string/map_label" /> + <ImageButton + android:id="@+id/call_button" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|right" + android:src="@drawable/ic_call" + android:background="?android:attr/selectableItemBackground" + android:padding="8dip" + android:contentDescription="@string/call_label" /> + <ImageButton android:id="@+id/email_button" android:layout_height="wrap_content" android:layout_width="wrap_content" diff --git a/res/values/strings.xml b/res/values/strings.xml index 5a6472f4..9c577802 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -294,6 +294,11 @@ <!-- Toast message displayed when the event id was not found LIMIT=30] --> <string name="event_not_found">"Event not found."</string> + <!-- Notification label for getting a map of the location. --> + <string name="map_label">Map</string> + <!-- Notification label for calling a phone number in the location. --> + <string name="call_label">Call</string> + <!-- Title of the quick response item in Settings [CHAR LIMIT=18]--> <string name="quick_response_settings">Quick responses</string> <!-- Summary of the quick response item in Settings [CHAR LIMIT=80]--> diff --git a/src/com/android/calendar/EventInfoFragment.java b/src/com/android/calendar/EventInfoFragment.java index 88e58355..d3caae0b 100644 --- a/src/com/android/calendar/EventInfoFragment.java +++ b/src/com/android/calendar/EventInfoFragment.java @@ -254,10 +254,6 @@ public class EventInfoFragment extends DialogFragment implements OnCheckedChange static final String CALENDARS_DUPLICATE_NAME_WHERE = Calendars.CALENDAR_DISPLAY_NAME + "=?"; static final String CALENDARS_VISIBLE_WHERE = Calendars.VISIBLE + "=?"; - private static final String NANP_ALLOWED_SYMBOLS = "()+-*#."; - private static final int NANP_MIN_DIGITS = 7; - private static final int NANP_MAX_DIGITS = 11; - private View mView; @@ -320,8 +316,6 @@ public class EventInfoFragment extends DialogFragment implements OnCheckedChange private boolean mNoCrossFade = false; // Used to prevent repeated cross-fade - private static final Pattern mWildcardPattern = Pattern.compile("^.*$"); - ArrayList<Attendee> mAcceptedAttendees = new ArrayList<Attendee>(); ArrayList<Attendee> mDeclinedAttendees = new ArrayList<Attendee>(); ArrayList<Attendee> mTentativeAttendees = new ArrayList<Attendee>(); @@ -1233,7 +1227,7 @@ public class EventInfoFragment extends DialogFragment implements OnCheckedChange textView.setAutoLinkMask(0); textView.setText(location.trim()); try { - linkifyTextView(textView); + Utils.linkifyTextView(textView, true); } catch (Exception ex) { // unexpected Log.e(TAG, "Linkification failed", ex); @@ -1337,326 +1331,6 @@ public class EventInfoFragment extends DialogFragment implements OnCheckedChange return; } - /** - * 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; - } - - 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; - } - - /** - * Replaces stretches of text that look like addresses and phone numbers with clickable - * links. - * <p> - * This is really just an enhanced version of Linkify.addLinks(). - */ - private static void linkifyTextView(TextView textView) { - /* - * 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")) { - CharSequence origText = textView.getText(); - Linkify.addLinks(textView, Linkify.ALL); - - // If Linkify links the entire text, use that result. - if (textView.getText() instanceof Spannable) { - Spannable spanText = (Spannable) textView.getText(); - 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(origText) && - linkEnd >= indexLastNonWhitespaceChar(origText) + 1) { - return; - } - } - } - - // Otherwise default to geo. - textView.setText(origText); - Linkify.addLinks(textView, mWildcardPattern, "geo:0,0?q="); - return; - } - - /* - * 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(textView, - Linkify.ALL & ~(Linkify.PHONE_NUMBERS)); - - /* - * 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. - */ - CharSequence text = textView.getText(); - int[] phoneSequences = findNanpPhoneNumbers(text); - - /* - * If the contents of the TextView are already Spannable (which will be the case if - * Linkify found stuff, but might not be otherwise), we can just add annotations - * to what's there. If it's not, and we find phone numbers, we need to convert it to - * a Spannable form. (This mimics the behavior of Linkable.addLinks().) - */ - Spannable spanText; - if (text instanceof SpannableString) { - spanText = (SpannableString) text; - } else { - spanText = SpannableString.valueOf(text); - } - - /* - * Get a list of any spans created by Linkify, for the overlapping span check. - */ - URLSpan[] existingSpans = spanText.getSpans(0, spanText.length(), URLSpan.class); - - /* - * 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)) { - if (Log.isLoggable(TAG, Log.VERBOSE)) { - CharSequence seq = text.subSequence(start, end); - Log.v(TAG, "Not linkifying " + seq + " as phone number due to overlap"); - } - 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 (phoneCount != 0) { - // If we had to "upgrade" to Spannable, store the object into the TextView. - if (spanText != text) { - textView.setText(spanText); - } - - // Linkify.addLinks() sets the TextView movement method if it finds any links. We - // want to do the same here. (This is cloned from Linkify.addLinkMovementMethod().) - MovementMethod mm = textView.getMovementMethod(); - - if ((mm == null) || !(mm instanceof LinkMovementMethod)) { - if (textView.getLinksClickable()) { - textView.setMovementMethod(LinkMovementMethod.getInstance()); - } - } - } - - if (!linkifyFoundLinks && phoneCount == 0) { - if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v(TAG, "No linkification matches, using geo default"); - } - Linkify.addLinks(textView, mWildcardPattern, "geo:0,0?q="); - } - } - - /** - * 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) { - return true; - } - } - - return false; - } - private void sendAccessibilityEvent() { AccessibilityManager am = (AccessibilityManager) getActivity().getSystemService(Service.ACCESSIBILITY_SERVICE); diff --git a/src/com/android/calendar/Utils.java b/src/com/android/calendar/Utils.java index a3198cd2..a3c6abe6 100644 --- a/src/com/android/calendar/Utils.java +++ b/src/com/android/calendar/Utils.java @@ -39,12 +39,20 @@ 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.method.LinkMovementMethod; +import android.text.method.MovementMethod; +import android.text.style.URLSpan; +import android.text.util.Linkify; import android.util.Log; import android.widget.SearchView; +import android.widget.TextView; import com.android.calendar.CalendarController.ViewType; import com.android.calendar.CalendarUtils.TimeZoneUtils; @@ -62,6 +70,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TimeZone; +import java.util.regex.Pattern; public class Utils { private static final boolean DEBUG = false; @@ -128,6 +137,12 @@ public class Utils { private static long mTardis = 0; private static String sVersion = null; + private static final Pattern mWildcardPattern = Pattern.compile("^.*$"); + 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. */ @@ -1550,4 +1565,326 @@ public class Utils { 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. + * <p> + * This is really just an enhanced version of Linkify.addLinks(). + */ + public static void linkifyTextView(TextView textView, boolean lastDitchGeo) { + /* + * 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")) { + CharSequence origText = textView.getText(); + Linkify.addLinks(textView, Linkify.ALL); + + // If Linkify links the entire text, use that result. + if (textView.getText() instanceof Spannable) { + Spannable spanText = (Spannable) textView.getText(); + 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(origText) && + linkEnd >= indexLastNonWhitespaceChar(origText) + 1) { + return; + } + } + } + + // Otherwise default to geo. + textView.setText(origText); + Linkify.addLinks(textView, mWildcardPattern, "geo:0,0?q="); + return; + } + + /* + * 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(textView, + Linkify.ALL & ~(Linkify.PHONE_NUMBERS)); + + /* + * 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. + */ + CharSequence text = textView.getText(); + int[] phoneSequences = findNanpPhoneNumbers(text); + + /* + * If the contents of the TextView are already Spannable (which will be the case if + * Linkify found stuff, but might not be otherwise), we can just add annotations + * to what's there. If it's not, and we find phone numbers, we need to convert it to + * a Spannable form. (This mimics the behavior of Linkable.addLinks().) + */ + Spannable spanText; + if (text instanceof SpannableString) { + spanText = (SpannableString) text; + } else { + spanText = SpannableString.valueOf(text); + } + + /* + * Get a list of any spans created by Linkify, for the overlapping span check. + */ + URLSpan[] existingSpans = spanText.getSpans(0, spanText.length(), URLSpan.class); + + /* + * 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)) { + if (Log.isLoggable(TAG, Log.VERBOSE)) { + CharSequence seq = text.subSequence(start, end); + Log.v(TAG, "Not linkifying " + seq + " as phone number due to overlap"); + } + 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 (phoneCount != 0) { + // If we had to "upgrade" to Spannable, store the object into the TextView. + if (spanText != text) { + textView.setText(spanText); + } + + // Linkify.addLinks() sets the TextView movement method if it finds any links. We + // want to do the same here. (This is cloned from Linkify.addLinkMovementMethod().) + MovementMethod mm = textView.getMovementMethod(); + + if ((mm == null) || !(mm instanceof LinkMovementMethod)) { + if (textView.getLinksClickable()) { + textView.setMovementMethod(LinkMovementMethod.getInstance()); + } + } + } + + if (lastDitchGeo && !linkifyFoundLinks && phoneCount == 0) { + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "No linkification matches, using geo default"); + } + Linkify.addLinks(textView, mWildcardPattern, "geo:0,0?q="); + } + } + + 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) { + return true; + } + } + + return false; + } + } diff --git a/src/com/android/calendar/alerts/AlertReceiver.java b/src/com/android/calendar/alerts/AlertReceiver.java index a0a82d54..f3a45978 100644 --- a/src/com/android/calendar/alerts/AlertReceiver.java +++ b/src/com/android/calendar/alerts/AlertReceiver.java @@ -23,6 +23,7 @@ import android.content.BroadcastReceiver; import android.content.ContentUris; import android.content.Context; import android.content.Intent; +import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.database.Cursor; import android.net.Uri; @@ -32,13 +33,18 @@ import android.os.PowerManager; import android.provider.CalendarContract.Attendees; import android.provider.CalendarContract.Calendars; import android.provider.CalendarContract.Events; +import android.telephony.TelephonyManager; +import android.text.Spannable; +import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.RelativeSizeSpan; import android.text.style.TextAppearanceSpan; +import android.text.style.URLSpan; import android.util.Log; import android.view.View; import android.widget.RemoteViews; +import android.widget.TextView; import com.android.calendar.R; import com.android.calendar.Utils; @@ -66,6 +72,8 @@ public class AlertReceiver extends BroadcastReceiver { private static final String TAG = "AlertReceiver"; private static final String DELETE_ALL_ACTION = "com.android.calendar.DELETEALL"; + private static final String MAP_ACTION = "com.android.calendar.MAP"; + private static final String CALL_ACTION = "com.android.calendar.CALL"; private static final String MAIL_ACTION = "com.android.calendar.MAIL"; private static final String EXTRA_EVENT_ID = "eventid"; @@ -77,6 +85,10 @@ public class AlertReceiver extends BroadcastReceiver { public static final String ACTION_DISMISS_OLD_REMINDERS = "removeOldReminders"; private static final int NOTIFICATION_DIGEST_MAX_LENGTH = 3; + private static final String GEO_PREFIX = "geo:"; + private static final String TEL_PREFIX = "tel:"; + private static final int MAX_NOTIF_ACTIONS = 3; + private static Handler sAsyncHandler; static { HandlerThread thr = new HandlerThread("AlertReceiver async"); @@ -97,10 +109,46 @@ public class AlertReceiver extends BroadcastReceiver { // TODO Grab a wake lock here? Intent serviceIntent = new Intent(context, DismissAlarmsService.class); context.startService(serviceIntent); + } else if (MAP_ACTION.equals(intent.getAction())) { + // Try starting the map action. + // If no map location is found (something changed since the notification was originally + // fired), update the notifications to express this change. + final long eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1); + if (eventId != -1) { + URLSpan[] urlSpans = getURLSpans(context, eventId); + Intent geoIntent = createMapActivityIntent(context, urlSpans); + if (geoIntent != null) { + // Location was successfully found, so dismiss the shade and start maps. + context.startActivity(geoIntent); + closeNotificationShade(context); + } else { + // No location was found, so update all notifications. + // Our alert service does not currently allow us to specify only one + // specific notification to refresh. + AlertService.updateAlertNotification(context); + } + } + } else if (CALL_ACTION.equals(intent.getAction())) { + // Try starting the call action. + // If no call location is found (something changed since the notification was originally + // fired), update the notifications to express this change. + final long eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1); + if (eventId != -1) { + URLSpan[] urlSpans = getURLSpans(context, eventId); + Intent callIntent = createCallActivityIntent(context, urlSpans); + if (callIntent != null) { + // Call location was successfully found, so dismiss the shade and start dialer. + context.startActivity(callIntent); + closeNotificationShade(context); + } else { + // No call location was found, so update all notifications. + // Our alert service does not currently allow us to specify only one + // specific notification to refresh. + AlertService.updateAlertNotification(context); + } + } } else if (MAIL_ACTION.equals(intent.getAction())) { - // Close the notification shade. - Intent closeNotificationShadeIntent = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); - context.sendBroadcast(closeNotificationShadeIntent); + closeNotificationShade(context); // Now start the email intent. final long eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1); @@ -254,15 +302,24 @@ public class AlertReceiver extends BroadcastReceiver { notificationBuilder.setFullScreenIntent(createAlertActivityIntent(context), true); } - PendingIntent snoozeIntent = null; - PendingIntent emailIntent = null; + PendingIntent mapIntent = null, callIntent = null, snoozeIntent = null, emailIntent = null; if (addActionButtons) { - // Create snooze intent. TODO: change snooze to 10 minutes. - snoozeIntent = createSnoozeIntent(context, eventId, startMillis, endMillis, - notificationId); + // Send map, call, and email intent back to ourself first for a couple reasons: + // 1) Workaround issue where clicking action button in notification does + // not automatically close the notification shade. + // 2) Event information will always be up to date. + + // Create map and/or call intents. + URLSpan[] urlSpans = getURLSpans(context, eventId); + mapIntent = createMapBroadcastIntent(context, urlSpans, eventId); + callIntent = createCallBroadcastIntent(context, urlSpans, eventId); // Create email intent for emailing attendees. emailIntent = createBroadcastMailIntent(context, eventId, title); + + // Create snooze intent. TODO: change snooze to 10 minutes. + snoozeIntent = createSnoozeIntent(context, eventId, startMillis, endMillis, + notificationId); } if (Utils.isJellybeanOrLater()) { @@ -273,14 +330,34 @@ public class AlertReceiver extends BroadcastReceiver { // A higher priority will encourage notification manager to expand it. notificationBuilder.setPriority(priority); - // Add action buttons. - if (snoozeIntent != null) { - notificationBuilder.addAction(R.drawable.ic_alarm_holo_dark, - resources.getString(R.string.snooze_label), snoozeIntent); + // Add action buttons. Show at most three, using the following priority ordering: + // 1. Map + // 2. Call + // 3. Email + // 4. Snooze + // Actions will only be shown if they are applicable; i.e. with no location, map will + // not be shown, and with no recipients, snooze will not be shown. + // TODO: Get icons, get strings. Maybe show preview of actual location/number? + int numActions = 0; + if (mapIntent != null && numActions < MAX_NOTIF_ACTIONS) { + notificationBuilder.addAction(R.drawable.ic_map, + resources.getString(R.string.map_label), mapIntent); + numActions++; } - if (emailIntent != null) { + if (callIntent != null && numActions < MAX_NOTIF_ACTIONS) { + notificationBuilder.addAction(R.drawable.ic_call, + resources.getString(R.string.call_label), callIntent); + numActions++; + } + if (emailIntent != null && numActions < MAX_NOTIF_ACTIONS) { notificationBuilder.addAction(R.drawable.ic_menu_email_holo_dark, resources.getString(R.string.email_guests_label), emailIntent); + numActions++; + } + if (snoozeIntent != null && numActions < MAX_NOTIF_ACTIONS) { + notificationBuilder.addAction(R.drawable.ic_alarm_holo_dark, + resources.getString(R.string.snooze_label), snoozeIntent); + numActions++; } return notificationBuilder.getNotification(); @@ -295,20 +372,41 @@ public class AlertReceiver extends BroadcastReceiver { contentView.setImageViewResource(R.id.image, R.drawable.stat_notify_calendar); contentView.setTextViewText(R.id.title, title); contentView.setTextViewText(R.id.text, summaryText); - if (snoozeIntent == null) { - contentView.setViewVisibility(R.id.email_button, View.GONE); + + int numActions = 0; + if (mapIntent == null || numActions >= MAX_NOTIF_ACTIONS) { + contentView.setViewVisibility(R.id.map_button, View.GONE); } else { - contentView.setViewVisibility(R.id.snooze_button, View.VISIBLE); - contentView.setOnClickPendingIntent(R.id.snooze_button, snoozeIntent); + contentView.setViewVisibility(R.id.map_button, View.VISIBLE); + contentView.setOnClickPendingIntent(R.id.map_button, mapIntent); contentView.setViewVisibility(R.id.end_padding, View.GONE); + numActions++; } - if (emailIntent == null) { + if (callIntent == null || numActions >= MAX_NOTIF_ACTIONS) { + contentView.setViewVisibility(R.id.call_button, View.GONE); + } else { + contentView.setViewVisibility(R.id.call_button, View.VISIBLE); + contentView.setOnClickPendingIntent(R.id.call_button, callIntent); + contentView.setViewVisibility(R.id.end_padding, View.GONE); + numActions++; + } + if (emailIntent == null || numActions >= MAX_NOTIF_ACTIONS) { contentView.setViewVisibility(R.id.email_button, View.GONE); } else { contentView.setViewVisibility(R.id.email_button, View.VISIBLE); contentView.setOnClickPendingIntent(R.id.email_button, emailIntent); contentView.setViewVisibility(R.id.end_padding, View.GONE); + numActions++; } + if (snoozeIntent == null || numActions >= MAX_NOTIF_ACTIONS) { + contentView.setViewVisibility(R.id.snooze_button, View.GONE); + } else { + contentView.setViewVisibility(R.id.snooze_button, View.VISIBLE); + contentView.setOnClickPendingIntent(R.id.snooze_button, snoozeIntent); + contentView.setViewVisibility(R.id.end_padding, View.GONE); + numActions++; + } + n.contentView = contentView; return n; @@ -486,6 +584,11 @@ public class AlertReceiver extends BroadcastReceiver { return nw; } + private void closeNotificationShade(Context context) { + Intent closeNotificationShadeIntent = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); + context.sendBroadcast(closeNotificationShadeIntent); + } + private static final String[] ATTENDEES_PROJECTION = new String[] { Attendees.ATTENDEE_EMAIL, // 0 Attendees.ATTENDEE_STATUS, // 1 @@ -519,6 +622,12 @@ public class AlertReceiver extends BroadcastReceiver { ATTENDEES_SORT_ORDER); } + private static Cursor getLocationCursor(Context context, long eventId) { + return context.getContentResolver().query( + ContentUris.withAppendedId(Events.CONTENT_URI, eventId), + new String[] { Events.EVENT_LOCATION }, null, null, null); + } + /** * Creates a broadcast pending intent that fires to AlertReceiver when the email button * is clicked. @@ -545,10 +654,6 @@ public class AlertReceiver extends BroadcastReceiver { do { String email = attendeesCursor.getString(ATTENDEES_INDEX_EMAIL); if (Utils.isEmailableFrom(email, syncAccount)) { - // Send intent back to ourself first for a couple reasons: - // 1) Workaround issue where clicking action button in notification does - // not automatically close the notification shade. - // 2) Attendees list in email will always be up to date. Intent broadcastIntent = new Intent(MAIL_ACTION); broadcastIntent.setClass(context, AlertReceiver.class); broadcastIntent.putExtra(EXTRA_EVENT_ID, eventId); @@ -647,4 +752,137 @@ public class AlertReceiver extends BroadcastReceiver { emailList.add(email); } } + + /** + * Using the linkify magic, get a list of URLs from the event's location. If no such links + * are found, we should end up with a single geo link of the entire string. + */ + private static URLSpan[] getURLSpans(Context context, long eventId) { + Cursor locationCursor = getLocationCursor(context, eventId); + if (locationCursor != null && locationCursor.moveToFirst()) { + String location = locationCursor.getString(0); // Only one item in this cursor. + if (location == null || location.isEmpty()) { + // Return an empty list if we know there was nothing in the location field. + return new URLSpan[0]; + } + + TextView locationTV = new TextView(context); + locationTV.setText(location); + Utils.linkifyTextView(locationTV, false); + CharSequence text = locationTV.getText(); + + // The linkify method should have found at least one link, at the very least. + // If no smart links were found, it should have set the whole string as a geo link. + if (text instanceof Spannable) { + Spannable spanText = (SpannableString) locationTV.getText(); + URLSpan[] urlSpans = + spanText.getSpans(0, spanText.length(), URLSpan.class); + return urlSpans; + } + } + + // If no links were found or location was empty, return an empty list. + return new URLSpan[0]; + } + + /** + * Create a pending intent to send ourself a broadcast to start maps, using the first map + * link available. + * If no links are found, return null. + */ + private static PendingIntent createMapBroadcastIntent(Context context, URLSpan[] urlSpans, + long eventId) { + for (int span_i = 0; span_i < urlSpans.length; span_i++) { + URLSpan urlSpan = urlSpans[span_i]; + String urlString = urlSpan.getURL(); + if (urlString.startsWith(GEO_PREFIX)) { + Intent broadcastIntent = new Intent(MAP_ACTION); + broadcastIntent.setClass(context, AlertReceiver.class); + broadcastIntent.putExtra(EXTRA_EVENT_ID, eventId); + return PendingIntent.getBroadcast(context, + Long.valueOf(eventId).hashCode(), broadcastIntent, + PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + // No geo link was found, so return null; + return null; + } + + /** + * Create an intent to take the user to maps, using the first map link available. + * If no links are found, return null. + */ + private static Intent createMapActivityIntent(Context context, URLSpan[] urlSpans) { + for (int span_i = 0; span_i < urlSpans.length; span_i++) { + URLSpan urlSpan = urlSpans[span_i]; + String urlString = urlSpan.getURL(); + if (urlString.startsWith(GEO_PREFIX)) { + Intent geoIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlString)); + geoIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return geoIntent; + } + } + + // No geo link was found, so return null; + return null; + } + + /** + * Create a pending intent to send ourself a broadcast to take the user to dialer, or any other + * app capable of making phone calls. Use the first phone number available. If no phone number + * is found, or if the device is not capable of making phone calls (i.e. a tablet), return null. + */ + private static PendingIntent createCallBroadcastIntent(Context context, URLSpan[] urlSpans, + long eventId) { + // Return null if the device is unable to make phone calls. + TelephonyManager tm = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (tm.getPhoneType() == TelephonyManager.PHONE_TYPE_NONE) { + return null; + } + + for (int span_i = 0; span_i < urlSpans.length; span_i++) { + URLSpan urlSpan = urlSpans[span_i]; + String urlString = urlSpan.getURL(); + if (urlString.startsWith(TEL_PREFIX)) { + Intent broadcastIntent = new Intent(CALL_ACTION); + broadcastIntent.setClass(context, AlertReceiver.class); + broadcastIntent.putExtra(EXTRA_EVENT_ID, eventId); + return PendingIntent.getBroadcast(context, + Long.valueOf(eventId).hashCode(), broadcastIntent, + PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + // No tel link was found, so return null; + return null; + } + + /** + * Create an intent to take the user to dialer, or any other app capable of making phone calls. + * Use the first phone number available. If no phone number is found, or if the device is + * not capable of making phone calls (i.e. a tablet), return null. + */ + private static Intent createCallActivityIntent(Context context, URLSpan[] urlSpans) { + // Return null if the device is unable to make phone calls. + TelephonyManager tm = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (tm.getPhoneType() == TelephonyManager.PHONE_TYPE_NONE) { + return null; + } + + for (int span_i = 0; span_i < urlSpans.length; span_i++) { + URLSpan urlSpan = urlSpans[span_i]; + String urlString = urlSpan.getURL(); + if (urlString.startsWith(TEL_PREFIX)) { + Intent callIntent = new Intent(Intent.ACTION_DIAL, Uri.parse(urlString)); + callIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return callIntent; + } + } + + // No tel link was found, so return null; + return null; + } } diff --git a/tests/src/com/android/calendar/UtilsTests.java b/tests/src/com/android/calendar/UtilsTests.java index 6beac971..02798e49 100644 --- a/tests/src/com/android/calendar/UtilsTests.java +++ b/tests/src/com/android/calendar/UtilsTests.java @@ -363,7 +363,7 @@ public class UtilsTests extends TestCase { * @param matches Pairs of start/end positions. */ private static void findPhoneNumber(String text, String[] matches) { - int[] results = EventInfoFragment.findNanpPhoneNumbers(text); + int[] results = Utils.findNanpPhoneNumbers(text); assertEquals(results.length % 2, 0); |