chromium/ios/chrome/browser/ui/scanner/scanner_view_controller.mm

// Copyright 2019 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/ui/scanner/scanner_view_controller.h"

#import <AVFoundation/AVFoundation.h>

#import "base/logging.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "ios/chrome/browser/shared/public/commands/load_query_commands.h"
#import "ios/chrome/browser/ui/scanner/scanner_alerts.h"
#import "ios/chrome/browser/ui/scanner/scanner_presenting.h"
#import "ios/chrome/browser/ui/scanner/scanner_transitioning_delegate.h"
#import "ios/chrome/browser/ui/scanner/scanner_view.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/l10n/l10n_util.h"

using base::UserMetricsAction;

@interface ScannerViewController ()

@property(nonatomic, readwrite, weak) id<LoadQueryCommands> queryLoader;

@end

@implementation ScannerViewController

#pragma mark - lifecycle

- (instancetype)initWithPresentationProvider:
    (id<ScannerPresenting>)presentationProvider {
  self = [super initWithNibName:nil bundle:nil];
  if (self) {
    _presentationProvider = presentationProvider;
  }
  return self;
}

#pragma mark - UIAccessibilityAction

- (BOOL)accessibilityPerformEscape {
  [self dismissForReason:scannerViewController::CLOSE_BUTTON
          withCompletion:nil];
  return YES;
}

#pragma mark - UIViewController

- (void)viewDidLoad {
  [super viewDidLoad];
  DCHECK(self.cameraController);

  [self.view addSubview:self.scannerView];

  // Constraints for `self.scannerView`.
  [self.scannerView setTranslatesAutoresizingMaskIntoConstraints:NO];
  [NSLayoutConstraint activateConstraints:@[
    [[self.scannerView leadingAnchor]
        constraintEqualToAnchor:[self.view leadingAnchor]],
    [[self.scannerView trailingAnchor]
        constraintEqualToAnchor:[self.view trailingAnchor]],
    [[self.scannerView topAnchor]
        constraintEqualToAnchor:[self.view topAnchor]],
    [[self.scannerView bottomAnchor]
        constraintEqualToAnchor:[self.view bottomAnchor]],
  ]];

  AVCaptureVideoPreviewLayer* previewLayer = [self.scannerView previewLayer];

  switch ([self.cameraController authorizationStatus]) {
    case AVAuthorizationStatusNotDetermined:
      [self.cameraController
          requestAuthorizationAndLoadCaptureSession:previewLayer];
      break;
    case AVAuthorizationStatusAuthorized:
      [self.cameraController loadCaptureSession:previewLayer];
      break;
    case AVAuthorizationStatusRestricted:
    case AVAuthorizationStatusDenied:
      // If this happens, then the user is really unlucky:
      // The authorization status changed in between the moment this VC was
      // instantiated and presented, and the moment viewDidLoad was called.
      [self dismissForReason:scannerViewController::
                                 IMPOSSIBLY_UNLIKELY_AUTHORIZATION_CHANGE
              withCompletion:nil];
      break;
  }
}

- (void)viewWillAppear:(BOOL)animated {
  [super viewWillAppear:animated];
  [self startReceivingNotifications];
  [self.cameraController startRecording];

  // Reset torch.
  [self setTorchMode:AVCaptureTorchModeOff];
}

