1 // Copyright 2017 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.chrome.browser.contextualsearch;
6 
7 import android.graphics.Point;
8 import android.graphics.Rect;
9 import android.text.TextUtils;
10 import android.view.View;
11 import android.widget.PopupWindow.OnDismissListener;
12 
13 import org.chromium.chrome.R;
14 import org.chromium.chrome.browser.compositor.bottombar.contextualsearch.ContextualSearchPanel;
15 import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
16 import org.chromium.chrome.browser.profiles.Profile;
17 import org.chromium.chrome.browser.util.ChromeAccessibilityUtil;
18 import org.chromium.components.browser_ui.widget.textbubble.TextBubble;
19 import org.chromium.components.feature_engagement.EventConstants;
20 import org.chromium.components.feature_engagement.FeatureConstants;
21 import org.chromium.components.feature_engagement.Tracker;
22 import org.chromium.components.feature_engagement.TriggerState;
23 import org.chromium.ui.widget.AnchoredPopupWindow;
24 import org.chromium.ui.widget.RectProvider;
25 
26 /**
27  * Helper class for displaying In-Product Help UI for Contextual Search.
28  */
29 public class ContextualSearchIPH {
30     private static final int FLOATING_BUBBLE_SPACING_FACTOR = 10;
31     private View mParentView;
32     private ContextualSearchPanel mSearchPanel;
33     private TextBubble mHelpBubble;
34     private RectProvider mRectProvider;
35     private String mFeatureName;
36     private boolean mIsShowing;
37     private boolean mDidShow;
38     private boolean mIsPositionedByPanel;
39     private boolean mHasUserEverEngaged;
40     private Point mFloatingBubbleAnchorPoint;
41     private OnDismissListener mDismissListener;
42     private boolean mDidUserOptIn;
43 
44     /**
45      * Constructs the helper class.
46      */
ContextualSearchIPH()47     ContextualSearchIPH() {}
48 
49     /**
50      * @param searchPanel The instance of {@link ContextualSearchPanel}.
51      */
setSearchPanel(ContextualSearchPanel searchPanel)52     void setSearchPanel(ContextualSearchPanel searchPanel) {
53         mSearchPanel = searchPanel;
54     }
55 
56     /**
57      * @param parentView The parent view that the {@link TextBubble} will be attached to.
58      */
setParentView(View parentView)59     void setParentView(View parentView) {
60         mParentView = parentView;
61     }
62 
63     /**
64      * Called after the Contextual Search panel's animation is finished.
65      * @param wasActivatedByTap Whether Contextual Search was activated by tapping.
66      * @param profile The {@link Profile} used for {@link TrackerFactory}.
67      */
onPanelFinishedShowing(boolean wasActivatedByTap, Profile profile)68     void onPanelFinishedShowing(boolean wasActivatedByTap, Profile profile) {
69         if (!wasActivatedByTap) {
70             maybeShow(FeatureConstants.CONTEXTUAL_SEARCH_PROMOTE_TAP_FEATURE, profile);
71             maybeShow(FeatureConstants.CONTEXTUAL_SEARCH_WEB_SEARCH_FEATURE, profile);
72         }
73     }
74 
75     /**
76      * Called after entity data is received.
77      * @param wasActivatedByTap Whether Contextual Search was activated by tapping.
78      * @param profile The {@link Profile} used for {@link TrackerFactory}.
79      */
onEntityDataReceived(boolean wasActivatedByTap, Profile profile)80     void onEntityDataReceived(boolean wasActivatedByTap, Profile profile) {
81         Tracker tracker = TrackerFactory.getTrackerForProfile(profile);
82         tracker.notifyEvent(EventConstants.CONTEXTUAL_SEARCH_ENTITY_RESULT);
83         if (wasActivatedByTap) {
84             maybeShow(FeatureConstants.CONTEXTUAL_SEARCH_PROMOTE_PANEL_OPEN_FEATURE, profile);
85         }
86     }
87 
88     /**
89      * Called when the Search Panel is shown.
90      * @param wasActivatedByTap Whether Contextual Search was activated by tapping.
91      * @param profile The {@link Profile} used for {@link TrackerFactory}.
92      */
onSearchPanelShown(boolean wasActivatedByTap, Profile profile)93     void onSearchPanelShown(boolean wasActivatedByTap, Profile profile) {
94         Tracker tracker = TrackerFactory.getTrackerForProfile(profile);
95         tracker.notifyEvent(wasActivatedByTap
96                         ? EventConstants.CONTEXTUAL_SEARCH_TRIGGERED_BY_TAP
97                         : EventConstants.CONTEXTUAL_SEARCH_TRIGGERED_BY_LONGPRESS);
98 
99         // Log whether IPH for tapping has been shown before.
100         if (wasActivatedByTap) {
101             ContextualSearchUma.logTapIPH(
102                     tracker.getTriggerState(FeatureConstants.CONTEXTUAL_SEARCH_PROMOTE_TAP_FEATURE)
103                     == TriggerState.HAS_BEEN_DISPLAYED);
104         }
105     }
106 
107     /**
108      * Should be called after the user taps but a tap will not trigger due to longpress activation.
109      * @param profile The active user profile.
110      * @param bubbleAnchorPoint The point where the bubble arrow should be positioned.
111      * @param hasUserEverEngaged Whether the user has ever engaged Contextual Search by opening
112      *        the panel.
113      * @param dismissListener An {@link OnDismissListener} to call when the bubble is dismissed.
114      */
onNonTriggeringTap(Profile profile, Point bubbleAnchorPoint, boolean hasUserEverEngaged, OnDismissListener dismissListener)115     void onNonTriggeringTap(Profile profile, Point bubbleAnchorPoint, boolean hasUserEverEngaged,
116             OnDismissListener dismissListener) {
117         mFloatingBubbleAnchorPoint = bubbleAnchorPoint;
118         mHasUserEverEngaged = hasUserEverEngaged;
119         mDismissListener = dismissListener;
120         maybeShow(FeatureConstants.CONTEXTUAL_SEARCH_TAPPED_BUT_SHOULD_LONGPRESS_FEATURE, profile,
121                 false);
122     }
123 
124     /**
125      * Should be called when the panel is shown and a Translation is needed but the user has
126      * not yet Opted-in.
127      * @param profile The {@link Profile} used for {@link TrackerFactory}.
128      */
onTranslationNeeded(Profile profile)129     void onTranslationNeeded(Profile profile) {
130         maybeShow(FeatureConstants.CONTEXTUAL_SEARCH_TRANSLATION_ENABLE_FEATURE, profile);
131     }
132 
133     /**
134      * Shows the appropriate In-Product Help UI if the conditions are met.
135      * @param featureName Name of the feature in IPH, look at {@link FeatureConstants}.
136      * @param profile The {@link Profile} used for {@link TrackerFactory}.
137      */
maybeShow(String featureName, Profile profile)138     private void maybeShow(String featureName, Profile profile) {
139         maybeShow(featureName, profile, true);
140     }
141 
142     /**
143      * Shows the appropriate In-Product Help UI if the conditions are met.
144      * @param featureName Name of the feature in IPH, look at {@link FeatureConstants}.
145      * @param profile The {@link Profile} used for {@link TrackerFactory}.
146      * @param isPositionedByPanel Whether the bubble positioning should be based on the
147      *        panel position instead of floating somewhere on the base page.
148      */
maybeShow(String featureName, Profile profile, boolean isPositionedByPanel)149     private void maybeShow(String featureName, Profile profile, boolean isPositionedByPanel) {
150         mIsPositionedByPanel = isPositionedByPanel;
151         if (mIsShowing || profile == null || mParentView == null
152                 || mIsPositionedByPanel && mSearchPanel == null) {
153             return;
154         }
155 
156         mFeatureName = featureName;
157         maybeShowFeaturedBubble(profile);
158     }
159 
160     /**
161      * Shows a help bubble if the In-Product Help conditions are met.
162      * Private state members are used to determine which message to show in the bubble
163      * and how to position it.
164      * @param profile The {@link Profile} used for {@link TrackerFactory}.
165      */
maybeShowFeaturedBubble(Profile profile)166     private void maybeShowFeaturedBubble(Profile profile) {
167         if (mIsPositionedByPanel && !mSearchPanel.isShowing()) return;
168 
169         final Tracker tracker = TrackerFactory.getTrackerForProfile(profile);
170         if (!tracker.shouldTriggerHelpUI(mFeatureName)) return;
171 
172         int stringId = 0;
173         switch (mFeatureName) {
174             case FeatureConstants.CONTEXTUAL_SEARCH_WEB_SEARCH_FEATURE:
175                 stringId = R.string.contextual_search_iph_search_result;
176                 break;
177             case FeatureConstants.CONTEXTUAL_SEARCH_PROMOTE_PANEL_OPEN_FEATURE:
178                 stringId = R.string.contextual_search_iph_entity;
179                 break;
180             case FeatureConstants.CONTEXTUAL_SEARCH_PROMOTE_TAP_FEATURE:
181                 stringId = R.string.contextual_search_iph_tap;
182                 break;
183             case FeatureConstants.CONTEXTUAL_SEARCH_TAPPED_BUT_SHOULD_LONGPRESS_FEATURE:
184                 // TODO(donnd): put the engaged user variant behind a separate fieldtrial parameter
185                 // so we can control it or collapse it later.
186                 if (mHasUserEverEngaged) {
187                     stringId = R.string.contextual_search_iph_touch_and_hold_engaged;
188                 } else {
189                     stringId = R.string.contextual_search_iph_touch_and_hold;
190                 }
191                 break;
192             case FeatureConstants.CONTEXTUAL_SEARCH_TRANSLATION_ENABLE_FEATURE:
193                 stringId = R.string.contextual_search_iph_enable;
194                 break;
195         }
196 
197         assert stringId != 0;
198         assert mHelpBubble == null;
199         mRectProvider = new RectProvider(getHelpBubbleAnchorRect());
200         mHelpBubble = new TextBubble(mParentView.getContext(), mParentView, stringId, stringId,
201                 mRectProvider, ChromeAccessibilityUtil.get().isAccessibilityEnabled());
202 
203         // Set the dismiss logic.
204         mHelpBubble.setDismissOnTouchInteraction(true);
205         mHelpBubble.addOnDismissListener(() -> {
206             tracker.dismissed(mFeatureName);
207             mIsShowing = false;
208             mHelpBubble = null;
209         });
210         if (mDismissListener != null) {
211             mHelpBubble.addOnDismissListener(mDismissListener);
212             mDismissListener = null;
213         }
214 
215         maybeSetPreferredOrientation();
216         mHelpBubble.show();
217         mIsShowing = true;
218         mDidShow = true;
219     }
220 
221     /**
222      * Updates the position of the help bubble if it is showing.
223      */
updateBubblePosition()224     void updateBubblePosition() {
225         if (!mIsShowing || mHelpBubble == null || !mHelpBubble.isShowing()) return;
226 
227         mRectProvider.setRect(getHelpBubbleAnchorRect());
228     }
229 
230     /**
231      * @return A {@link Rect} object that represents the appropriate anchor for {@link TextBubble}.
232      */
getHelpBubbleAnchorRect()233     private Rect getHelpBubbleAnchorRect() {
234         int yInsetPx = mParentView.getResources().getDimensionPixelOffset(
235                 R.dimen.contextual_search_bubble_y_inset);
236         if (!mIsPositionedByPanel) {
237             // Position the bubble to point to an adjusted tap location, since there's no panel,
238             // just a selected word.  It would be better to point to the rectangle of the selected
239             // word, but that's not easy to get.
240             int adjustFactor = shouldPositionBubbleBelowArrow() ? -1 : 1;
241             int yAdjust = FLOATING_BUBBLE_SPACING_FACTOR * yInsetPx * adjustFactor;
242             return new Rect(mFloatingBubbleAnchorPoint.x, mFloatingBubbleAnchorPoint.y + yAdjust,
243                     mFloatingBubbleAnchorPoint.x, mFloatingBubbleAnchorPoint.y + yAdjust);
244         }
245 
246         Rect anchorRect = mSearchPanel.getPanelRect();
247         anchorRect.top -= yInsetPx;
248         return anchorRect;
249     }
250 
251     /** Overrides the preferred orientation if the bubble is not anchored to the panel. */
maybeSetPreferredOrientation()252     private void maybeSetPreferredOrientation() {
253         if (mIsPositionedByPanel) return;
254 
255         mHelpBubble.setPreferredVerticalOrientation(shouldPositionBubbleBelowArrow()
256                         ? AnchoredPopupWindow.VerticalOrientation.BELOW
257                         : AnchoredPopupWindow.VerticalOrientation.ABOVE);
258     }
259 
260     /** @return whether the bubble should be positioned below it's arrow pointer. */
shouldPositionBubbleBelowArrow()261     private boolean shouldPositionBubbleBelowArrow() {
262         // The bubble looks best when above the arrow, so we use that for most of the screen,
263         // but needs to appear below the arrow near the top.
264         return mFloatingBubbleAnchorPoint.y < mParentView.getHeight() / 3;
265     }
266 
267     /**
268      * Notifies that the search has completed so we can dismiss the In-Product Help UI, etc.
269      */
onCloseContextualSearch()270     void onCloseContextualSearch() {
271         recordOptedInOutcome();
272         if (!mIsShowing || TextUtils.isEmpty(mFeatureName)) return;
273 
274         mHelpBubble.dismiss();
275 
276         mIsShowing = false;
277     }
278 
279     /**
280      * @return whether the bubble is currently showing for the tap-where-longpress-needed promo.
281      */
isShowingForTappedButShouldLongpress()282     boolean isShowingForTappedButShouldLongpress() {
283         return mIsShowing
284                 && FeatureConstants.CONTEXTUAL_SEARCH_TAPPED_BUT_SHOULD_LONGPRESS_FEATURE.equals(
285                         mFeatureName);
286     }
287 
288     /**
289      * Notifies the Feature Engagement backend and logs UMA metrics.
290      * @param profile The current {@link Profile}.
291      * @param wasSearchContentViewSeen Whether the Contextual Search panel was opened.
292      * @param wasActivatedByTap Whether the Contextual Search was activating by tapping.
293      * @param wasContextualCardsDataShown Whether entity cards were received.
294      */
doSearchFinishedNotifications(Profile profile, boolean wasSearchContentViewSeen, boolean wasActivatedByTap, boolean wasContextualCardsDataShown)295     public static void doSearchFinishedNotifications(Profile profile,
296             boolean wasSearchContentViewSeen, boolean wasActivatedByTap,
297             boolean wasContextualCardsDataShown) {
298         Tracker tracker = TrackerFactory.getTrackerForProfile(profile);
299         if (wasSearchContentViewSeen) {
300             tracker.notifyEvent(EventConstants.CONTEXTUAL_SEARCH_PANEL_OPENED);
301             tracker.notifyEvent(wasActivatedByTap
302                             ? EventConstants.CONTEXTUAL_SEARCH_PANEL_OPENED_AFTER_TAP
303                             : EventConstants.CONTEXTUAL_SEARCH_PANEL_OPENED_AFTER_LONGPRESS);
304 
305             // Log whether IPH for opening the panel has been shown before.
306             ContextualSearchUma.logPanelOpenedIPH(
307                     tracker.getTriggerState(
308                             FeatureConstants.CONTEXTUAL_SEARCH_PROMOTE_PANEL_OPEN_FEATURE)
309                     == TriggerState.HAS_BEEN_DISPLAYED);
310 
311             // Log whether IPH for Contextual Search web search has been shown before.
312             ContextualSearchUma.logContextualSearchIPH(
313                     tracker.getTriggerState(FeatureConstants.CONTEXTUAL_SEARCH_WEB_SEARCH_FEATURE)
314                     == TriggerState.HAS_BEEN_DISPLAYED);
315         }
316         if (wasContextualCardsDataShown) {
317             tracker.notifyEvent(EventConstants.CONTEXTUAL_SEARCH_PANEL_OPENED_FOR_ENTITY);
318         }
319 
320         // Log whether a Translation Opt-in suggestion IPH was ever shown.
321         ContextualSearchUma.logTranslationsOptInIPHShown(
322                 tracker.getTriggerState(
323                         FeatureConstants.CONTEXTUAL_SEARCH_TRANSLATION_ENABLE_FEATURE)
324                 == TriggerState.HAS_BEEN_DISPLAYED);
325     }
326 
327     /**
328      * Notifies the Feature Engagement backend and logs UMA metrics when the user Opted-in.
329      * @param profile The current user {@link Profile}.
330      */
doUserOptedInNotifications(Profile profile)331     void doUserOptedInNotifications(Profile profile) {
332         Tracker tracker = TrackerFactory.getTrackerForProfile(profile);
333         tracker.notifyEvent(EventConstants.CONTEXTUAL_SEARCH_ENABLED_OPT_IN);
334         mDidUserOptIn = true;
335     }
336 
337     /**
338      * Records UMA metrics inidcated whether the user Opted-in.
339      */
recordOptedInOutcome()340     private void recordOptedInOutcome() {
341         // If we showed the suggestion to Opt-in for Translations, Log whether the user did or not.
342         if (mDidShow
343                 && mFeatureName.equals(
344                         FeatureConstants.CONTEXTUAL_SEARCH_TRANSLATION_ENABLE_FEATURE)) {
345             ContextualSearchUma.logTranslationsOptInIPHWorked(mDidUserOptIn);
346         }
347         mDidShow = false;
348         mDidUserOptIn = false;
349     }
350 }
351