chromium/remoting/ios/app/client_connection_view_controller.mm

// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "remoting/ios/app/client_connection_view_controller.h"

#import <MaterialComponents/MDCActivityIndicator.h>
#import <MaterialComponents/MDCNavigationBar.h>
#import <MaterialComponents/MaterialButtons.h>
#import <MaterialComponents/MaterialCollections.h>
#import <MaterialComponents/MaterialSnackbar.h>

#include "base/logging.h"
#include "base/strings/sys_string_conversions.h"
#include "remoting/base/string_resources.h"
#import "remoting/ios/app/help_and_feedback.h"
#import "remoting/ios/app/host_view_controller.h"
#import "remoting/ios/app/pin_entry_view.h"
#import "remoting/ios/app/remoting_theme.h"
#import "remoting/ios/app/session_reconnect_view.h"
#import "remoting/ios/app/view_utils.h"
#import "remoting/ios/domain/client_session_details.h"
#import "remoting/ios/domain/host_info.h"
#import "remoting/ios/facade/remoting_authentication.h"
#import "remoting/ios/facade/remoting_service.h"
#import "remoting/ios/session/remoting_client.h"
#include "remoting/protocol/client_authentication_config.h"
#include "ui/base/l10n/l10n_util.h"

static const CGFloat kIconRadius = 30.f;
static const CGFloat kActivityIndicatorStrokeWidth = 3.f;
static const CGFloat kActivityIndicatorRadius = 33.f;

static const CGFloat kPinEntryViewWidth = 240.f;
static const CGFloat kPinEntryViewHeight = 90.f;

static const CGFloat kReconnectViewWidth = 240.f;
static const CGFloat kReconnectViewHeight = 90.f;

static const CGFloat kPadding = 20.f;
static const CGFloat kMargin = 20.f;

static const CGFloat kKeyboardAnimationTime = 0.3;

static NSString* const kConnectionErrorFeedbackContext =
    @"ConnectionErrorFeedbackContext";

using EntryPoint = remoting::ChromotingEvent::SessionEntryPoint;

@interface ClientConnectionViewController ()<PinEntryDelegate,
                                             SessionReconnectViewDelegate> {
  UIImageView* _iconView;
  MDCActivityIndicator* _activityIndicator;
  NSLayoutConstraint* _activityIndicatorTopConstraintFull;
  NSLayoutConstraint* _activityIndicatorTopConstraintKeyboard;
  UILabel* _statusLabel;
  MDCNavigationBar* _navBar;
  PinEntryView* _pinEntryView;
  SessionReconnectView* _reconnectView;
  NSString* _remoteHostName;
  RemotingClient* _client;
  SessionErrorCode _lastError;
  HostInfo* _hostInfo;
  BOOL _hasViewAppeared;
}

@property(nonatomic, assign) SessionErrorCode lastError;

@end

@implementation ClientConnectionViewController

@synthesize state = _state;
@synthesize lastError = _lastError;

- (instancetype)initWithHostInfo:(HostInfo*)hostInfo {
  self = [super init];
  if (self) {
    _hostInfo = hostInfo;
    _remoteHostName = hostInfo.hostName;
    _hasViewAppeared = NO;

    // TODO(yuweih): This logic may be reused by other views.
    UIButton* cancelButton = [UIButton buttonWithType:UIButtonTypeSystem];
    [cancelButton setTitle:l10n_util::GetNSString(IDS_CANCEL).uppercaseString
                  forState:UIControlStateNormal];
    [cancelButton
        setImage:[RemotingTheme
                         .backIcon imageFlippedForRightToLeftLayoutDirection]
        forState:UIControlStateNormal];
    [cancelButton addTarget:self
                     action:@selector(didTapCancel:)
           forControlEvents:UIControlEventTouchUpInside];
    self.navigationItem.leftBarButtonItem =
        [[UIBarButtonItem alloc] initWithCustomView:cancelButton];

    _navBar = [[MDCNavigationBar alloc] initWithFrame:CGRectZero];
    [_navBar observeNavigationItem:self.navigationItem];

    [_navBar setBackgroundColor:RemotingTheme.connectionViewBackgroundColor];
    MDCNavigationBarTextColorAccessibilityMutator* mutator =
        [[MDCNavigationBarTextColorAccessibilityMutator alloc] init];
    [mutator mutate:_navBar];
    [self.view addSubview:_navBar];
    _navBar.translatesAutoresizingMaskIntoConstraints = NO;

    // Attach navBar to the top of the view.
    UILayoutGuide* layoutGuide =
        remoting::SafeAreaLayoutGuideForView(self.view);
    [NSLayoutConstraint activateConstraints:@[
      [_navBar.topAnchor constraintEqualToAnchor:layoutGuide.topAnchor],
      [_navBar.leadingAnchor constraintEqualToAnchor:layoutGuide.leadingAnchor],
      [_navBar.trailingAnchor
          constraintEqualToAnchor:layoutGuide.trailingAnchor],
    ]];
  }
  return self;
}

