chromium/ios/chrome/browser/policy/ui_bundled/idle/idle_timeout_policy_scene_agent.mm

// Copyright 2023 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/policy/ui_bundled/idle/idle_timeout_policy_scene_agent.h"

#import <MaterialComponents/MaterialSnackbar.h>
#import <UIKit/UIKit.h>

#import "base/time/time.h"
#import "components/enterprise/idle/metrics.h"
#import "components/policy/core/common/policy_pref_names.h"
#import "components/prefs/pref_service.h"
#import "ios/chrome/app/application_delegate/app_state.h"
#import "ios/chrome/app/application_delegate/app_state_observer.h"
#import "ios/chrome/browser/enterprise/model/idle/idle_service_observer_bridge.h"
#import "ios/chrome/browser/shared/coordinator/scene/scene_ui_provider.h"
#import "ios/chrome/browser/shared/model/browser/browser.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/web_state_list.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/commands/snackbar_commands.h"
#import "ios/chrome/browser/shared/ui/util/snackbar_util.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/policy/ui_bundled/idle/constants.h"
#import "ios/chrome/browser/policy/ui_bundled/idle/idle_timeout_confirmation_coordinator.h"
#import "ios/chrome/browser/policy/ui_bundled/idle/idle_timeout_confirmation_coordinator_delegate.h"
#import "ios/chrome/browser/policy/ui_bundled/idle/idle_timeout_launch_screen_view_controller.h"
#import "ios/chrome/browser/policy/ui_bundled/idle/idle_timeout_policy_utils.h"
#import "ios/chrome/browser/ui/scoped_ui_blocker/scoped_ui_blocker.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/web/public/web_state.h"
#import "ui/base/l10n/l10n_util.h"

@interface IdleTimeoutPolicySceneAgent () <
    AppStateObserver,
    IdleServiceObserving,
    IdleTimeoutConfirmationCoordinatorDelegate>
@end

@implementation IdleTimeoutPolicySceneAgent {
  // Scoped UI blocker that blocks the other scenes/windows if the dialog is
  // shown on this scene.
  std::unique_ptr<ScopedUIBlocker> _UIBlocker;

  // Observes changes in the idle state.
  std::unique_ptr<IdleServiceObserverBridge> _idleServiceObserverBridge;

  // Browser of the main interface of the scene.
  raw_ptr<Browser> _mainBrowser;

  // SceneUIProvider that provides the scene UI objects.
  id<SceneUIProvider> _sceneUIProvider;

  // Handler for application commands.
  __weak id<ApplicationCommands> _applicationHandler;

  // Handler for application commands.
  __weak id<SnackbarCommands> _snackbarHandler;

  // Service handling IdleTimeout and IdleTimeoutActions policies.
  // IdleTimeoutPolicySceneAgents observe this service.
  raw_ptr<enterprise_idle::IdleService> _idleService;

  // Flag indicating whether this dialog is allowed to display the snackbar.
  // This is used to show the snackbar on the same scene that shows the timeout
  // confirmation dialog.
  BOOL _pendingDisplayingSnackbar;

  // Coordinator for the idle timeout confirmation dialog.
  IdleTimeoutConfirmationCoordinator* _idleTimeoutConfirmationCoordinator;

  // An extended launch screen that shows on start-up or re-foreground. The
  // windows shows on top of the browser to block the user from navigating when
  // data is cleared, and to hide the triggered UI changes when tabs are closed
  // or the user is signed out.
  UIWindow* _launchScreenWindow;
}

- (instancetype)
       initWithSceneUIProvider:(id<SceneUIProvider>)sceneUIProvider
    applicationCommandsHandler:(id<ApplicationCommands>)applicationHandler
       snackbarCommandsHandler:(id<SnackbarCommands>)snackbarHandler
                   idleService:(enterprise_idle::IdleService*)idleService
                   mainBrowser:(Browser*)mainBrowser {
  self = [super init];
  if (self) {
    _sceneUIProvider = sceneUIProvider;
    _applicationHandler = applicationHandler;
    _snackbarHandler = snackbarHandler;
    _mainBrowser = mainBrowser;
    _idleService = idleService;
  }
  return self;
}

#pragma mark - ObservingSceneAgent

- (void)setSceneState:(SceneState*)sceneState {
  [super setSceneState:sceneState];
  [self.sceneState.appState addObserver:self];
}

#pragma mark - SceneStateObserver

- (void)sceneStateDidDisableUI:(SceneState*)sceneState {
  // Tear down objects tied to the scene state before it is deleted.
  [self tearDownObservers];
  _mainBrowser = nullptr;
  [self stopIdleTimeoutConfirmationCoordinator];
}

