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
18package com.android.mail.browse;
19
20import android.app.Activity;
21import android.content.ClipData;
22import android.content.ClipboardManager;
23import android.content.Context;
24import android.content.Intent;
25import android.content.pm.PackageManager;
26import android.content.pm.ResolveInfo;
27import android.net.Uri;
28import android.provider.ContactsContract;
29import android.view.ContextMenu;
30import android.view.ContextMenu.ContextMenuInfo;
31import android.view.MenuInflater;
32import android.view.MenuItem;
33import android.view.View;
34import android.view.View.OnCreateContextMenuListener;
35import android.webkit.WebView;
36
37import com.android.mail.R;
38import com.android.mail.analytics.Analytics;
39import com.android.mail.providers.Message;
40
41import java.io.UnsupportedEncodingException;
42import java.net.URLDecoder;
43import java.net.URLEncoder;
44import 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 */
55public 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