- (void)dealloc {
  [[NSNotificationCenter defaultCenter] removeObserver:self];
}

#pragma mark - UIViewController

- (void)viewDidLoad {
  [super viewDidLoad];
  self.view.backgroundColor = RemotingTheme.connectionViewBackgroundColor;

  _activityIndicator = [[MDCActivityIndicator alloc] initWithFrame:CGRectZero];
  _activityIndicator.radius = kActivityIndicatorRadius;
  _activityIndicator.trackEnabled = YES;
  _activityIndicator.strokeWidth = kActivityIndicatorStrokeWidth;
  _activityIndicator.cycleColors =
      @[ RemotingTheme.connectionViewForegroundColor ];
  _activityIndicator.translatesAutoresizingMaskIntoConstraints = NO;
  [self.view addSubview:_activityIndicator];

  _statusLabel = [[UILabel alloc] initWithFrame:CGRectZero];
  _statusLabel.numberOfLines = 1;
  _statusLabel.lineBreakMode = NSLineBreakByTruncatingTail;
  _statusLabel.textColor = RemotingTheme.connectionViewForegroundColor;
  _statusLabel.textAlignment = NSTextAlignmentCenter;
  _statusLabel.translatesAutoresizingMaskIntoConstraints = NO;
  [self.view addSubview:_statusLabel];

  _iconView = [[UIImageView alloc] initWithFrame:CGRectZero];
  _iconView.contentMode = UIViewContentModeCenter;
  _iconView.alpha = 0.87f;
  _iconView.backgroundColor = RemotingTheme.hostOnlineColor;
  _iconView.layer.cornerRadius = kIconRadius;
  _iconView.layer.masksToBounds = YES;
  _iconView.image = RemotingTheme.desktopIcon;
  _iconView.translatesAutoresizingMaskIntoConstraints = NO;
  [self.view addSubview:_iconView];

  _reconnectView = [[SessionReconnectView alloc] initWithFrame:CGRectZero];
  _reconnectView.hidden = YES;
  _reconnectView.translatesAutoresizingMaskIntoConstraints = NO;
  [self.view addSubview:_reconnectView];
  _reconnectView.delegate = self;

  _pinEntryView = [[PinEntryView alloc] init];
  _pinEntryView.hidden = YES;
  _pinEntryView.translatesAutoresizingMaskIntoConstraints = NO;
  [self.view addSubview:_pinEntryView];
  _pinEntryView.delegate = self;

  [self
      initializeLayoutConstraintsWithViews:NSDictionaryOfVariableBindings(
                                               _activityIndicator, _statusLabel,
                                               _iconView, _reconnectView,
                                               _pinEntryView)];

  [[NSNotificationCenter defaultCenter]
      addObserver:self
         selector:@selector(hostSessionStatusChanged:)
             name:kHostSessionStatusChanged
           object:nil];

  [self attemptConnectionToHostWithEntryPoint:EntryPoint::CONNECT_BUTTON];

  // Although keyboard listeners are registered here, they won't work properly
  // if the keyboard shows/hides before the view appears.
  [[NSNotificationCenter defaultCenter]
      addObserver:self
         selector:@selector(keyboardWillShow:)
             name:UIKeyboardWillShowNotification
           object:nil];

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

- (void)initializeLayoutConstraintsWithViews:(NSDictionary*)views {
  // Metrics to use in visual format strings.
  NSDictionary* layoutMetrics = @{
    @"padding" : @(kPadding),
    @"margin" : @(kMargin),
    @"iconDiameter" : @(kIconRadius * 2),
    @"pinEntryViewWidth" : @(kPinEntryViewWidth),
    @"pinEntryViewHeight" : @(kPinEntryViewHeight),
    @"reconnectViewWidth" : @(kReconnectViewWidth),
    @"reconnectViewHeight" : @(kReconnectViewHeight),
  };
  [_activityIndicator sizeToFit];
  NSString* f;

  // Horizontal constraints:
  [self.view addConstraints:
                 [NSLayoutConstraint
                     constraintsWithVisualFormat:@"H:[_iconView(iconDiameter)]"
                                         options:0
                                         metrics:layoutMetrics
                                           views:views]];

  [self.view addConstraints:[NSLayoutConstraint
                                constraintsWithVisualFormat:
                                    @"H:|-margin-[_statusLabel]-margin-|"
                                                    options:0
                                                    metrics:layoutMetrics
                                                      views:views]];

  [self.view addConstraints:[NSLayoutConstraint
                                constraintsWithVisualFormat:
                                    @"H:[_pinEntryView(pinEntryViewWidth)]"
                                                    options:0
                                                    metrics:layoutMetrics
                                                      views:views]];

  [self.view addConstraints:[NSLayoutConstraint
                                constraintsWithVisualFormat:
                                    @"H:[_reconnectView(reconnectViewWidth)]"
                                                    options:0
                                                    metrics:layoutMetrics
                                                      views:views]];

  // Anchors:
  _activityIndicatorTopConstraintFull = [_activityIndicator.bottomAnchor
      constraintEqualToAnchor:self.view.centerYAnchor];
  _activityIndicatorTopConstraintFull.active = YES;

  [_iconView.centerYAnchor
      constraintEqualToAnchor:_activityIndicator.centerYAnchor]
      .active = YES;

  // Vertical constraints:
  [self.view addConstraints:
                 [NSLayoutConstraint
                     constraintsWithVisualFormat:@"V:[_iconView(iconDiameter)]"
                                         options:0
                                         metrics:layoutMetrics
                                           views:views]];

  [self.view addConstraints:
                 [NSLayoutConstraint
                     constraintsWithVisualFormat:
                         @"V:[_activityIndicator]-(padding)-[_statusLabel]"
                                         options:NSLayoutFormatAlignAllCenterX
                                         metrics:layoutMetrics
                                           views:views]];

  [self.view addConstraints:
                 [NSLayoutConstraint
                     constraintsWithVisualFormat:
                         @"V:[_iconView]-(padding)-[_statusLabel]"
                                         options:NSLayoutFormatAlignAllCenterX
                                         metrics:layoutMetrics
                                           views:views]];

  f = @"V:[_statusLabel]-(padding)-[_pinEntryView(pinEntryViewHeight)]";
  [self.view addConstraints:
                 [NSLayoutConstraint
                     constraintsWithVisualFormat:f
                                         options:NSLayoutFormatAlignAllCenterX
                                         metrics:layoutMetrics
                                           views:views]];

  f = @"V:[_statusLabel]-padding-[_reconnectView(reconnectViewHeight)]";
  [self.view addConstraints:
                 [NSLayoutConstraint
                     constraintsWithVisualFormat:f
                                         options:NSLayoutFormatAlignAllCenterX
                                         metrics:layoutMetrics
                                           views:views]];

  [self.view setNeedsUpdateConstraints];
}

- (void)viewWillAppear:(BOOL)animated {
  [super viewWillAppear:animated];
  [self.navigationController setNavigationBarHidden:YES animated:animated];
}

- (void)viewDidAppear:(BOOL)animated {
  [super viewDidAppear:animated];
  [_activityIndicator startAnimating];

  _hasViewAppeared = YES;

  self.state = _state;
}

- (void)viewWillDisappear:(BOOL)animated {
  [super viewWillDisappear:animated];
  [_activityIndicator stopAnimating];
}

- (BOOL)prefersStatusBarHidden {
  return YES;
}

#pragma mark - Keyboard

- (void)keyboardWillShow:(NSNotification*)notification {
  CGSize keyboardSize =
      [[[notification userInfo] objectForKey:UIKeyboardFrameEndUserInfoKey]
          CGRectValue]
          .size;

  CGFloat newHeight = self.view.frame.size.height - keyboardSize.height;
  CGFloat overlap = newHeight - (_pinEntryView.frame.origin.y +
                                 _pinEntryView.frame.size.height + kPadding);
  if (overlap > 0) {
    overlap = 0;
  }
  _activityIndicatorTopConstraintKeyboard.active = NO;
  _activityIndicatorTopConstraintKeyboard = [_activityIndicator.topAnchor
      constraintEqualToAnchor:self.view.topAnchor
                     constant:_activityIndicator.frame.origin.y + overlap];
  _activityIndicatorTopConstraintFull.active = NO;
  _activityIndicatorTopConstraintKeyboard.active = YES;
  [UIView animateWithDuration:kKeyboardAnimationTime
                   animations:^{
                     [self.view layoutIfNeeded];
                   }];
}

- (void)keyboardWillHide:(NSNotification*)notification {
  _activityIndicatorTopConstraintKeyboard.active = NO;
  _activityIndicatorTopConstraintFull.active = YES;
  [UIView animateWithDuration:kKeyboardAnimationTime
                   animations:^{
                     [self.view layoutIfNeeded];
                   }];
}

#pragma mark - Properties

- (void)setState:(ClientConnectionViewState)state {
  _state = state;
  if (!_hasViewAppeared) {
    // Showing different state will re-layout the view, which will be broken if
    // the view is not shown yet.
    return;
  }
  switch (_state) {
    case ClientViewConnecting:
      [self showConnectingState];
      break;
    case ClientViewPinPrompt:
      [self showPinPromptState];
      break;
    case ClientViewConnected:
      [self showConnectedState];
      break;
    case ClientViewReconnect:
      [self showReconnect];
      break;
    case ClientViewClosed:
      [self.navigationController popToRootViewControllerAnimated:YES];
      break;
    case ClientViewError:
      [self showError];
      break;
  }
}

#pragma mark - SessionReconnectViewDelegate

- (void)didTapReconnect {
  [self attemptConnectionToHostWithEntryPoint:EntryPoint::RECONNECT_BUTTON];
}

- (void)didTapReport {
  [_client createFeedbackDataWithCallback:^(
               const remoting::FeedbackData& feedbackData) {
    [HelpAndFeedback.instance
        presentFeedbackFlowWithContext:kConnectionErrorFeedbackContext
                          feedbackData:feedbackData];
  }];
}

#pragma mark - Private

- (void)attemptConnectionToHostWithEntryPoint:(EntryPoint)entryPoint {
  _client = [[RemotingClient alloc] init];
  __weak ClientConnectionViewController* weakSelf = self;
  __weak RemotingClient* weakClient = _client;
  __weak HostInfo* weakHostInfo = _hostInfo;
  [RemotingService.instance.authentication
      callbackWithAccessToken:^(RemotingAuthenticationStatus status,
                                NSString* userEmail, NSString* accessToken) {
        if (status == RemotingAuthenticationStatusSuccess) {
          [weakClient connectToHost:weakHostInfo
                           username:userEmail
                        accessToken:accessToken
                         entryPoint:entryPoint];
        } else {
          LOG(ERROR) << "Failed to fetch access token for connectToHost. ("
                     << status << ")";
          weakSelf.lastError = SessionErrorOAuthTokenInvalid;
          weakSelf.state = ClientViewError;
        }
      }];
  self.state = ClientViewConnecting;
}

- (void)showConnectingState {
  [_pinEntryView endEditing:YES];
  _statusLabel.text =
      [self stringWithHostNameForId:IDS_CONNECTING_TO_HOST_MESSAGE];
  [self focusOnStatusLabel];

  _pinEntryView.hidden = YES;

  _reconnectView.hidden = YES;

  _iconView.backgroundColor = RemotingTheme.hostOnlineColor;

  [_activityIndicator stopAnimating];
  _activityIndicator.cycleColors =
      @[ RemotingTheme.connectionViewForegroundColor ];
  _activityIndicator.indicatorMode = MDCActivityIndicatorModeIndeterminate;
  _activityIndicator.hidden = NO;
  [_activityIndicator startAnimating];
}

- (void)showPinPromptState {
  _statusLabel.text = [NSString stringWithFormat:@"%@", _remoteHostName];

  _iconView.backgroundColor = RemotingTheme.hostOnlineColor;

  [_activityIndicator stopAnimating];
  _activityIndicator.hidden = YES;

  _pinEntryView.hidden = NO;

  _reconnectView.hidden = YES;

  _reconnectView.hidden = YES;

  // TODO(yuweih): This may be called before viewDidAppear and miss the keyboard
  // callback.
  [_pinEntryView becomeFirstResponder];
}

- (void)showConnectedState {
  [_pinEntryView endEditing:YES];
  _statusLabel.text =
      [self stringWithHostNameForId:IDS_CONNECTED_TO_HOST_MESSAGE];
  [self focusOnStatusLabel];

  _pinEntryView.hidden = YES;
  [_pinEntryView clearPinEntry];

  _iconView.backgroundColor = RemotingTheme.hostOnlineColor;

  _activityIndicator.progress = 0.0;
  _activityIndicator.hidden = NO;
  _activityIndicator.indicatorMode = MDCActivityIndicatorModeDeterminate;
  _activityIndicator.cycleColors = @[ RemotingTheme.hostOnlineColor ];
  [_activityIndicator startAnimating];
  _activityIndicator.progress = 1.0;

  _reconnectView.hidden = YES;

  _reconnectView.hidden = YES;

  HostViewController* hostViewController =
      [[HostViewController alloc] initWithClient:_client];

  [self.navigationController pushViewController:hostViewController animated:NO];
}

// TODO(yuweih): Unused. Remove this method and the ClientViewReconnect enum.
- (void)showReconnect {
  _statusLabel.text =
      [self stringWithHostNameForId:IDS_CONNECTION_CLOSED_FOR_HOST_MESSAGE];
  [self focusOnStatusLabel];

  _iconView.backgroundColor = RemotingTheme.hostErrorColor;

  [_activityIndicator stopAnimating];
  _activityIndicator.hidden = YES;

  _pinEntryView.hidden = YES;

  _reconnectView.hidden = NO;
  _reconnectView.errorText =
      l10n_util::GetNSString(IDS_MESSAGE_SESSION_FINISHED);

  [self.navigationController popToViewController:self animated:YES];
}

- (void)showError {
  // Error may happen after the session is connected. In this case we should
  // pop back to the client connection VC.
  if (self.navigationController.topViewController != self) {
    [self.navigationController popToViewController:self animated:YES];
  }

  _statusLabel.text =
      [self stringWithHostNameForId:IDS_ERROR_CONNECTING_TO_HOST_MESSAGE];

  _pinEntryView.hidden = YES;

  _iconView.backgroundColor = RemotingTheme.hostErrorColor;

  _activityIndicator.hidden = YES;

  NSString* message = nil;
  switch (_lastError) {
    case SessionErrorOk:
      // Do nothing.
      break;
    case SessionErrorPeerIsOffline:
      message = l10n_util::GetNSString(IDS_ERROR_HOST_IS_OFFLINE);
      break;
    case SessionErrorSessionRejected:
      message = l10n_util::GetNSString(IDS_ERROR_INVALID_ACCOUNT);
      break;
    case SessionErrorIncompatibleProtocol:
      message = l10n_util::GetNSString(IDS_ERROR_INCOMPATIBLE_PROTOCOL);
      break;
    case SessionErrorAuthenticationFailed:
      message = l10n_util::GetNSString(IDS_ERROR_INVALID_ACCESS_CODE);
      [_pinEntryView clearPinEntry];
      break;
    case SessionErrorInvalidAccount:
      message = l10n_util::GetNSString(IDS_ERROR_INVALID_ACCOUNT);
      break;
    case SessionErrorChannelConnectionError:
      message = l10n_util::GetNSString(IDS_ERROR_NETWORK_FAILURE);
      break;
    case SessionErrorSignalingError:
      message = l10n_util::GetNSString(IDS_ERROR_P2P_FAILURE);
      break;
    case SessionErrorSignalingTimeout:
      message = l10n_util::GetNSString(IDS_ERROR_HOST_IS_OFFLINE);
      break;
    case SessionErrorHostOverload:
      message = l10n_util::GetNSString(IDS_ERROR_HOST_OVERLOAD);
      break;
    case SessionErrorMaxSessionLength:
      message = l10n_util::GetNSString(IDS_ERROR_MAX_SESSION_LENGTH);
      break;
    case SessionErrorHostConfigurationError:
      message = l10n_util::GetNSString(IDS_ERROR_HOST_CONFIGURATION_ERROR);
      break;
    case SessionErrorUnknownError:
      message = l10n_util::GetNSString(IDS_ERROR_UNEXPECTED);
      break;
    case SessionErrorOAuthTokenInvalid:
      message = l10n_util::GetNSString(IDS_ERROR_OAUTH_TOKEN_INVALID);
      break;
    case SessionErrorThirdPartyAuthNotSupported:
      message = l10n_util::GetNSString(IDS_THIRD_PARTY_AUTH_NOT_SUPPORTED);
      break;
  }
  if (message) {
    _reconnectView.errorText = message;
  }
  _reconnectView.hidden = NO;
  remoting::SetAccessibilityFocusElement(_reconnectView);
}

- (void)didProvidePin:(NSString*)pin createPairing:(BOOL)createPairing {
  [[NSNotificationCenter defaultCenter]
      postNotificationName:kHostSessionPinProvided
                    object:self
                  userInfo:@{
                    kHostSessionHostName : _remoteHostName,
                    kHostSessionPin : pin,
                    kHostSessionCreatePairing : @(createPairing)
                  }];
}

- (void)didTapCancel:(id)sender {
  _client = nil;
  [self.navigationController popViewControllerAnimated:YES];
}

- (void)hostSessionStatusChanged:(NSNotification*)notification {
  NSLog(@"hostSessionStatusChanged: %@", [notification userInfo]);
  ClientConnectionViewState state;
  ClientSessionDetails* sessionDetails =
      [[notification userInfo] objectForKey:kSessionDetails];
  switch (sessionDetails.state) {
    case SessionInitializing:
    // Same as HostConnecting in UI. Fall-though.
    case SessionAuthenticated:
    // Same as HostConnecting in UI. Fall-though.
    case SessionConnecting:
      state = ClientViewConnecting;
      break;
    case SessionPinPrompt:
      _pinEntryView.supportsPairing = [[[notification userInfo]
          objectForKey:kSessionSupportsPairing] boolValue];
      state = ClientViewPinPrompt;
      break;
    case SessionConnected:
      state = ClientViewConnected;
      break;
    case SessionFailed:
      state = ClientViewError;
      break;
    case SessionClosed:
      // If the session is closed by the host, just go back to the host list and
      // show a toast.
      state = ClientViewClosed;
      [MDCSnackbarManager.defaultManager
          showMessage:[MDCSnackbarMessage
                          messageWithText:l10n_util::GetNSString(
                                              IDS_MESSAGE_SESSION_FINISHED)]];
      break;
    default:
      LOG(ERROR) << "Unknown State for Session, " << sessionDetails.state;
      return;
  }
  _lastError = sessionDetails.error;
  [[NSOperationQueue mainQueue] addOperationWithBlock:^{
    self.state = state;
  }];
}

- (NSString*)stringWithHostNameForId:(int)messageId {
  return l10n_util::GetNSStringF(messageId,
                                 base::SysNSStringToUTF16(_remoteHostName));
}

- (void)focusOnStatusLabel {
  remoting::SetAccessibilityFocusElement(_statusLabel);
}

@end