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