[go: nahoru, domu]

blob: c55970b5afff5a9516d7e1aea251a97b00481bce [file] [log] [blame]
// Copyright 2022 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/autofill/card_unmask_prompt_view_controller.h"
#import "base/apple/foundation_util.h"
#import "base/strings/sys_string_conversions.h"
#import "components/autofill/core/browser/ui/payments/card_unmask_prompt_controller.h"
#import "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/net/crurl.h"
#import "ios/chrome/browser/shared/ui/table_view/cells/table_view_link_header_footer_item.h"
#import "ios/chrome/browser/shared/ui/table_view/cells/table_view_text_edit_item.h"
#import "ios/chrome/browser/shared/ui/table_view/cells/table_view_text_edit_item_delegate.h"
#import "ios/chrome/browser/shared/ui/table_view/table_view_utils.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/ui/autofill/card_unmask_prompt_view_bridge.h"
#import "ios/chrome/browser/ui/autofill/card_unmask_prompt_view_controller+private.h"
#import "ios/chrome/browser/ui/autofill/cells/cvc_header_item.h"
#import "ios/chrome/browser/ui/autofill/cells/expiration_date_edit_item.h"
#import "ios/chrome/browser/ui/autofill/cells/expiration_date_edit_item_delegate.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ui/base/l10n/l10n_util.h"
#import "url/gurl.h"
NSString* const kCardUnmaskPromptTableViewAccessibilityID =
@"CardUnmaskPromptTableViewAccessibilityID";
namespace {
typedef NS_ENUM(NSInteger, SectionIdentifier) {
SectionIdentifierHeader = kSectionIdentifierEnumZero,
SectionIdentifierInputs,
};
typedef NS_ENUM(NSInteger, ItemType) {
ItemTypeHeader = kItemTypeEnumZero,
ItemTypeCVCInput,
ItemTypeFooter,
ItemTypeExpirationDateInput,
};
// Empty space on top of the input section. This value added up to the gPay
// badge bottom padding achieves the mock's vertical spacing between the gPay
// badge in the header.
const CGFloat kInputsSectionTopSpacing = 18;
// When the inputs section doesn't display a footer, an empty one is displayed
// with this height to provide spacing to the bottom of the tableView.
const CGFloat kEmptyFooterHeight = 10;
// Estimated height of the header/footer, used to speed the constraints.
const CGFloat kEstimatedHeaderFooterHeight = 50;
// Dummy URL used as target of the link in the footer.
const char kFooterDummyLinkTarget[] = "about:blank";
} // namespace
@interface CardUnmaskPromptViewController () <
ExpirationDateEditItemDelegate,
TableViewLinkHeaderFooterItemDelegate,
TableViewTextEditItemDelegate,
UITextFieldDelegate> {
// Button displayed on the right side of the navigation bar.
// Tapping it sends the data in the prompt for verification.
UIBarButtonItem* _confirmButton;
// Owns `self`. A value of nullptr means the view controller is dismissed or
// about to be dismissed.
autofill::CardUnmaskPromptViewBridge* _bridge; // weak
// Model of the CVC input cell.
TableViewTextEditItem* _CVCInputItem;
// Model of the footer.
TableViewLinkHeaderFooterItem* _footerItem;
// Model of the header.
CVCHeaderItem* _headerItem;
// Model of the expiration date input cell.
ExpirationDateEditItem* _expirationDateInputItem;
// Flag indicating that we should set the focus in either the CVC or
// expiration date fields once the tableView is reloaded. After the view shows
// the CVC form or the expiration form, we want to focus the CVC and
// expiration date fields respectively. When transitioning from one form to
// the other, we activate this flag so the focus is updated.
BOOL _shouldUpdateFocus;
}
@end
@implementation CardUnmaskPromptViewController
- (instancetype)initWithBridge:(autofill::CardUnmaskPromptViewBridge*)bridge {
self = [super initWithStyle:ChromeTableViewStyle()];
if (self) {
_bridge = bridge;
// Focus CVC field after initial load.
_shouldUpdateFocus = YES;
}
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
if (_bridge == nullptr) {
return;
}
// Default inset inherited from `super` is for cells that display icons.
// Using smaller inset.
self.tableView.separatorInset =
UIEdgeInsetsMake(0, kTableViewSeparatorInset, 0, 0);
self.tableView.accessibilityIdentifier =
kCardUnmaskPromptTableViewAccessibilityID;
self.title =
base::SysUTF16ToNSString(_bridge->GetController()->GetWindowTitle());
// Disable selection.
self.tableView.allowsSelection = NO;
self.tableView.estimatedSectionFooterHeight = kEstimatedHeaderFooterHeight;
self.tableView.estimatedSectionHeaderHeight = kEstimatedHeaderFooterHeight;
self.navigationItem.leftBarButtonItem = [self createCancelButton];
_confirmButton = [self createConfirmButton];
// Disable confirm button until valid input is entered.
_confirmButton.enabled = NO;
self.navigationItem.rightBarButtonItem = _confirmButton;
[self loadModel];
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
// Provide context for users with Voice Over enabled.
NSString* initialMessage =
[NSString stringWithFormat:@"%@\n%@", self.title, [self instructions]];
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification,
initialMessage);
}
- (void)loadModel {
[super loadModel];
if (_bridge == nullptr) {
return;
}
TableViewModel* model = self.tableViewModel;
[model addSectionWithIdentifier:SectionIdentifierHeader];
_headerItem = [self createHeaderItem];
[self updateInstructions];
[model setHeader:_headerItem
forSectionWithIdentifier:SectionIdentifierHeader];
[model addSectionWithIdentifier:SectionIdentifierInputs];
_CVCInputItem = [self createCVCInputItem];
[self.tableViewModel addItem:_CVCInputItem
toSectionWithIdentifier:SectionIdentifierInputs];
}
#pragma mark - Public
- (void)showLoadingState {
self.tableView.userInteractionEnabled = NO;
UIActivityIndicatorView* activityIndicator = [[UIActivityIndicatorView alloc]
initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
UIBarButtonItem* barButton =
[[UIBarButtonItem alloc] initWithCustomView:activityIndicator];
self.navigationItem.rightBarButtonItem = barButton;
[activityIndicator startAnimating];
}
- (void)showErrorAlertWithMessage:(NSString*)message
closeOnDismiss:(BOOL)closeOnDismiss {
// Restore confirm button to navigation bar.
self.navigationItem.rightBarButtonItem = _confirmButton;
UIAlertController* errorAlert = [UIAlertController
alertControllerWithTitle:
l10n_util::GetNSString(
IDS_AUTOFILL_CARD_UNMASK_PROMPT_ERROR_ALERT_TITLE)
message:message
preferredStyle:UIAlertControllerStyleAlert];
auto* __weak weakSelf = self;
UIAlertAction* okAction = [UIAlertAction
actionWithTitle:l10n_util::GetNSString(IDS_OK)
style:UIAlertActionStyleCancel
handler:^(UIAlertAction* action) {
[weakSelf onErrorAlertDismissedAndShouldCloseOnDismiss:
closeOnDismiss];
}];
[errorAlert addAction:okAction];
[self presentViewController:errorAlert animated:YES completion:nil];
}
- (void)disconnectFromBridge {
_bridge = nullptr;
}
#pragma mark - UIAdaptivePresentationControllerDelegate
- (void)presentationControllerDidDismiss:
(UIPresentationController*)presentationController {
if (_bridge == nullptr) {
return;
}
// Notify bridge that UI was dismissed.
_bridge->NavigationControllerDismissed();
}
#pragma mark - Actions
- (void)onCancelTapped {
if (_bridge) {
_bridge->PerformClose();
}
}
- (void)onVerifyTapped {
if (_bridge == nullptr) {
return;
}
// Guard against the rare case where the user was able to tap on verify after
// setting an invalid date and before the button is disabled. The UIPickerView
// notifies its delegate about picker changes but with a sligh delay, which
// leads to this case.
if (![self isExpirationInputValid]) {
return;
}
autofill::CardUnmaskPromptController* controller = _bridge->GetController();
NSString* CVC = _CVCInputItem.textFieldValue;
NSString* month = nil;
NSString* year = nil;
if (_expirationDateInputItem) {
month = _expirationDateInputItem.month;
year = _expirationDateInputItem.year;
}
controller->OnUnmaskPromptAccepted(
base::SysNSStringToUTF16(CVC), base::SysNSStringToUTF16(month),
base::SysNSStringToUTF16(year), /*enable_fido_auth=*/false);
}
#pragma mark - Private
// Displays the form for updating the card's expiration date.
// Displayed on this state:
// - Header with instructions label and gPay badge.
// - CVC input field.
// - Update expiration date input field.
- (void)showUpdateExpirationDateForm {
// Check if the expiration date form is already being shown.
// After submitting a form and receiving an error result, this method might
// get called to switch to the expiration date form. If the expiration date
// form was already being displayed, no changes are needed.
if (_expirationDateInputItem) {
return;
}
// Load instructions for updating the expiration date.
[self updateInstructions];
[self addExpirationDateInputItem];
// The footer is not displayed when updating the expiration date.
[self removeFooterItem];
// Change focus to expiration date field once the cells are loaded.
_shouldUpdateFocus = YES;
[self reloadAllSections];
[self updateConfirmButtonState];
// For Voice Over users, focus on the instructions to provide context about
// what to do next.
UIAccessibilityPostNotification(
UIAccessibilityLayoutChangedNotification,
[self.tableView headerViewForSection:[self.tableViewModel
sectionForSectionIdentifier:
SectionIdentifierHeader]]);
}
// Displays a footer with a link to update the expiration date of the card.
// If the footer is already displayed, this method has no effects.
- (void)showUpdateExpirationDateLink {
// Check if the link is already being displayed.
// After submitting a form and receiving an error result, this method might
// get called. If the link is already displayed, no changes are needed.
if (_footerItem) {
return;
}
_footerItem = [self createFooterItem];
[self.tableViewModel setFooter:_footerItem
forSectionWithIdentifier:SectionIdentifierInputs];
// Restore focus to CVC input field after the section is reloaded.
_shouldUpdateFocus = YES;
// Reload inputs section to display footer.
NSIndexSet* indexSet = [[NSIndexSet alloc]
initWithIndex:[self.tableViewModel
sectionForSectionIdentifier:SectionIdentifierInputs]];
[self.tableView reloadSections:indexSet
withRowAnimation:UITableViewRowAnimationAutomatic];
}
// Returns a newly created item for the header of the section.
- (CVCHeaderItem*)createHeaderItem {
CVCHeaderItem* header = [[CVCHeaderItem alloc] initWithType:ItemTypeHeader];
return header;
}
// Returns a new cancel button for the navigation bar.
- (UIBarButtonItem*)createCancelButton {
UIBarButtonItem* cancelButton =
[[UIBarButtonItem alloc] initWithTitle:l10n_util::GetNSString(IDS_CANCEL)
style:UIBarButtonItemStylePlain
target:self
action:@selector(onCancelTapped)];
return cancelButton;
}
// Returns a new confirm button for the navigation bar.
- (UIBarButtonItem*)createConfirmButton {
if (_bridge == nullptr) {
return nil;
}
NSString* confirmButtonText =
base::SysUTF16ToNSString(_bridge->GetController()->GetOkButtonLabel());
UIBarButtonItem* confirmButton =
[[UIBarButtonItem alloc] initWithTitle:confirmButtonText
style:UIBarButtonItemStyleDone
target:self
action:@selector(onVerifyTapped)];
[confirmButton setTitleTextAttributes:@{
NSForegroundColorAttributeName : [UIColor colorNamed:kBlueColor]
}
forState:UIControlStateNormal];
[confirmButton setTitleTextAttributes:@{
NSForegroundColorAttributeName : [UIColor colorNamed:kDisabledTintColor]
}
forState:UIControlStateDisabled];
return confirmButton;
}
// Returns the model for the CVC input cell.
- (TableViewTextEditItem*)createCVCInputItem {
if (_bridge == nullptr) {
return nil;
}
autofill::CardUnmaskPromptController* controller = _bridge->GetController();
TableViewTextEditItem* CVCInputItem =
[[TableViewTextEditItem alloc] initWithType:ItemTypeCVCInput];
CVCInputItem.delegate = self;
CVCInputItem.fieldNameLabelText =
l10n_util::GetNSString(IDS_AUTOFILL_CARD_UNMASK_PROMPT_CVC_FIELD_TITLE);
CVCInputItem.keyboardType = UIKeyboardTypeNumberPad;
CVCInputItem.hideIcon = YES;
CVCInputItem.textFieldEnabled = YES;
CVCInputItem.identifyingIcon = NativeImage(controller->GetCvcImageRid());
return CVCInputItem;
}
// Returns a newly created item for the footer of the section.
- (TableViewLinkHeaderFooterItem*)createFooterItem {
TableViewLinkHeaderFooterItem* footer =
[[TableViewLinkHeaderFooterItem alloc] initWithType:ItemTypeFooter];
footer.text = l10n_util::GetNSString(
IDS_AUTOFILL_CARD_UNMASK_PROMPT_UPDATE_CARD_MESSAGE_LINK);
// Using a dummy target for the link in the footer.
// The link target is ignored and taps on it are handled by `didTapLinkURL`.
footer.urls = @[ [[CrURL alloc] initWithGURL:GURL(kFooterDummyLinkTarget)] ];
return footer;
}
// Removes the footer item from the table view model.
- (void)removeFooterItem {
_footerItem = nil;
[self.tableViewModel setFooter:nil
forSectionWithIdentifier:SectionIdentifierInputs];
}
// Returns the model for the expiration date input cell.
- (ExpirationDateEditItem*)createExpirationDateInputItem {
ExpirationDateEditItem* expirationDateInputItem =
[[ExpirationDateEditItem alloc] initWithType:ItemTypeExpirationDateInput];
expirationDateInputItem.delegate = self;
expirationDateInputItem.fieldNameLabelText = l10n_util::GetNSString(
IDS_AUTOFILL_CARD_UNMASK_PROMPT_EXPIRATION_DATE_FIELD_TITLE);
return expirationDateInputItem;
}
// Adds the model for the expiration input item to the TableView model.
- (void)addExpirationDateInputItem {
_expirationDateInputItem = [self createExpirationDateInputItem];
[self.tableViewModel addItem:_expirationDateInputItem
toSectionWithIdentifier:SectionIdentifierInputs];
}
// Updates the instructions in the header.
- (void)updateInstructions {
_headerItem.instructionsText = [self instructions];
}
// Reloads all sections of the table view using automatic row animations.
// This method is used to update the views after the state of `self` changes.
// `reloadData` could work as well but preferring the animated aproach for
// smoother UX transitions.
- (void)reloadAllSections {
NSIndexSet* indexSet = [NSIndexSet
indexSetWithIndexesInRange:NSMakeRange(
0,
[self.tableViewModel numberOfSections])];
[self.tableView reloadSections:indexSet
withRowAnimation:UITableViewRowAnimationAutomatic];
}
// Returns YES if the CVC value entered matches the expected CVC format.
// The actual correctness of the CVC is verified by the server when the form is
// submitted.
- (BOOL)isCVCInputValid {
if (_bridge == nullptr) {
return NO;
}
return _bridge->GetController()->InputCvcIsValid(
base::SysNSStringToUTF16(_CVCInputItem.textFieldValue));
}
// Returns YES if the expiration date entered matches the expected format or if
// the expiration date input is not being displayed. The actual correctness of
// the expiration date is verified by the server when the form is submitted.
- (BOOL)isExpirationInputValid {
if (_bridge == nullptr) {
return NO;
}
if (!_expirationDateInputItem) {
return YES;
}
return _bridge->GetController()->InputExpirationIsValid(
base::SysNSStringToUTF16(_expirationDateInputItem.month),
base::SysNSStringToUTF16(_expirationDateInputItem.year));
}
// Enables the confirm button in the navigation bar iff the values for CVC and
// expiration date entered by the user are valid.
- (void)updateConfirmButtonState {
_confirmButton.enabled =
[self isCVCInputValid] && [self isExpirationInputValid];
}
// Updates `self` after the error alert was dismissed.
// When `closeOnDismiss` is YES, `self` is dismissed.
- (void)onErrorAlertDismissedAndShouldCloseOnDismiss:(BOOL)closeOnDismiss {
if (_bridge == nullptr) {
return;
}
if (closeOnDismiss) {
_bridge->PerformClose();
return;
}
// Interactions were disabled in the loading state.
// Enabling them after the alert is dismissed.
self.tableView.userInteractionEnabled = YES;
// Check if we need to switch to the update expiration date form.
if (_bridge->GetController()->ShouldRequestExpirationDate()) {
[self showUpdateExpirationDateForm];
} else {
// Display the expiration date link after an error verifying the CVC.
[self showUpdateExpirationDateLink];
}
}
// Helper method that fetches the instructions from the Controller.
- (NSString*)instructions {
if (_bridge == nullptr) {
return nil;
}
autofill::CardUnmaskPromptController* controller = _bridge->GetController();
return base::SysUTF16ToNSString(controller->GetInstructionsMessage());
}
#pragma mark - TableViewTextEditItemDelegate
- (void)tableViewItemDidChange:(TableViewTextEditItem*)tableViewItem {
[self updateConfirmButtonState];
}
- (void)tableViewItemDidBeginEditing:(TableViewTextEditItem*)tableViewItem {
}
- (void)tableViewItemDidEndEditing:(TableViewTextEditItem*)tableViewItem {
}
#pragma mark - UITableViewDelegate
- (CGFloat)tableView:(UITableView*)tableView
heightForHeaderInSection:(NSInteger)section {
// Adding space on top of the inputs section to match the mocks' spacing.
NSInteger inputsSection =
[self.tableViewModel sectionForSectionIdentifier:SectionIdentifierInputs];
if (section == inputsSection) {
return kInputsSectionTopSpacing;
}
return UITableViewAutomaticDimension;
}
- (CGFloat)tableView:(UITableView*)tableView
heightForFooterInSection:(NSInteger)section {
// The header section doesn't need a footer, settings its height to zero to
// avoid extra spacing between sections.
NSInteger headerSection =
[self.tableViewModel sectionForSectionIdentifier:SectionIdentifierHeader];
if (section == headerSection) {
return 0;
}
// Let Autolayout calculate calculate the footer's height if any.
if ([self.tableViewModel footerForSectionIndex:section]) {
return UITableViewAutomaticDimension;
}
// Default spacing when no footer.
return kEmptyFooterHeight;
}
- (void)tableView:(UITableView*)tableView
willDisplayCell:(UITableViewCell*)cell
forRowAtIndexPath:(NSIndexPath*)indexPath {
// Only update focus for cells with input fields and when update focus is
// needed.
// Don't update focus when Voice Over is running. Instead, the instructions
// will be read, providing more context for users with Voice Over enabled.
if (UIAccessibilityIsVoiceOverRunning() || !_shouldUpdateFocus ||
![cell isKindOfClass:TableViewTextEditCell.class]) {
return;
}
// When we're about to display the CVC form or expiration date form for
// the first time focus the textField on the right cell.
TableViewTextEditCell* rowCell =
base::apple::ObjCCastStrict<TableViewTextEditCell>(cell);
ItemType rowItemType = static_cast<ItemType>(
[self.tableViewModel itemTypeForIndexPath:indexPath]);
// If the expiration date cell is displayed then we're transitioning to the
// expiration date form. Only focus the CVC cell when we're not transitioning
// to the expiration date form.
if (rowItemType == ItemTypeExpirationDateInput ||
(rowItemType == ItemTypeCVCInput && !_expirationDateInputItem)) {
[rowCell.textField becomeFirstResponder];
_shouldUpdateFocus = NO;
}
}
#pragma mark - UITableViewDataSource
- (UIView*)tableView:(UITableView*)tableView
viewForFooterInSection:(NSInteger)section {
UIView* view = [super tableView:tableView viewForFooterInSection:section];
NSInteger sectionIdentifier =
[self.tableViewModel sectionIdentifierForSectionIndex:section];
// Set `self` as delegate for the inputs section footer to handle taps on the
// Update Card link.
if (sectionIdentifier == SectionIdentifierInputs) {
TableViewLinkHeaderFooterView* footerView =
base::apple::ObjCCast<TableViewLinkHeaderFooterView>(view);
footerView.delegate = self;
}
return view;
}
- (UITableViewCell*)tableView:(UITableView*)tableView
cellForRowAtIndexPath:(NSIndexPath*)indexPath {
UITableViewCell* cell = [super tableView:tableView
cellForRowAtIndexPath:indexPath];
ItemType rowItemType = static_cast<ItemType>(
[self.tableViewModel itemTypeForIndexPath:indexPath]);
if (rowItemType == ItemTypeCVCInput) {
TableViewTextEditCell* rowCell =
base::apple::ObjCCastStrict<TableViewTextEditCell>(cell);
rowCell.textField.delegate = self;
// Hide the icon from Voice Over.
rowCell.identifyingIconButton.isAccessibilityElement = NO;
}
return cell;
}
#pragma mark - TableViewLinkHeaderFooterDelegate
- (void)view:(TableViewLinkHeaderFooterView*)view didTapLinkURL:(CrURL*)URL {
if (_bridge == nullptr) {
return;
}
// Notify Controller about the Expiration Date Form being shown so it updates
// its state accordingly.
_bridge->GetController()->NewCardLinkClicked();
// Handle taps on the Update Card link.
[self showUpdateExpirationDateForm];
}
#pragma mark - ExpirationDateEditItemDelegate
- (void)expirationDateEditItemDidChange:(ExpirationDateEditItem*)item {
// Handle expiration date selections.
[self updateConfirmButtonState];
}
#pragma mark - UITextFieldDelegate
// CVC input is limited to 4 characters since CVCs are never longer than that.
- (BOOL)textField:(UITextField*)textField
shouldChangeCharactersInRange:(NSRange)range
replacementString:(NSString*)string {
return (textField.text.length + string.length - range.length) <= 4;
}
@end