- (void)sceneStateDidEnableUI:(SceneState*)sceneState {
  // Setup objects that need the browser UI objects before being set.
  [self setupObserver];
}

- (void)sceneStateDidHideModalOverlay:(SceneState*)sceneState {
  // Called to check if the dialog needs to be shown after a UI blocker has been
  // released. This is the case when one scene is closed while showing the
  // dialog, so any other open scene should take over showing the countdown.
  [self maybeShowIdleTimeoutConfirmationDialog];
}

- (void)sceneState:(SceneState*)sceneState
    transitionedToActivationLevel:(SceneActivationLevel)level {
  // Show or remove the launch screen based on the scene activation level.
  [self handleExtendedLaunchScreenWindowForSceneActivationLevel:level];
  // Monitor the scene activation level to consider showing the idle timeout
  // snackbar when the scene becomes active and in the foreground. This is
  // needed because the scene state might not be foregrounded yet when
  // `onIdleTimeoutActionsCompleted` is called on foreground.
  [self maybeShowPostActionSnackbar];
}

#pragma mark - IdleServiceObserving

- (void)onIdleTimeoutInForeground {
  [self maybeShowIdleTimeoutConfirmationDialog];
}

- (void)onIdleTimeoutOnStartup {
  CHECK(_idleService->IsIdleTimeoutPolicySet());
  // Any window can display the snackbar after actions run on startup or
  // reforeground. The differentiating factor in this case will be which scene
  // enters foreground first.
  _pendingDisplayingSnackbar = YES;
  [self showExtendedLaunchScreenWindow];
}

- (void)onIdleTimeoutActionsCompleted {
  [self maybeDismissExtendedLaunchScreenWindowIfDisplayed];
  [self maybeShowPostActionSnackbar];
}

- (void)onApplicationWillEnterBackground {
  CHECK(_idleService->IsIdleTimeoutPolicySet());
  [self stopIdleTimeoutConfirmationCoordinator];
  // When the app is moving to the background -> Show the launch screen. This
  // needs to be done now instead of when we are sure the app will be idle on
  // foreground. The reason is when the app is reforegrounded, there is a second
  // when the initial snapshot of the UI is shown. The screen will be removed on
  // reforeground if it is not actually needed (i.e. the browser has not timed
  // out).
  [self showExtendedLaunchScreenWindow];
}

#pragma mark - IdleTimeoutConfirmationCoordinatorDelegate

- (void)stopPresentingAndRunActionsAfterwards:(BOOL)doRunActions {
  _idleService->OnIdleTimeoutDialogPresented();
  [self stopIdleTimeoutConfirmationCoordinator];

  if (doRunActions) {
    enterprise_idle::metrics::RecordIdleTimeoutDialogEvent(
        enterprise_idle::metrics::IdleTimeoutDialogEvent::kDialogExpired);
    _pendingDisplayingSnackbar = YES;
    _idleService->RunActions();
  } else {
    enterprise_idle::metrics::RecordIdleTimeoutDialogEvent(
        enterprise_idle::metrics::IdleTimeoutDialogEvent::
            kDialogDismissedByUser);
    _pendingDisplayingSnackbar = NO;
  }
}

#pragma mark - Private

- (void)setupObserver {
  // Set observer for service status changes.
  _idleServiceObserverBridge =
      std::make_unique<IdleServiceObserverBridge>(_idleService, self);
}

- (void)tearDownObservers {
  _idleServiceObserverBridge.reset();
  [self.sceneState.appState removeObserver:self];
}

- (PrefService*)prefService {
  return _mainBrowser->GetBrowserState()->GetPrefs();
}

// Returns whether the scene and app states allow for the idle timeout snackbar
// to show if idle actions have run.
- (BOOL)isUIAvailableToShowSnackbar {
  if (self.sceneState.activationLevel < SceneActivationLevelForegroundActive) {
    // Return NO when the scene isn't visible, active, and in the foreground.
    return NO;
  }

  // Return whether this is the agent that should display the
  // snackbar. If there was a timeout dialog, only one agent would have this
  // state set.
  return _pendingDisplayingSnackbar;
}

