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