- (void)viewWillTransitionToSize:(CGSize)size
       withTransitionCoordinator:
           (id<UIViewControllerTransitionCoordinator>)coordinator {
  [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
  CGFloat epsilon = 0.0001;
  // Note: targetTransform is always either identity or a 90, -90, or 180 degree
  // rotation.
  CGAffineTransform targetTransform = coordinator.targetTransform;
  CGFloat angle = atan2f(targetTransform.b, targetTransform.a);
  if (fabs(angle) > epsilon) {
    // Rotate the preview in the opposite direction of the interface rotation
    // and add a small value to the angle to force the rotation to occur in the
    // correct direction when rotating by 180 degrees.
    void (^animationBlock)(id<UIViewControllerTransitionCoordinatorContext>) =
        ^void(id<UIViewControllerTransitionCoordinatorContext> context) {
          [self.scannerView rotatePreviewByAngle:(epsilon - angle)];
        };
    // Note: The completion block is called even if the animation is
    // interrupted, for example by pressing the home button, with the same
    // target transform as the animation block.
    void (^completionBlock)(id<UIViewControllerTransitionCoordinatorContext>) =
        ^void(id<UIViewControllerTransitionCoordinatorContext> context) {
          [self.scannerView finishPreviewRotation];
        };
    [coordinator animateAlongsideTransition:animationBlock
                                 completion:completionBlock];
  } else if (!CGSizeEqualToSize(self.view.frame.size, size)) {
    // Reset the size of the preview if the bounds of the view controller
    // changed. This can happen if entering or leaving Split View mode on iPad.
    [self.scannerView resetPreviewFrame:size];
    [self.cameraController
        resetVideoOrientation:[self.scannerView previewLayer]];
  }
}

- (void)viewDidDisappear:(BOOL)animated {
  [super viewDidDisappear:animated];
  [self.cameraController stopRecording];
  [self stopReceivingNotifications];

  // Reset torch.
  [self setTorchMode:AVCaptureTorchModeOff];
}

- (BOOL)prefersStatusBarHidden {
  return YES;
}

#pragma mark - public methods

- (void)dismissForReason:(scannerViewController::DismissalReason)reason
          withCompletion:(void (^)(void))completion {
  [self.presentationProvider dismissScannerViewController:self
                                               completion:completion];
}

- (ScannerView*)buildScannerView {
  NOTIMPLEMENTED();
  return nil;
}

- (CameraController*)buildCameraController {
  NOTIMPLEMENTED();
  return nil;
}

#pragma mark - private methods

// Starts receiving notifications about the UIApplication going to background.
- (void)startReceivingNotifications {
  [[NSNotificationCenter defaultCenter]
      addObserver:self
         selector:@selector(handleUIApplicationWillResignActiveNotification)
             name:UIApplicationWillResignActiveNotification
           object:nil];

  [[NSNotificationCenter defaultCenter]
      addObserver:self
         selector:@selector
         (handleUIAccessibilityAnnouncementDidFinishNotification:)
             name:UIAccessibilityAnnouncementDidFinishNotification
           object:nil];
}

// Stops receiving all notifications.
- (void)stopReceivingNotifications {
  [[NSNotificationCenter defaultCenter] removeObserver:self];
}

// Requests the torch mode to be set to `mode` by the `self.cameraController`
// and the icon of the torch button to be changed by the `self.scannerView`.
- (void)setTorchMode:(AVCaptureTorchMode)mode {
  [self.cameraController setTorchMode:mode];
}

- (ScannerView*)scannerView {
  if (!_scannerView) {
    _scannerView = [self buildScannerView];
  }
  return _scannerView;
}

- (CameraController*)cameraController {
  if (!_cameraController) {
    _cameraController = [self buildCameraController];
  }
  return _cameraController;
}

#pragma mark - notification handlers

- (void)handleUIApplicationWillResignActiveNotification {
  [self setTorchMode:AVCaptureTorchModeOff];
}

- (void)handleUIAccessibilityAnnouncementDidFinishNotification:
    (NSNotification*)notification {
  NSString* announcement = [[notification userInfo]
      valueForKey:UIAccessibilityAnnouncementKeyStringValue];
  if ([announcement
          isEqualToString:
              l10n_util::GetNSString(
                  IDS_IOS_SCANNER_SCANNED_ACCESSIBILITY_ANNOUNCEMENT)]) {
    DCHECK(_result);
    __weak ScannerViewController* weakSelf = self;
    [self dismissForReason:scannerViewController::SCAN_COMPLETE
            withCompletion:^{
              [weakSelf dismissForReasonCompletion];
            }];
  }
}

- (void)dismissForReasonCompletion {
  [self.queryLoader loadQuery:_result immediately:self.loadResultImmediately];
}

#pragma mark - CameraControllerDelegate

- (void)captureSessionIsConnected {
  [self.cameraController setViewport:[self.scannerView viewportRectOfInterest]];
}

- (void)cameraStateChanged:(scanner::CameraState)state {
  switch (state) {
    case scanner::CAMERA_AVAILABLE:
      // Dismiss any presented alerts.
      if ([self presentedViewController]) {
        [self dismissViewControllerAnimated:YES completion:nil];
      }
      break;
    case scanner::CAMERA_IN_USE_BY_ANOTHER_APPLICATION:
    case scanner::MULTIPLE_FOREGROUND_APPS:
    case scanner::CAMERA_PERMISSION_DENIED:
    case scanner::CAMERA_UNAVAILABLE_DUE_TO_SYSTEM_PRESSURE:
    case scanner::CAMERA_UNAVAILABLE: {
      // Dismiss any presented alerts.
      if ([self presentedViewController]) {
        [self dismissViewControllerAnimated:YES completion:nil];
      }
      [self presentViewController:
                scanner::DialogForCameraState(
                    state,
                    ^(UIAlertAction*) {
                      [self dismissForReason:scannerViewController::ERROR_DIALOG
                              withCompletion:nil];
                    })
                         animated:YES
                       completion:nil];
      break;
    }
    case scanner::CAMERA_NOT_LOADED:
      NOTREACHED_IN_MIGRATION();
      break;
  }
}

- (void)torchStateChanged:(BOOL)torchIsOn {
  [self.scannerView setTorchButtonTo:torchIsOn];
}

- (void)torchAvailabilityChanged:(BOOL)torchIsAvailable {
  [self.scannerView enableTorchButton:torchIsAvailable];
}

#pragma mark - ScannerViewDelegate

- (void)dismissScannerView:(id)sender {
  [self dismissForReason:scannerViewController::CLOSE_BUTTON
          withCompletion:nil];
}

- (void)toggleTorch:(id)sender {
  if ([self.cameraController isTorchActive]) {
    [self setTorchMode:AVCaptureTorchModeOff];
  } else {
    base::RecordAction(UserMetricsAction("MobileQRScannerTorchOn"));
    [self setTorchMode:AVCaptureTorchModeOn];
  }
}

@end