// Shows the actions ran snackbar using the snackbar command.
- (void)maybeShowPostActionSnackbar {
  if ([self isLaunchScreenDisplayed]) {
    // The snackbar will show after the launch screen is hidden.
    return;
  }
  if (![self isUIAvailableToShowSnackbar]) {
    return;
  }

  if (!_idleService->ShouldIdleTimeoutSnackbarBePresented()) {
    // Returns if the snackbar is no longer needed and has been handled by
    // another agent which has set the flag to false.  This flag check is
    // important for the case when the actions run on startup/reforeground where
    // `onIdleTimeoutActionsCompleted` may be called before
    // `transitionedToActivationLevel` to foreground.
    return;
  }

  // It is important to get the last actions from the service because the window
  // showing the snackbar might have been opened after timeout happened. This
  // can be the case in the following scenario: Foreground 1 window -> wait till
  // dialog shows -> open another window -> Now close the window that initially
  // showed the dialog.
  std::optional<int> messageId =
      enterprise_idle::GetIdleTimeoutActionsSnackbarMessageId(
          _idleService->GetLastActionSet());
  CHECK(messageId) << "There is no snackbar message for the set of actions";
  NSString* messageText = l10n_util::GetNSString(*messageId);

  // Delay showing the snackbar message when voice over is on because other
  // elements with higher accessibility priority will cut off reading the
  // snackbar message. For example, when tabs are closed on idle timeout, the
  // snackbar message is cut off when the screen reader reads out the text on
  // the empty tab grid that got displayed, so we need to wait.
  if (UIAccessibilityIsVoiceOverRunning()) {
    __weak __typeof(self) weakSelf = self;
    base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
        FROM_HERE, base::BindOnce(^{
          [weakSelf showSnackbar:messageText];
        }),
        base::Seconds(2));
  } else {
    [self showSnackbar:messageText];
  }
  _idleService->OnIdleTimeoutSnackbarPresented();
}

- (void)showSnackbar:(NSString*)messageText {
  MDCSnackbarMessage* message = CreateSnackbarMessage(messageText);
  message.duration = kIdleTimeoutSnackbarDuration;
  message.accessibilityLabel = messageText;
  [_snackbarHandler showSnackbarMessage:message];
}

// Returns whether the scene and app states allow for the idle timeout
// confirmation dialog to be shown if it is needed.
- (BOOL)isUIAvailableToShowDialog {
  if (self.sceneState.appState.initStage < InitStageFinal) {
    // Return NO when the app isn't yet fully initialized.
    return NO;
  }

  if (self.sceneState.activationLevel < SceneActivationLevelForegroundActive) {
    // Return NO when the scene isn't visible, active, and in the foreground.
    return NO;
  }

  if (_UIBlocker != nil) {
    // Return NO if scene is already showing the dialog as there is no need to
    // reshow it with a disturbing transition.
    // This is for the case when the scene displaying the dialog is backgrounded
    // while there is another scene with a modal overlay opened. Going back to
    // the window showing the modal overlay should continue showing the same
    // dialog.
    return NO;
  }

  // Return YES if the scene is not blocked by a modal overlay.
  return !self.sceneState.presentingModalOverlay;
}

// Shows the notification dialog if these two conditions are satisfied:
// 1. the UI is available
// 2. it was never shown or if a scene displaying the dialog
// was closed and anoher foregrouded window remained open.
- (void)maybeShowIdleTimeoutConfirmationDialog {
  // Initially set the pending snackbar flag to false in case it was set on
  // startup but actions failed to complete.
  _pendingDisplayingSnackbar = NO;
  if (![self isUIAvailableToShowDialog]) {
    return;
  }

  if (!_idleService->ShouldIdleTimeoutDialogBePresented()) {
    // Skip the dialog if it has already been displayed until expiry, in which
    // case it will not be needed.
    return;
  }

  // Set the pending snackbar flag for the agent that will show the dialog then
  // show then dismiss any modals and display the dialog.
  _pendingDisplayingSnackbar = YES;
  _UIBlocker = std::make_unique<ScopedUIBlocker>(self.sceneState);
  __weak __typeof(self) weakSelf = self;
  [_applicationHandler dismissModalDialogsWithCompletion:^{
    [weakSelf showIdleTimeoutConfirmation];
  }];
}

// Shows the notification dialog for the account on the `viewController`
- (void)showIdleTimeoutConfirmation {
  [self closeMediaPresentationsIfFullScreenMode];
  _idleTimeoutConfirmationCoordinator =
      [[IdleTimeoutConfirmationCoordinator alloc]
          initWithBaseViewController:[_sceneUIProvider activeViewController]
                             browser:_mainBrowser];
  _idleTimeoutConfirmationCoordinator.delegate = self;
  _idleTimeoutConfirmationCoordinator.triggerTime =
      _idleService->GetIdleTriggerTime();
  [_idleTimeoutConfirmationCoordinator start];
  enterprise_idle::metrics::RecordIdleTimeoutDialogEvent(
      enterprise_idle::metrics::IdleTimeoutDialogEvent::kDialogShown);
}

