Home | History | Annotate | Download | only in browse
      1 /*
      2  * Copyright (C) 2011 Google Inc.
      3  * Licensed to The Android Open Source Project.
      4  *
      5  * Licensed under the Apache License, Version 2.0 (the "License");
      6  * you may not use this file except in compliance with the License.
      7  * You may obtain a copy of the License at
      8  *
      9  *      http://www.apache.org/licenses/LICENSE-2.0
     10  *
     11  * Unless required by applicable law or agreed to in writing, software
     12  * distributed under the License is distributed on an "AS IS" BASIS,
     13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     14  * See the License for the specific language governing permissions and
     15  * limitations under the License.
     16  */
     17 
     18 package com.android.mail.browse;
     19 
     20 import android.app.Activity;
     21 import android.content.ClipData;
     22 import android.content.ClipboardManager;
     23 import android.content.Context;
     24 import android.content.Intent;
     25 import android.content.pm.PackageManager;
     26 import android.content.pm.ResolveInfo;
     27 import android.net.Uri;
     28 import android.provider.ContactsContract;
     29 import android.view.ContextMenu;
     30 import android.view.ContextMenu.ContextMenuInfo;
     31 import android.view.MenuInflater;
     32 import android.view.MenuItem;
     33 import android.view.View;
     34 import android.view.View.OnCreateContextMenuListener;
     35 import android.webkit.WebView;
     36 
     37 import com.android.mail.R;
     38 import com.android.mail.analytics.Analytics;
     39 import com.android.mail.providers.Message;
     40 
     41 import java.io.UnsupportedEncodingException;
     42 import java.net.URLDecoder;
     43 import java.net.URLEncoder;
     44 import java.nio.charset.Charset;
     45 
     46 /**
     47  * <p>Handles display and behavior of the context menu for known actionable content in WebViews.
     48  * Requires an Activity to bind to for Context resolution and to start other activites.</p>
     49  * <br>
     50  * Dependencies:
     51  * <ul>
     52  * <li>res/menu/webview_context_menu.xml</li>
     53  * </ul>
     54  */
     55 public class WebViewContextMenu implements OnCreateContextMenuListener,
     56         MenuItem.OnMenuItemClickListener {
     57 
     58     private final Activity mActivity;
     59     private final InlineAttachmentViewIntentBuilder mIntentBuilder;
     60 
     61     private final boolean mSupportsDial;
     62     private final boolean mSupportsSms;
     63 
     64     private Callbacks mCallbacks;
     65 
     66     // Strings used for analytics.
     67     private static final String CATEGORY_WEB_CONTEXT_MENU = "web_context_menu";
     68     private static final String ACTION_LONG_PRESS = "long_press";
     69     private static final String ACTION_CLICK = "menu_clicked";
     70 
     71     protected static enum MenuType {
     72         OPEN_MENU,
     73         COPY_LINK_MENU,
     74         SHARE_LINK_MENU,
     75         DIAL_MENU,
     76         SMS_MENU,
     77         ADD_CONTACT_MENU,
     78         COPY_PHONE_MENU,
     79         EMAIL_CONTACT_MENU,
     80         COPY_MAIL_MENU,
     81         MAP_MENU,
     82         COPY_GEO_MENU,
     83     }
     84 
     85     public interface Callbacks {
     86         /**
     87          * Given a URL the user clicks/long-presses on, get the {@link Message} whose body contains
     88          * that URL.
     89          *
     90          * @param url URL of a selected link
     91          * @return Message containing that URL
     92          */
     93         ConversationMessage getMessageForClickedUrl(String url);
     94     }
     95 
     96     public WebViewContextMenu(Activity host, InlineAttachmentViewIntentBuilder builder) {
     97         mActivity = host;
     98         mIntentBuilder = builder;
     99 
    100         // Query the package manager to see if the device
    101         // has an app that supports ACTION_DIAL or ACTION_SENDTO
    102         // with the appropriate uri schemes.
    103         final PackageManager pm = mActivity.getPackageManager();
    104         mSupportsDial = !pm.queryIntentActivities(
    105                 new Intent(Intent.ACTION_DIAL, Uri.parse(WebView.SCHEME_TEL)),
    106                 PackageManager.MATCH_DEFAULT_ONLY).isEmpty();
    107         mSupportsSms = !pm.queryIntentActivities(
    108                 new Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:")),
    109                 PackageManager.MATCH_DEFAULT_ONLY).isEmpty();
    110     }
    111 
    112     public void setCallbacks(Callbacks cb) {
    113         mCallbacks = cb;
    114     }
    115 
    116     /**
    117      * Abstract base class that automates sending an analytics event
    118      * when the menu item is clicked.
    119      */
    120     private abstract class AnalyticsClick implements MenuItem.OnMenuItemClickListener {
    121         private final String mAnalyticsLabel;
    122 
    123         public AnalyticsClick(String analyticsLabel) {
    124             mAnalyticsLabel = analyticsLabel;
    125         }
    126 
    127         @Override
    128         public final boolean onMenuItemClick(MenuItem item) {
    129             Analytics.getInstance().sendEvent(
    130                     CATEGORY_WEB_CONTEXT_MENU, ACTION_CLICK, mAnalyticsLabel, 0);
    131             return onClick();
    132         }
    133 
    134         public abstract boolean onClick();
    135     }
    136 
    137     // For our copy menu items.
    138     private class Copy extends AnalyticsClick {
    139         private final CharSequence mText;
    140 
    141         public Copy(CharSequence text, String analyticsLabel) {
    142             super(analyticsLabel);
    143             mText = text;
    144         }
    145 
    146         @Override
    147         public boolean onClick() {
    148             ClipboardManager clipboard =
    149                     (ClipboardManager) mActivity.getSystemService(Context.CLIPBOARD_SERVICE);
    150             clipboard.setPrimaryClip(ClipData.newPlainText(null, mText));
    151             return true;
    152         }
    153     }
    154 
    155     /**
    156      * Sends an intent and reports the analytics event.
    157      */
    158     private class SendIntent extends AnalyticsClick {
    159         private Intent mIntent;
    160 
    161         public SendIntent(String analyticsLabel) {
    162             super(analyticsLabel);
    163         }
    164 
    165         public SendIntent(Intent intent, String analyticsLabel) {
    166             super(analyticsLabel);
    167             setIntent(intent);
    168         }
    169 
    170         void setIntent(Intent intent) {
    171             mIntent = intent;
    172         }
    173 
    174         @Override
    175         public final boolean onClick() {
    176             try {
    177                 mActivity.startActivity(mIntent);
    178             } catch(android.content.ActivityNotFoundException ex) {
    179                 // if no app handles it, do nothing
    180             }
    181             return true;
    182         }
    183     }
    184 
    185     // For our share menu items.
    186     private class Share extends SendIntent {
    187         public Share(String url, String analyticsLabel) {
    188             super(analyticsLabel);
    189             final Intent send = new Intent(Intent.ACTION_SEND);
    190             send.setType("text/plain");
    191             send.putExtra(Intent.EXTRA_TEXT, url);
    192             setIntent(Intent.createChooser(send, mActivity.getText(
    193                     getChooserTitleStringResIdForMenuType(MenuType.SHARE_LINK_MENU))));
    194         }
    195     }
    196 
    197     private boolean showShareLinkMenuItem() {
    198         PackageManager pm = mActivity.getPackageManager();
    199         Intent send = new Intent(Intent.ACTION_SEND);
    200         send.setType("text/plain");
    201         ResolveInfo ri = pm.resolveActivity(send, PackageManager.MATCH_DEFAULT_ONLY);
    202         return ri != null;
    203     }
    204 
    205     @Override
    206     public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo info) {
    207         // FIXME: This is copied over almost directly from BrowserActivity.
    208         // Would like to find a way to combine the two (Bug 1251210).
    209 
    210         WebView webview = (WebView) v;
    211         WebView.HitTestResult result = webview.getHitTestResult();
    212         if (result == null) {
    213             return;
    214         }
    215 
    216         int type = result.getType();
    217         switch (type) {
    218             case WebView.HitTestResult.UNKNOWN_TYPE:
    219                 Analytics.getInstance().sendEvent(
    220                         CATEGORY_WEB_CONTEXT_MENU, ACTION_LONG_PRESS, "unknown", 0);
    221                 return;
    222             case WebView.HitTestResult.EDIT_TEXT_TYPE:
    223                 Analytics.getInstance().sendEvent(
    224                         CATEGORY_WEB_CONTEXT_MENU, ACTION_LONG_PRESS, "edit_text", 0);
    225                 return;
    226             default:
    227                 break;
    228         }
    229 
    230         // Note, http://b/issue?id=1106666 is requesting that
    231         // an inflated menu can be used again. This is not available
    232         // yet, so inflate each time (yuk!)
    233         MenuInflater inflater = mActivity.getMenuInflater();
    234         // Also, we are copying the menu file from browser until
    235         // 1251210 is fixed.
    236         inflater.inflate(getMenuResourceId(), menu);
    237 
    238         // Initially make set the menu item handler this WebViewContextMenu, which will default to
    239         // calling the non-abstract subclass's implementation.
    240         for (int i = 0; i < menu.size(); i++) {
    241             final MenuItem menuItem = menu.getItem(i);
    242             menuItem.setOnMenuItemClickListener(this);
    243         }
    244 
    245 
    246         // Show the correct menu group
    247         String extra = result.getExtra();
    248         menu.setGroupVisible(R.id.PHONE_MENU, type == WebView.HitTestResult.PHONE_TYPE);
    249         menu.setGroupVisible(R.id.EMAIL_MENU, type == WebView.HitTestResult.EMAIL_TYPE);
    250         menu.setGroupVisible(R.id.GEO_MENU, type == WebView.HitTestResult.GEO_TYPE);
    251         menu.setGroupVisible(R.id.ANCHOR_MENU, type == WebView.HitTestResult.SRC_ANCHOR_TYPE
    252                 || type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE);
    253         menu.setGroupVisible(R.id.IMAGE_MENU, type == WebView.HitTestResult.IMAGE_TYPE
    254                 || type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE);
    255 
    256         // Setup custom handling depending on the type
    257         switch (type) {
    258             case WebView.HitTestResult.PHONE_TYPE:
    259                 Analytics.getInstance().sendEvent(
    260                         CATEGORY_WEB_CONTEXT_MENU, ACTION_LONG_PRESS, "phone", 0);
    261                 String decodedPhoneExtra;
    262                 try {
    263                     decodedPhoneExtra = URLDecoder.decode(extra, Charset.defaultCharset().name());
    264 
    265                     // International numbers start with '+' followed by the country code, etc.
    266                     // However, during decode, the initial '+' is changed into ' '.
    267                     // Let's special case that here to avoid losing that information. If the decoded
    268                     // string starts with one space, let's replace that space with + since it's
    269                     // impossible for the normal number string to start with a space.
    270                     // b/10640197
    271                     if (decodedPhoneExtra.startsWith(" ") && !decodedPhoneExtra.startsWith("  ")) {
    272                         decodedPhoneExtra = decodedPhoneExtra.replaceFirst(" ", "+");
    273                     }
    274                 } catch (UnsupportedEncodingException ignore) {
    275                     // Should never happen; default charset is UTF-8
    276                     decodedPhoneExtra = extra;
    277                 }
    278 
    279                 menu.setHeaderTitle(decodedPhoneExtra);
    280                 // Dial
    281                 final MenuItem dialMenuItem =
    282                         menu.findItem(getMenuResIdForMenuType(MenuType.DIAL_MENU));
    283 
    284                 if (mSupportsDial) {
    285                     final Intent intent = new Intent(Intent.ACTION_DIAL,
    286                             Uri.parse(WebView.SCHEME_TEL + extra));
    287                     dialMenuItem.setOnMenuItemClickListener(new SendIntent(intent, "dial"));
    288                 } else {
    289                     dialMenuItem.setVisible(false);
    290                 }
    291 
    292                 // Send SMS
    293                 final MenuItem sendSmsMenuItem =
    294                         menu.findItem(getMenuResIdForMenuType(MenuType.SMS_MENU));
    295                 if (mSupportsSms) {
    296                     final Intent intent =
    297                             new Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:" + extra));
    298                     sendSmsMenuItem.setOnMenuItemClickListener(new SendIntent(intent, "sms"));
    299                 } else {
    300                     sendSmsMenuItem.setVisible(false);
    301                 }
    302 
    303                 // Add to contacts
    304                 final Intent addIntent = new Intent(Intent.ACTION_INSERT_OR_EDIT);
    305                 addIntent.setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE);
    306 
    307                 addIntent.putExtra(ContactsContract.Intents.Insert.PHONE, decodedPhoneExtra);
    308                 final MenuItem addToContactsMenuItem =
    309                         menu.findItem(getMenuResIdForMenuType(MenuType.ADD_CONTACT_MENU));
    310                 addToContactsMenuItem.setOnMenuItemClickListener(
    311                         new SendIntent(addIntent, "add_contact"));
    312 
    313                 // Copy
    314                 menu.findItem(getMenuResIdForMenuType(MenuType.COPY_PHONE_MENU)).
    315                         setOnMenuItemClickListener(new Copy(extra, "copy_phone"));
    316                 break;
    317             case WebView.HitTestResult.EMAIL_TYPE:
    318                 Analytics.getInstance().sendEvent(
    319                         CATEGORY_WEB_CONTEXT_MENU, ACTION_LONG_PRESS, "email", 0);
    320                 menu.setHeaderTitle(extra);
    321                 final Intent mailtoIntent =
    322                         new Intent(Intent.ACTION_VIEW, Uri.parse(WebView.SCHEME_MAILTO + extra));
    323                 menu.findItem(getMenuResIdForMenuType(MenuType.EMAIL_CONTACT_MENU))
    324                         .setOnMenuItemClickListener(new SendIntent(mailtoIntent, "send_email"));
    325                 menu.findItem(getMenuResIdForMenuType(MenuType.COPY_MAIL_MENU)).
    326                         setOnMenuItemClickListener(new Copy(extra, "copy_email"));
    327                 break;
    328             case WebView.HitTestResult.GEO_TYPE:
    329                 Analytics.getInstance().sendEvent(
    330                         CATEGORY_WEB_CONTEXT_MENU, ACTION_LONG_PRESS, "geo", 0);
    331                 menu.setHeaderTitle(extra);
    332                 String geoExtra = "";
    333                 try {
    334                     geoExtra = URLEncoder.encode(extra, Charset.defaultCharset().name());
    335                 } catch (UnsupportedEncodingException ignore) {
    336                     // Should never happen; default charset is UTF-8
    337                 }
    338                 final MenuItem viewMapMenuItem =
    339                         menu.findItem(getMenuResIdForMenuType(MenuType.MAP_MENU));
    340 
    341                 final Intent viewMap =
    342                         new Intent(Intent.ACTION_VIEW, Uri.parse(WebView.SCHEME_GEO + geoExtra));
    343                 viewMapMenuItem.setOnMenuItemClickListener(new SendIntent(viewMap, "view_map"));
    344                 menu.findItem(getMenuResIdForMenuType(MenuType.COPY_GEO_MENU)).
    345                         setOnMenuItemClickListener(new Copy(extra, "copy_geo"));
    346                 break;
    347             case WebView.HitTestResult.SRC_ANCHOR_TYPE:
    348                 Analytics.getInstance().sendEvent(
    349                         CATEGORY_WEB_CONTEXT_MENU, ACTION_LONG_PRESS, "src_anchor", 0);
    350                 setupAnchorMenu(extra, menu);
    351                 break;
    352             case WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE:
    353                 Analytics.getInstance().sendEvent(
    354                         CATEGORY_WEB_CONTEXT_MENU, ACTION_LONG_PRESS, "src_image_anchor", 0);
    355                 setupAnchorMenu(extra, menu);
    356                 setupImageMenu(extra, menu);
    357                 break;
    358             case WebView.HitTestResult.IMAGE_TYPE:
    359                 Analytics.getInstance().sendEvent(
    360                         CATEGORY_WEB_CONTEXT_MENU, ACTION_LONG_PRESS, "image", 0);
    361                 setupImageMenu(extra, menu);
    362                 break;
    363             default:
    364                 break;
    365         }
    366     }
    367 
    368     private void setupAnchorMenu(String extra, ContextMenu menu) {
    369         menu.findItem(getMenuResIdForMenuType(MenuType.SHARE_LINK_MENU)).setVisible(
    370                 showShareLinkMenuItem());
    371 
    372         // The documentation for WebView indicates that if the HitTestResult is
    373         // SRC_ANCHOR_TYPE or the url would be specified in the extra.  We don't need to
    374         // call requestFocusNodeHref().  If we wanted to handle UNKNOWN HitTestResults, we
    375         // would.  With this knowledge, we can just set the title
    376         menu.setHeaderTitle(extra);
    377 
    378         menu.findItem(getMenuResIdForMenuType(MenuType.COPY_LINK_MENU)).
    379                 setOnMenuItemClickListener(new Copy(extra, "copy_link"));
    380 
    381         final MenuItem openLinkMenuItem =
    382                 menu.findItem(getMenuResIdForMenuType(MenuType.OPEN_MENU));
    383         openLinkMenuItem.setOnMenuItemClickListener(
    384                 new SendIntent(new Intent(Intent.ACTION_VIEW, Uri.parse(extra)), "open_link"));
    385 
    386         menu.findItem(getMenuResIdForMenuType(MenuType.SHARE_LINK_MENU)).
    387                 setOnMenuItemClickListener(new Share(extra, "share_link"));
    388     }
    389 
    390     /**
    391      * Used to setup the image menu group if the {@link android.webkit.WebView.HitTestResult}
    392      * is of type {@link android.webkit.WebView.HitTestResult#IMAGE_TYPE} or
    393      * {@link android.webkit.WebView.HitTestResult#SRC_IMAGE_ANCHOR_TYPE}.
    394      * @param url Url that was long pressed.
    395      * @param menu The {@link android.view.ContextMenu} that is about to be shown.
    396      */
    397     private void setupImageMenu(String url, ContextMenu menu) {
    398         final ConversationMessage msg =
    399                 (mCallbacks != null) ? mCallbacks.getMessageForClickedUrl(url) : null;
    400         if (msg == null) {
    401             menu.setGroupVisible(R.id.IMAGE_MENU, false);
    402             return;
    403         }
    404 
    405         final Intent intent = mIntentBuilder.createInlineAttachmentViewIntent(mActivity, url, msg);
    406         if (intent == null) {
    407             menu.setGroupVisible(R.id.IMAGE_MENU, false);
    408             return;
    409         }
    410 
    411         final MenuItem menuItem = menu.findItem(R.id.view_image_context_menu_id);
    412         menuItem.setOnMenuItemClickListener(new SendIntent(intent, "view_image"));
    413 
    414         menu.setGroupVisible(R.id.IMAGE_MENU, true);
    415     }
    416 
    417     @Override
    418     public boolean onMenuItemClick(MenuItem item) {
    419         return onMenuItemSelected(item);
    420     }
    421 
    422     /**
    423      * Returns the menu resource id for the specified menu type
    424      * @param menuType type of the specified menu
    425      * @return menu resource id
    426      */
    427     protected int getMenuResIdForMenuType(MenuType menuType) {
    428         switch(menuType) {
    429             case OPEN_MENU:
    430                 return R.id.open_context_menu_id;
    431             case COPY_LINK_MENU:
    432                 return R.id.copy_link_context_menu_id;
    433             case SHARE_LINK_MENU:
    434                 return R.id.share_link_context_menu_id;
    435             case DIAL_MENU:
    436                 return R.id.dial_context_menu_id;
    437             case SMS_MENU:
    438                 return R.id.sms_context_menu_id;
    439             case ADD_CONTACT_MENU:
    440                 return R.id.add_contact_context_menu_id;
    441             case COPY_PHONE_MENU:
    442                 return R.id.copy_phone_context_menu_id;
    443             case EMAIL_CONTACT_MENU:
    444                 return R.id.email_context_menu_id;
    445             case COPY_MAIL_MENU:
    446                 return R.id.copy_mail_context_menu_id;
    447             case MAP_MENU:
    448                 return R.id.map_context_menu_id;
    449             case COPY_GEO_MENU:
    450                 return R.id.copy_geo_context_menu_id;
    451             default:
    452                 throw new IllegalStateException("Unexpected MenuType");
    453         }
    454     }
    455 
    456     /**
    457      * Returns the resource id of the string to be used when showing a chooser for a menu
    458      * @param menuType type of the specified menu
    459      * @return string resource id
    460      */
    461     protected int getChooserTitleStringResIdForMenuType(MenuType menuType) {
    462         switch(menuType) {
    463             case SHARE_LINK_MENU:
    464                 return R.string.choosertitle_sharevia;
    465             default:
    466                 throw new IllegalStateException("Unexpected MenuType");
    467         }
    468     }
    469 
    470     /**
    471      * Returns the resource id for the web view context menu
    472      */
    473     protected int getMenuResourceId() {
    474         return R.menu.webview_context_menu;
    475     }
    476 
    477 
    478     /**
    479      * Called when a menu item is not handled by the context menu.
    480      */
    481     protected boolean onMenuItemSelected(MenuItem menuItem) {
    482         return mActivity.onOptionsItemSelected(menuItem);
    483     }
    484 }
    485