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          */
getMessageForClickedUrl(String url)93         ConversationMessage getMessageForClickedUrl(String url);
94     }
95 
WebViewContextMenu(Activity host, InlineAttachmentViewIntentBuilder builder)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 
setCallbacks(Callbacks cb)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 
AnalyticsClick(String analyticsLabel)123         public AnalyticsClick(String analyticsLabel) {
124             mAnalyticsLabel = analyticsLabel;
125         }
126 
127         @Override
onMenuItemClick(MenuItem item)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 
onClick()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 
Copy(CharSequence text, String analyticsLabel)141         public Copy(CharSequence text, String analyticsLabel) {
142             super(analyticsLabel);
143             mText = text;
144         }
145 
146         @Override
onClick()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 
SendIntent(String analyticsLabel)161         public SendIntent(String analyticsLabel) {
162             super(analyticsLabel);
163         }
164 
SendIntent(Intent intent, String analyticsLabel)165         public SendIntent(Intent intent, String analyticsLabel) {
166             super(analyticsLabel);
167             setIntent(intent);
168         }
169 
setIntent(Intent intent)170         void setIntent(Intent intent) {
171             mIntent = intent;
172         }
173 
174         @Override
onClick()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 {
Share(String url, String analyticsLabel)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 
showShareLinkMenuItem()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
onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo info)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 
setupAnchorMenu(String extra, ContextMenu menu)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      */
setupImageMenu(String url, ContextMenu menu)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
onMenuItemClick(MenuItem item)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      */
getMenuResIdForMenuType(MenuType menuType)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      */
getChooserTitleStringResIdForMenuType(MenuType menuType)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      */
getMenuResourceId()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      */
onMenuItemSelected(MenuItem menuItem)481     protected boolean onMenuItemSelected(MenuItem menuItem) {
482         return mActivity.onOptionsItemSelected(menuItem);
483     }
484 }
485