// Dismisses the idle timeout confirmation dialog.
- (void)stopIdleTimeoutConfirmationCoordinator {
  _UIBlocker.reset();
  if (_idleTimeoutConfirmationCoordinator) {
    [_idleTimeoutConfirmationCoordinator stop];
    _idleTimeoutConfirmationCoordinator = nil;
  }
}

- (void)showExtendedLaunchScreenWindow {
  // Return if the window is already displayed. This happens when the app has
  // been backgrounded with the policy set then re-foregrounded, in which case
  // the app has the launch screen window on re-foregrounded.
  if ([self isLaunchScreenDisplayed]) {
    return;
  }

  IdleTimeoutLaunchScreenViewController* _launchScreenController =
      [[IdleTimeoutLaunchScreenViewController alloc] init];
  _launchScreenWindow =
      [[UIWindow alloc] initWithWindowScene:self.sceneState.scene];
  // The blocker is above everything, including the alerts, but below the status
  // bar.
  _launchScreenWindow.windowLevel = UIWindowLevelStatusBar - 1;
  _launchScreenWindow.rootViewController = _launchScreenController;
  [_launchScreenWindow makeKeyAndVisible];
  [self scheduleLaunchScreenDismissal];
}

- (void)maybeDismissExtendedLaunchScreenWindowIfDisplayed {
  if (![self isLaunchScreenDisplayed]) {
    // Nothing needs to be done here, so we can return.
    return;
  }

  enterprise_idle::metrics::RecordIdleTimeoutLaunchScreenEvent(
      enterprise_idle::metrics::IdleTimeoutLaunchScreenEvent::
          kLaunchScreenDismissedAfterActionCompletion);

  if (!_idleService->GetLastActionSet().close) {
    // Dismiss right away if tabs will not be closing, which is often delayed.
    [self dismissExtendedLaunchScreenWindowIfDisplayed];
    return;
  }

  // Remove after 1 more second to give the UI enough time to update behind the
  // screen after actions have run. If the screen is dimssed right away, the
  // tabs will be seen closing.
  __weak __typeof(self) weakSelf = self;
  base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
      FROM_HERE, base::BindOnce(^{
        [weakSelf dismissExtendedLaunchScreenWindowIfDisplayed];
        [weakSelf maybeShowPostActionSnackbar];
      }),
      base::Seconds(1));
}

- (void)dismissExtendedLaunchScreenWindowIfDisplayed {
  if (![self isLaunchScreenDisplayed]) {
    // Nothing needs to be done here, so we can return.
    return;
  }

  _launchScreenWindow = nil;
  [self.sceneState.window makeKeyAndVisible];
}

- (BOOL)isLaunchScreenDisplayed {
  return _launchScreenWindow != nil;
}

- (void)handleExtendedLaunchScreenWindowForSceneActivationLevel:
    (SceneActivationLevel)level {
  // When the backgrounded app is moving back to the foreground -> dismiss the
  // launch screen if the browser did not timeout. Otherwise keep it, and it
  // will be dismissed later when the service completes running the idle
  // actions, or when it has exceeded 5 seconds.
  bool isReforegroundedWithLaunchScreen =
      (level == SceneActivationLevelForegroundInactive) && _launchScreenWindow;
  if (isReforegroundedWithLaunchScreen) {
    if (!_idleService->IsIdleAfterPreviouslyBeingActive()) {
      [self dismissExtendedLaunchScreenWindowIfDisplayed];
    } else {
      [self scheduleLaunchScreenDismissal];
    }
  }
}

- (void)scheduleLaunchScreenDismissal {
  // Set a deadline for the dismissal of the launch screen so the user never
  // waits indefinitely.
  __weak __typeof(self) weakSelf = self;
  base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
      FROM_HERE, base::BindOnce(^{
        enterprise_idle::metrics::RecordIdleTimeoutLaunchScreenEvent(
            enterprise_idle::metrics::IdleTimeoutLaunchScreenEvent::
                kLaunchScreenExpired);
        [weakSelf dismissExtendedLaunchScreenWindowIfDisplayed];
      }),
      base::Seconds(5));
}

// Closes the media presentations to avoid having the fullscreen video on top of
// the dialog so the user does not miss the dialog if they are watching a video.
- (void)closeMediaPresentationsIfFullScreenMode {
  Browser* currentBrowser =
      self.sceneState.browserProviderInterface.currentBrowserProvider.browser;
  CHECK(currentBrowser);
  web::WebState* activeWebState =
      currentBrowser->GetWebStateList()->GetActiveWebState();
  if (activeWebState) {
    activeWebState->CloseMediaPresentations();
  }
}

@end