chromium/ios/chrome/browser/accessibility/model/window_accessibility_change_notifier_app_agent.mm

// Copyright 2020 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/accessibility/model/window_accessibility_change_notifier_app_agent.h"

#import "base/check.h"
#import "base/i18n/message_formatter.h"
#import "base/logging.h"
#import "base/strings/sys_string_conversions.h"
#import "ios/chrome/app/application_delegate/app_state.h"
#import "ios/chrome/browser/shared/coordinator/scene/scene_state.h"
#import "ios/chrome/grit/ios_branded_strings.h"
#import "ui/base/l10n/l10n_util.h"
#import "ui/base/resource/resource_bundle.h"

namespace {

// Delay between events and notification.
const NSTimeInterval kWindowNotifcationDelay = 0.5;  // seconds

}  // namespace

@interface WindowAccessibilityChangeNotifierAppAgent () <AppStateObserver,
                                                         SceneStateObserver>
// Observed app state.
@property(nonatomic, weak) AppState* appState;

@property(nonatomic, assign) NSUInteger visibleWindowCount;

// If an update is pending, `lastUpdateTime` is the last time that an event
// occurred that might cause the window count to change. If no update is pending
// `lastUpdateTime` is nil.
@property(nonatomic, strong) NSDate* lastUpdateTime;

@end

@implementation WindowAccessibilityChangeNotifierAppAgent

#pragma mark - AppStateAgent

- (void)setAppState:(AppState*)appState {
  // This should only be called once!
  DCHECK(!_appState);

  _appState = appState;
  [appState addObserver:self];
  [self updateWindowCount];
}

#pragma mark - AppStateObserver

- (void)appState:(AppState*)appState sceneConnected:(SceneState*)sceneState {
  [sceneState addObserver:self];
}

#pragma mark - SceneStateObserver

// Init stage changes are potential opportunities for dictating the window count
// to Voiceover users.
- (void)appState:(AppState*)appState
    didTransitionFromInitStage:(InitStage)previousInitStage {
  [self maybeScheduleWindowCountWithDelay:kWindowNotifcationDelay];
}

// Changes in the activation level of scene states will indicate that the count
// of visible windows has changed. Some actions (such as opening a third window
// when two are already open) can cause a scene's activation level to change
// at a time when other scenes' activation levels have not yet updated, which
// would cause notifications of incorrect window counts. To handle this type
// of change, this class instead posts notifications with a delay. If another
// notification is requested before a queued one executes, the new request is
// skipped.
- (void)sceneState:(SceneState*)sceneState
    transitionedToActivationLevel:(SceneActivationLevel)level {
  if (self.lastUpdateTime == nil) {
    [self maybeScheduleWindowCountWithDelay:kWindowNotifcationDelay];
  }
  self.lastUpdateTime = [NSDate date];
}

#pragma mark - private

- (void)maybeScheduleWindowCountWithDelay:(NSTimeInterval)delay {
  // Weakify, since the window count can change in shutdown, so there are
  // likely to be pending notifications that would otherwise keep this object
  // alive.
  if (self.appState.initStage == InitStageFinal) {
    __weak WindowAccessibilityChangeNotifierAppAgent* weakSelf = self;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
                                 static_cast<int64_t>(delay * NSEC_PER_SEC)),
                   dispatch_get_main_queue(), ^{
                     [weakSelf notifyWindowCount];
                   });
  }
}

// Performs the notification, if enough time has passed since the last update.
// If the last update was more recent than the notification delay, then the
// notification is re-posted to happen after the delay has elapsed.
- (void)notifyWindowCount {
  NSDate* now = [NSDate date];
  NSTimeInterval delta = [now timeIntervalSinceDate:self.lastUpdateTime];
  if (delta < kWindowNotifcationDelay) {
    // Repost with a delay sufficient to be `kWindowNotifcationDelay` after
    // the last update time.
    NSTimeInterval newDelta = kWindowNotifcationDelay - delta;
    [self maybeScheduleWindowCountWithDelay:newDelta];
    return;
  }

  if (!ui::ResourceBundle::HasSharedInstance()) {
    // The resources have not yet been initialized. Delay the notification.
    [self maybeScheduleWindowCountWithDelay:kWindowNotifcationDelay];
    return;
  }

  self.lastUpdateTime = nil;

  NSUInteger previousWindowCount = self.visibleWindowCount;
  [self updateWindowCount];

  // Only notify the user if (a) the window count has changed, and (b) it's
  // non-zero. A zero window count would occur, for example, when the user
  // enters the system app switcher. Other accessibility systems will notify
  // them of that change; it isn't necessary to tell them that no Chrome windows
  // are showing.
  if (previousWindowCount != self.visibleWindowCount &&
      self.visibleWindowCount > 0) {
    std::u16string pattern =
        l10n_util::GetStringUTF16(IDS_IOS_WINDOW_COUNT_CHANGE);
    int numberOfWindows = static_cast<int>(self.visibleWindowCount);
    std::u16string formattedMessage =
        base::i18n::MessageFormatter::FormatWithNamedArgs(pattern, "count",
                                                          numberOfWindows);

    NSString* windowCountNotification =
        base::SysUTF16ToNSString(formattedMessage);
    UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification,
                                    windowCountNotification);
  }
}

// Update `self.visibleWindowCount` with the total number of foregrounded
// connected scenes.
- (void)updateWindowCount {
  NSUInteger windowCount = 0;
  for (SceneState* scene in [self.appState connectedScenes]) {
    if (scene.activationLevel >= SceneActivationLevelForegroundInactive) {
      windowCount++;
    }
  }
  self.visibleWindowCount = windowCount;
}

@end