// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/shared/coordinator/default_browser_promo/non_modal_default_browser_promo_scheduler_scene_agent.h"
#import "base/notreached.h"
#import "base/timer/timer.h"
#import "ios/chrome/browser/default_browser/model/utils.h"
#import "ios/chrome/browser/default_promo/ui_bundled/default_browser_promo_non_modal_commands.h"
#import "ios/chrome/browser/default_promo/ui_bundled/default_browser_promo_non_modal_metrics_util.h"
#import "ios/chrome/browser/overlays/model/public/overlay_presenter.h"
#import "ios/chrome/browser/overlays/model/public/overlay_presenter_observer_bridge.h"
#import "ios/chrome/browser/shared/coordinator/scene/scene_state.h"
#import "ios/chrome/browser/shared/model/browser/browser_observer_bridge.h"
#import "ios/chrome/browser/shared/model/browser/browser_provider.h"
#import "ios/chrome/browser/shared/model/browser/browser_provider_interface.h"
#import "ios/chrome/browser/shared/model/web_state_list/active_web_state_observation_forwarder.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list_observer_bridge.h"
#import "ios/chrome/browser/shared/public/commands/application_commands.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/web/public/web_state.h"
#import "ios/web/public/web_state_observer_bridge.h"
namespace {
// Default time interval to wait to show the promo after loading a webpage.
// This should allow any initial overlays to be presented first.
constexpr base::TimeDelta kShowPromoWebpageLoadWaitTime = base::Seconds(3);
// Default time interval to wait to show the promo after the share action is
// completed.
constexpr base::TimeDelta kShowPromoPostShareWaitTime = base::Seconds(1);
// Timeout before the promo is dismissed.
constexpr base::TimeDelta kPromoTimeout = base::Seconds(45);
typedef NS_ENUM(NSUInteger, PromoReason) {
PromoReasonNone,
PromoReasonOmniboxPaste,
PromoReasonExternalLink,
PromoReasonShare
};
NonModalPromoTriggerType MetricTypeForPromoReason(PromoReason reason) {
switch (reason) {
case PromoReasonNone:
return NonModalPromoTriggerType::kUnknown;
case PromoReasonOmniboxPaste:
return NonModalPromoTriggerType::kPastedLink;
case PromoReasonExternalLink:
return NonModalPromoTriggerType::kGrowthKitOpen;
case PromoReasonShare:
return NonModalPromoTriggerType::kShare;
default:
NOTREACHED_IN_MIGRATION();
break;
}
}
} // namespace
@interface NonModalDefaultBrowserPromoSchedulerSceneAgent () <
WebStateListObserving,
CRWWebStateObserver,
OverlayPresenterObserving,
BrowserObserving> {
std::unique_ptr<WebStateListObserverBridge> _webStateListObserver;
std::unique_ptr<web::WebStateObserverBridge> _webStateObserver;
std::unique_ptr<ActiveWebStateObservationForwarder> _forwarder;
std::unique_ptr<OverlayPresenterObserverBridge> _overlayObserver;
// Observe the browser the web state list is tied to to deregister any
// observers before the browser is destroyed.
std::unique_ptr<BrowserObserverBridge> _browserObserver;
// Timer for showing the promo after page load.
std::unique_ptr<base::OneShotTimer> _showPromoTimer;
// Timer for dismissing the promo after it is shown.
std::unique_ptr<base::OneShotTimer> _dismissPromoTimer;
__weak id<DefaultBrowserPromoNonModalCommands> _handler;
NSInteger _userInteractionWithNonModalPromoCount;
NSInteger _displayedFullscreenPromoCount;
}
// Time when a promo was shown on screen, used for metrics only.
@property(nonatomic) base::TimeTicks promoShownTime;
// WebState that the triggering event occured in.
@property(nonatomic, assign) web::WebState* webStateToListenTo;
// Whether or not the promo is currently showing.
@property(nonatomic, assign) BOOL promoIsShowing;
// The web state list used to listen to page load and
// WebState change events.
@property(nonatomic, assign) WebStateList* webStateList;
// The overlay presenter used to prevent the
// promo from showing over an overlay.
@property(nonatomic, assign) OverlayPresenter* overlayPresenter;
// The trigger reason for the in-progress promo flow.
@property(nonatomic, assign) PromoReason currentPromoReason;
// The browser that this scheduler uses to listen to events, such as page loads
// and overlay events
@property(nonatomic, assign) Browser* browser;
@end
@implementation NonModalDefaultBrowserPromoSchedulerSceneAgent
- (instancetype)init {
if ((self = [super init])) {
_webStateListObserver = std::make_unique<WebStateListObserverBridge>(self);
_webStateObserver = std::make_unique<web::WebStateObserverBridge>(self);
_overlayObserver = std::make_unique<OverlayPresenterObserverBridge>(self);
}
return self;
}
- (void)dealloc {
self.browser = nullptr;
}
- (void)logUserPastedInOmnibox {
if (self.currentPromoReason != PromoReasonNone) {
return;
}
if (![self promoCanBeDisplayed]) {
return;
}
// This assumes that the currently active webstate is the one that the paste
// occured in.
web::WebState* activeWebState = self.webStateList->GetActiveWebState();
// There should always be an active web state when pasting in the omnibox.
if (!activeWebState) {
return;
}
self.currentPromoReason = PromoReasonOmniboxPaste;
// Store the pasted web state, so when that web state's page load finishes,
// the promo can be shown.
self.webStateToListenTo = activeWebState;
}
- (void)logUserFinishedActivityFlow {
if (self.currentPromoReason != PromoReasonNone) {
return;
}
if (![self promoCanBeDisplayed]) {
return;
}
self.currentPromoReason = PromoReasonShare;
[self startShowPromoTimer];
}
- (void)logUserEnteredAppViaFirstPartyScheme {
if (self.currentPromoReason != PromoReasonNone) {
return;
}
if (![self promoCanBeDisplayed]) {
return;
}
self.currentPromoReason = PromoReasonExternalLink;
// Store the current web state, so when that web state's page load finishes,
// the promo can be shown.
self.webStateToListenTo = self.webStateList->GetActiveWebState();
}
- (void)logPromoWasDismissed {
self.currentPromoReason = PromoReasonNone;
self.webStateToListenTo = nullptr;
self.promoIsShowing = NO;
}
- (void)logTabGridEntered {
[self dismissPromoAnimated:YES];
}
- (void)logPopupMenuEntered {
[self dismissPromoAnimated:YES];
}
- (void)logUserPerformedPromoAction {
[self logPromoAction:self.currentPromoReason
promoShownTime:self.promoShownTime];
self.promoShownTime = base::TimeTicks();
}
- (void)logUserDismissedPromo {
[self logPromoUserDismiss:self.currentPromoReason
promoShownTime:self.promoShownTime];
self.promoShownTime = base::TimeTicks();
}
- (void)dismissPromoAnimated:(BOOL)animated {
_dismissPromoTimer = nullptr;
[self notifyHandlerDismissPromo:animated];
}
- (bool)promoCanBeDisplayed {
if (IsChromeLikelyDefaultBrowser()) {
return false;
}
if (IsNonModalDefaultBrowserPromoCooldownRefactorEnabled() &&
UserInNonModalPromoCooldown()) {
return false;
}
if (!IsNonModalDefaultBrowserPromoCooldownRefactorEnabled() &&
UserInFullscreenPromoCooldown()) {
return false;
}
NSInteger count = UserInteractionWithNonModalPromoCount();
return count < GetNonModalDefaultBrowserPromoImpressionLimit();
}
- (void)notifyHandlerShowPromo {
// The count of past non-modal promo interactions and fullscreen promo
// displays is cached because multiple interactions may be logged for the
// current non-modal promo impression. This makes sure we don't over-increment
// the interactions count value.
_userInteractionWithNonModalPromoCount =
UserInteractionWithNonModalPromoCount();
_displayedFullscreenPromoCount = DisplayedFullscreenPromoCount();
[_handler showDefaultBrowserNonModalPromo];
}
- (void)notifyHandlerDismissPromo:(BOOL)animated {
[_handler dismissDefaultBrowserNonModalPromoAnimated:animated];
}
- (void)onEnteringBackground:(PromoReason)currentPromoReason
promoIsShowing:(bool)promoIsShowing {
if (currentPromoReason != PromoReasonNone && !promoIsShowing) {
LogNonModalPromoAction(NonModalPromoAction::kBackgroundCancel,
MetricTypeForPromoReason(currentPromoReason),
_userInteractionWithNonModalPromoCount);
}
[self cancelShowPromoTimer];
[self dismissPromoAnimated:NO];
}
- (void)logPromoAppear:(PromoReason)currentPromoReason {
LogNonModalPromoAction(NonModalPromoAction::kAppear,
MetricTypeForPromoReason(currentPromoReason),
_userInteractionWithNonModalPromoCount);
}
- (void)logPromoAction:(PromoReason)currentPromoReason
promoShownTime:(base::TimeTicks)promoShownTime {
RecordDefaultBrowserPromoLastAction(
IOSDefaultBrowserPromoAction::kActionButton);
LogNonModalPromoAction(NonModalPromoAction::kAccepted,
MetricTypeForPromoReason(currentPromoReason),
_userInteractionWithNonModalPromoCount);
LogNonModalTimeOnScreen(promoShownTime);
LogUserInteractionWithNonModalPromo(_userInteractionWithNonModalPromoCount,
_displayedFullscreenPromoCount);
NSURL* settingsURL = [NSURL URLWithString:UIApplicationOpenSettingsURLString];
[[UIApplication sharedApplication] openURL:settingsURL
options:@{}
completionHandler:nil];
}
- (void)logPromoUserDismiss:(PromoReason)currentPromoReason
promoShownTime:(base::TimeTicks)promoShownTime {
RecordDefaultBrowserPromoLastAction(IOSDefaultBrowserPromoAction::kDismiss);
LogNonModalPromoAction(NonModalPromoAction::kDismiss,
MetricTypeForPromoReason(currentPromoReason),
_userInteractionWithNonModalPromoCount);
LogNonModalTimeOnScreen(promoShownTime);
LogUserInteractionWithNonModalPromo(_userInteractionWithNonModalPromoCount,
_displayedFullscreenPromoCount);
}
- (void)logPromoTimeout:(PromoReason)currentPromoReason
promoShownTime:(base::TimeTicks)promoShownTime {
LogNonModalPromoAction(NonModalPromoAction::kTimeout,
MetricTypeForPromoReason(currentPromoReason),
_userInteractionWithNonModalPromoCount);
LogNonModalTimeOnScreen(promoShownTime);
LogUserInteractionWithNonModalPromo(_userInteractionWithNonModalPromoCount,
_displayedFullscreenPromoCount);
}
#pragma mark - Accessors
- (void)setBrowser:(Browser*)browser {
if (_browser) {
self.webStateList = nullptr;
self.overlayPresenter = nullptr;
_handler = nil;
}
_browser = browser;
if (_browser) {
_browserObserver = std::make_unique<BrowserObserverBridge>(_browser, self);
self.webStateList = _browser->GetWebStateList();
self.overlayPresenter = OverlayPresenter::FromBrowser(
_browser, OverlayModality::kInfobarBanner);
_handler = HandlerForProtocol(browser->GetCommandDispatcher(),
DefaultBrowserPromoNonModalCommands);
}
}
- (void)setWebStateList:(WebStateList*)webStateList {
if (_webStateList) {
_webStateList->RemoveObserver(_webStateListObserver.get());
_forwarder = nullptr;
}
_webStateList = webStateList;
if (_webStateList) {
_webStateList->AddObserver(_webStateListObserver.get());
_forwarder = std::make_unique<ActiveWebStateObservationForwarder>(
_webStateList, _webStateObserver.get());
}
}
- (void)setOverlayPresenter:(OverlayPresenter*)overlayPresenter {
if (_overlayPresenter) {
_overlayPresenter->RemoveObserver(_overlayObserver.get());
}
_overlayPresenter = overlayPresenter;
if (_overlayPresenter) {
_overlayPresenter->AddObserver(_overlayObserver.get());
}
}
#pragma mark - WebStateListObserving
- (void)didChangeWebStateList:(WebStateList*)webStateList
change:(const WebStateListChange&)change
status:(const WebStateListStatus&)status {
switch (change.type()) {
case WebStateListChange::Type::kStatusOnly:
// The activation is handled after this switch statement.
break;
case WebStateListChange::Type::kDetach:
// Do nothing when a WebState is detached.
break;
case WebStateListChange::Type::kMove:
// Do nothing when a WebState is moved.
break;
case WebStateListChange::Type::kReplace:
// Do nothing when a WebState is replaced.
break;
case WebStateListChange::Type::kInsert: {
// For the external link open, the opened link can open in a new WebState.
// Assume that is the case if a new WebState is inserted and activated
// when the current web state is the one that was active when the link was
// opened.
if (self.currentPromoReason == PromoReasonExternalLink &&
self.webStateList->GetActiveWebState() == self.webStateToListenTo &&
status.active_web_state_change()) {
const WebStateListChangeInsert& insertChange =
change.As<WebStateListChangeInsert>();
self.webStateToListenTo = insertChange.inserted_web_state();
}
break;
}
case WebStateListChange::Type::kGroupCreate:
// Do nothing when a group is created.
break;
case WebStateListChange::Type::kGroupVisualDataUpdate:
// Do nothing when a tab group's visual data are updated.
break;
case WebStateListChange::Type::kGroupMove:
// Do nothing when a tab group is moved.
break;
case WebStateListChange::Type::kGroupDelete:
// Do nothing when a group is deleted.
break;
}
if (status.active_web_state_change()) {
if (status.new_active_web_state != self.webStateToListenTo) {
[self cancelShowPromoTimer];
}
}
}
#pragma mark - CRWWebStateObserver
- (void)webState:(web::WebState*)webState didLoadPageWithSuccess:(BOOL)success {
if (success && webState == self.webStateToListenTo) {
self.webStateToListenTo = nil;
[self startShowPromoTimer];
}
}
#pragma mark - OverlayPresenterObserving
- (void)overlayPresenter:(OverlayPresenter*)presenter
willShowOverlayForRequest:(OverlayRequest*)request
initialPresentation:(BOOL)initialPresentation {
[self cancelShowPromoTimer];
[self dismissPromoAnimated:YES];
}
- (void)overlayPresenterDestroyed:(OverlayPresenter*)presenter {
self.overlayPresenter = nullptr;
}
#pragma mark - SceneStateObserver
- (void)sceneState:(SceneState*)sceneState
transitionedToActivationLevel:(SceneActivationLevel)level {
if (level <= SceneActivationLevelBackground) {
[self onEnteringBackground:self.currentPromoReason
promoIsShowing:self.promoIsShowing];
}
}
- (void)sceneStateDidDisableUI:(SceneState*)sceneState {
self.browser = nullptr;
}
- (void)sceneStateDidEnableUI:(SceneState*)sceneState {
self.browser =
self.sceneState.browserProviderInterface.mainBrowserProvider.browser;
CHECK(self.browser);
}
#pragma mark - BrowserObserving
- (void)browserDestroyed:(Browser*)browser {
self.browser = nullptr;
}
#pragma mark - Timer Management
// Start the timer to show a promo. `self.currentPromoReason` must be set to
// the reason for this promo flow and must not be `PromoReasonNone`.
- (void)startShowPromoTimer {
DCHECK(self.currentPromoReason != PromoReasonNone);
if (![self promoCanBeDisplayed]) {
self.currentPromoReason = PromoReasonNone;
self.webStateToListenTo = nullptr;
return;
}
if (self.promoIsShowing || _showPromoTimer) {
return;
}
base::TimeDelta promoTimeInterval;
switch (self.currentPromoReason) {
case PromoReasonNone:
NOTREACHED_IN_MIGRATION();
promoTimeInterval = kShowPromoWebpageLoadWaitTime;
break;
case PromoReasonOmniboxPaste:
promoTimeInterval = kShowPromoWebpageLoadWaitTime;
break;
case PromoReasonExternalLink:
promoTimeInterval = kShowPromoWebpageLoadWaitTime;
break;
case PromoReasonShare:
promoTimeInterval = kShowPromoPostShareWaitTime;
break;
}
__weak __typeof(self) weakSelf = self;
_showPromoTimer = std::make_unique<base::OneShotTimer>();
_showPromoTimer->Start(FROM_HERE, promoTimeInterval, base::BindOnce(^{
[weakSelf showPromoTimerFinished];
}));
}
- (void)cancelShowPromoTimer {
// Only reset the reason and web state to listen to if there is no promo
// showing.
if (!self.promoIsShowing) {
self.currentPromoReason = PromoReasonNone;
self.webStateToListenTo = nullptr;
}
_showPromoTimer = nullptr;
}
- (void)showPromoTimerFinished {
if (![self promoCanBeDisplayed] || self.promoIsShowing) {
return;
}
_showPromoTimer = nullptr;
[self notifyHandlerShowPromo];
self.promoIsShowing = YES;
[self logPromoAppear:self.currentPromoReason];
self.promoShownTime = base::TimeTicks::Now();
if (!UIAccessibilityIsVoiceOverRunning()) {
[self startDismissPromoTimer];
}
}
- (void)startDismissPromoTimer {
if (_dismissPromoTimer) {
return;
}
__weak __typeof(self) weakSelf = self;
_dismissPromoTimer = std::make_unique<base::OneShotTimer>();
_dismissPromoTimer->Start(FROM_HERE, kPromoTimeout, base::BindOnce(^{
[weakSelf dismissPromoTimerFinished];
}));
}
- (void)dismissPromoTimerFinished {
_dismissPromoTimer = nullptr;
if (self.promoIsShowing) {
[self logPromoTimeout:self.currentPromoReason
promoShownTime:self.promoShownTime];
self.promoShownTime = base::TimeTicks();
[self notifyHandlerDismissPromo:YES];
}
}
@end