19 Card Digit Support Part 2 (#1631)

* * Adds API bindings for new, private card metadata endpoint
* Disables card number truncation in `STPPaymentCardTextField`
* Disables auto-advancing out of the number entry field in `STPPaymentCardTextField`
* Updates card number validation to wait for response from card metadata endpoint
* Updates tests with new card number behavior and asynchronicity

* Fixes UI test

* Enables auto-advancing when able and loading indicator to STPPaymentCardTextField

* Revert

* Comment updates from review

* Adds logging for entering full card number before getting a network response.

* Adds additional logging.

* Return

* Use configuration

* Fixes inifite loop; fixes KVO; fixes tests for updated behavior.

* Fixes memory leak/crash from retain cycle in OCMock code

* Fix test case

* Only enable service-based BIN lookups for CUP bins (#1642)

* Fixes validation tests and removes delay from card textfield tests.

* Updates README with correct simulator version for tests.

* Fixes snapshot tests

Co-authored-by: davidme-stripe <52758633+davidme-stripe@users.noreply.github.com>
This commit is contained in:
Cameron 2020-09-14 12:40:26 -07:00 committed by GitHub
parent e8f53b2943
commit 03ab0f2280
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 879 additions and 671 deletions

View File

@ -111,7 +111,7 @@ We welcome contributions of any kind including new features, bug fixes, and docu
1. Install Carthage (if you have homebrew installed, `brew install carthage`)
2. From the root of the repo, install test dependencies by running `carthage bootstrap --platform ios --configuration Release --no-use-binaries`
3. Open Stripe.xcworkspace
4. Choose the "StripeiOS" scheme with the iPhone 7, iOS 12.2 simulator (required for snapshot tests to pass)
4. Choose the "StripeiOS" scheme with the iPhone 8, iOS 13.6 simulator (required for snapshot tests to pass)
5. Run Product -> Test
## Migrating from Older Versions

View File

@ -276,6 +276,8 @@
363E25DA24198B0D00070D59 /* STPViewWithSeparator.m in Sources */ = {isa = PBXBuildFile; fileRef = 363E25D824198B0D00070D59 /* STPViewWithSeparator.m */; };
363EA802241C3EC8000C7671 /* STPAUBECSDebitFormViewSnapshotTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 363EA801241C3EC8000C7671 /* STPAUBECSDebitFormViewSnapshotTests.m */; };
363EA804241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h in Headers */ = {isa = PBXBuildFile; fileRef = 363EA803241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h */; };
364B75DD24F46354007D9FAB /* STPCardLoadingIndicator.h in Headers */ = {isa = PBXBuildFile; fileRef = 364B75DB24F46354007D9FAB /* STPCardLoadingIndicator.h */; };
364B75DE24F46354007D9FAB /* STPCardLoadingIndicator.m in Sources */ = {isa = PBXBuildFile; fileRef = 364B75DC24F46354007D9FAB /* STPCardLoadingIndicator.m */; };
3650AA4221C07E3C002B0893 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 3650AA4121C07E3C002B0893 /* AppDelegate.m */; };
3650AA4521C07E3C002B0893 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3650AA4421C07E3C002B0893 /* ViewController.m */; };
3650AA4A21C07E3D002B0893 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3650AA4921C07E3D002B0893 /* Assets.xcassets */; };
@ -1067,6 +1069,8 @@
363E25D824198B0D00070D59 /* STPViewWithSeparator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPViewWithSeparator.m; sourceTree = "<group>"; };
363EA801241C3EC8000C7671 /* STPAUBECSDebitFormViewSnapshotTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPAUBECSDebitFormViewSnapshotTests.m; sourceTree = "<group>"; };
363EA803241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STPAUBECSDebitFormView+Testing.h"; sourceTree = "<group>"; };
364B75DB24F46354007D9FAB /* STPCardLoadingIndicator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STPCardLoadingIndicator.h; sourceTree = "<group>"; };
364B75DC24F46354007D9FAB /* STPCardLoadingIndicator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPCardLoadingIndicator.m; sourceTree = "<group>"; };
3650AA3E21C07E3C002B0893 /* LocalizationTester.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LocalizationTester.app; sourceTree = BUILT_PRODUCTS_DIR; };
3650AA4021C07E3C002B0893 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = "<group>"; };
3650AA4121C07E3C002B0893 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = "<group>"; };
@ -2807,8 +2811,17 @@
F1728CF81EAAA945002E0C29 /* Views */ = {
isa = PBXGroup;
children = (
363EA803241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h */,
36B8DDE8241AEB5400BB908E /* STPAUBECSFormViewModel.h */,
36B8DDE9241AEB5400BB908E /* STPAUBECSFormViewModel.m */,
364B75DB24F46354007D9FAB /* STPCardLoadingIndicator.h */,
364B75DC24F46354007D9FAB /* STPCardLoadingIndicator.m */,
0438EF261B7416BB00D506CC /* STPFormTextField.h */,
0438EF271B7416BB00D506CC /* STPFormTextField.m */,
36B8DDE0241AC0A100BB908E /* STPLabeledFormTextFieldView.h */,
36B8DDE1241AC0A100BB908E /* STPLabeledFormTextFieldView.m */,
36B8DDE4241AC17200BB908E /* STPLabeledMultiFormTextFieldView.h */,
36B8DDE5241AC17200BB908E /* STPLabeledMultiFormTextFieldView.m */,
0438EF2A1B7416BB00D506CC /* STPPaymentCardTextFieldViewModel.h */,
0438EF2B1B7416BB00D506CC /* STPPaymentCardTextFieldViewModel.m */,
C158AB3D1E1EE98900348D01 /* STPSectionHeaderView.h */,
@ -2817,13 +2830,6 @@
B347DD471FE35423006B3BAC /* STPValidatedTextField.m */,
363E25D724198B0D00070D59 /* STPViewWithSeparator.h */,
363E25D824198B0D00070D59 /* STPViewWithSeparator.m */,
36B8DDE0241AC0A100BB908E /* STPLabeledFormTextFieldView.h */,
36B8DDE1241AC0A100BB908E /* STPLabeledFormTextFieldView.m */,
36B8DDE4241AC17200BB908E /* STPLabeledMultiFormTextFieldView.h */,
36B8DDE5241AC17200BB908E /* STPLabeledMultiFormTextFieldView.m */,
36B8DDE8241AEB5400BB908E /* STPAUBECSFormViewModel.h */,
36B8DDE9241AEB5400BB908E /* STPAUBECSFormViewModel.m */,
363EA803241C4346000C7671 /* STPAUBECSDebitFormView+Testing.h */,
);
name = Views;
sourceTree = "<group>";
@ -2928,6 +2934,7 @@
B61D4B97245767D2001AEBEF /* STPPaymentIntentShippingDetailsAddress.h in Headers */,
C158AB3F1E1EE98900348D01 /* STPSectionHeaderView.h in Headers */,
04EBC7571B7533C300A0E6AE /* STPCardValidator.h in Headers */,
364B75DD24F46354007D9FAB /* STPCardLoadingIndicator.h in Headers */,
366658B0240F20AD00D00354 /* STPPaymentMethodAUBECSDebit.h in Headers */,
36B8DDE2241AC0A100BB908E /* STPLabeledFormTextFieldView.h in Headers */,
046FE9A11CE55D1D00DA6A7B /* STPPaymentActivityIndicatorView.h in Headers */,
@ -3776,6 +3783,7 @@
311A475824EB1D2300576D92 /* STPCardScanner.m in Sources */,
F1BEB2FF1F3508BB0043F48C /* NSError+Stripe.m in Sources */,
B699BC9524577FED009107F9 /* STPPaymentIntentShippingDetailsAddressParams.m in Sources */,
364B75DE24F46354007D9FAB /* STPCardLoadingIndicator.m in Sources */,
049952D01BCF13510088C703 /* STPAPIRequest.m in Sources */,
F1A2F92E1EEB6A70006B0456 /* NSCharacterSet+Stripe.m in Sources */,
F1D3A24B1EB012010095BFA9 /* STPFile.m in Sources */,

View File

@ -72,6 +72,16 @@
intentID:(NSString *)intentID
errorDictionary:(NSDictionary *)errorDictionary;
#pragma mark - Card Metadata
- (void)logUserEnteredCompletePANBeforeMetadataLoadedWithConfiguration:(STPPaymentConfiguration *)configuration;
- (void)logCardMetadataResponseFailureWithConfiguration:(STPPaymentConfiguration *)configuration;
- (void)logCardMetadataMissingRangeWithConfiguration:(STPPaymentConfiguration *)configuration;
#pragma mark - Card Scanning
- (void)logCardScanSucceededWithDuration:(NSTimeInterval)duration;
- (void)logCardScanCancelledWithDuration:(NSTimeInterval)duration;

View File

@ -321,6 +321,43 @@
[self logPayload:payload];
}
#pragma mark - Card Metadata
- (void)logUserEnteredCompletePANBeforeMetadataLoadedWithConfiguration:(STPPaymentConfiguration *)configuration {
NSDictionary *configurationDictionary = [self.class serializeConfiguration:configuration];
NSMutableDictionary *payload = [self.class commonPayload];
[payload addEntriesFromDictionary:@{
@"event": @"stripeios.card_metadata_loaded_too_slow",
}];
[payload addEntriesFromDictionary:[self productUsageDictionary]];
[payload addEntriesFromDictionary:configurationDictionary];
[self logPayload:payload];
}
- (void)logCardMetadataResponseFailureWithConfiguration:(STPPaymentConfiguration *)configuration {
NSDictionary *configurationDictionary = [self.class serializeConfiguration:configuration];
NSMutableDictionary *payload = [self.class commonPayload];
[payload addEntriesFromDictionary:@{
@"event": @"stripeios.card_metadata_load_failure",
}];
[payload addEntriesFromDictionary:[self productUsageDictionary]];
[payload addEntriesFromDictionary:configurationDictionary];
[self logPayload:payload];
}
- (void)logCardMetadataMissingRangeWithConfiguration:(STPPaymentConfiguration *)configuration {
NSDictionary *configurationDictionary = [self.class serializeConfiguration:configuration];
NSMutableDictionary *payload = [self.class commonPayload];
[payload addEntriesFromDictionary:@{
@"event": @"stripeios.card_metadata_missing_range",
}];
[payload addEntriesFromDictionary:[self productUsageDictionary]];
[payload addEntriesFromDictionary:configurationDictionary];
[self logPayload:payload];
}
#pragma mark - Card Scanning
- (void)logCardScanSucceededWithDuration:(NSTimeInterval)duration {
NSMutableDictionary *payload = [self.class commonPayload];
[payload addEntriesFromDictionary:@{

View File

@ -23,12 +23,22 @@ typedef void (^STPRetrieveBINRangesCompletionBlock)(NSArray<STPBINRange *> * _Nu
@property (nonatomic, readonly, copy) NSString *qRangeLow;
@property (nonatomic, readonly, copy) NSString *qRangeHigh;
@property (nonatomic, nullable, readonly) NSString *country;
@property (nonatomic, readonly) BOOL isCardMetadata; // indicates bin range was downloaded from edge service
+ (BOOL)isLoadingCardMetadataForPrefix:(NSString *)binPrefix;
+ (NSArray<STPBINRange *> *)allRanges;
+ (NSArray<STPBINRange *> *)binRangesForNumber:(NSString *)number;
+ (NSArray<STPBINRange *> *)binRangesForBrand:(STPCardBrand)brand;
+ (instancetype)mostSpecificBINRangeForNumber:(NSString *)number;
+ (NSUInteger)maxCardNumberLength;
+ (NSUInteger)minLengthForFullBINRange;
+ (BOOL)hasBINRangesForPrefix:(NSString *)binPrefix;
+ (BOOL)isInvalidBINPrefix:(NSString *)binPrefix;
// This will asynchronously check if we have already fetched metadata for this prefix and if we have not will
// issue a network request to retrieve it if possible.
+ (void)retrieveBINRangesForPrefix:(NSString *)binPrefix completion:(STPRetrieveBINRangesCompletionBlock)completion;

View File

@ -10,12 +10,17 @@
#import "NSDictionary+Stripe.h"
#import "NSString+Stripe.h"
#import "STPAnalyticsClient.h"
#import "STPAPIClient+Private.h"
#import "STPCard+Private.h"
#import "STPCardBINMetadata.h"
#import "STPPaymentConfiguration.h"
NS_ASSUME_NONNULL_BEGIN
static const NSUInteger kMaxCardNumberLength = 19;
static const NSUInteger kPrefixLengthForMetadataRequest = 6;
@interface STPBINRange()
@property (nonatomic) NSUInteger length;
@ -90,12 +95,30 @@ NS_ASSUME_NONNULL_BEGIN
binRange.brand = [STPCard brandFromString:brandString];
binRange.length = [length unsignedIntegerValue];
binRange.country = [dict stp_stringForKey:@"country"];
binRange->_isCardMetadata = YES;
return binRange;
}
#pragma mark - Class Utilities
+ (BOOL)isLoadingCardMetadataForPrefix:(NSString *)binPrefix {
__block BOOL isLoading = NO;
dispatch_sync([self _retrievalQueue], ^{
NSString *binPrefixKey = [binPrefix stp_safeSubstringToIndex:kPrefixLengthForMetadataRequest];
isLoading = binPrefixKey != nil && sPendingRequests[binPrefixKey] != nil;
});
return isLoading;
}
+ (NSUInteger)maxCardNumberLength {
return kMaxCardNumberLength;
}
+ (NSUInteger)minLengthForFullBINRange {
return kPrefixLengthForMetadataRequest;
}
static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
+ (void)_performSyncWithAllRangesLock:(dispatch_block_t)block {
@ -110,7 +133,7 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
if (STPBINRangeAllRanges == nil) {
NSArray *ranges = @[
// Unknown
@[@"", @"", @16, @(STPCardBrandUnknown)],
@[@"", @"", @19, @(STPCardBrandUnknown)],
// American Express
@[@"34", @"34", @15, @(STPCardBrandAmex)],
@ -135,6 +158,7 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
// UnionPay
@[@"62", @"62", @16, @(STPCardBrandUnionPay)],
@[@"81", @"81", @16, @(STPCardBrandUnionPay)],
// Visa
@[@"40", @"49", @16, @(STPCardBrandVisa)],
@ -203,12 +227,13 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
}]];
}
+ (void)retrieveBINRangesForPrefix:(NSString *)binPrefix completion:(STPRetrieveBINRangesCompletionBlock)completion {
// sPendingRequests contains the completion blocks for a given metadata request that we have not yet gotten a response for
static NSMutableDictionary<NSString *, NSArray<STPRetrieveBINRangesCompletionBlock> *> *sPendingRequests = nil;
// sRetrievedRanges tracks the bin prefixes for which we've already received metadata responses
static NSMutableDictionary<NSString *, NSArray<STPBINRange *> *> *sRetrievedRanges = nil;
// sRetrievalQueue protects access to the two above dictionaries, sSpendingRequests and sRetrievedRanges
// sPendingRequests contains the completion blocks for a given metadata request that we have not yet gotten a response for
static NSMutableDictionary<NSString *, NSArray<STPRetrieveBINRangesCompletionBlock> *> *sPendingRequests = nil;
// sRetrievedRanges tracks the bin prefixes for which we've already received metadata responses
static NSMutableDictionary<NSString *, NSArray<STPBINRange *> *> *sRetrievedRanges = nil;
// _retrievalQueue protects access to the two above dictionaries, sSpendingRequests and sRetrievedRanges
+ (dispatch_queue_t)_retrievalQueue {
static dispatch_queue_t sRetrievalQueue = nil;
static dispatch_once_t onceToken;
@ -218,10 +243,42 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
sRetrievedRanges = [NSMutableDictionary new];
});
dispatch_async(sRetrievalQueue, ^{
NSString *binPrefixKey = [binPrefix stp_safeSubstringToIndex:6];
if (sRetrievedRanges[binPrefixKey] != nil || binPrefixKey.length < 6) {
return sRetrievalQueue;
}
+ (BOOL)hasBINRangesForPrefix:(NSString *)binPrefix {
if ([self isInvalidBINPrefix:binPrefix]) {
return YES; // we won't fetch any more info for this prefix
}
if (![self isVariableLengthBINPrefix:binPrefix]) {
return YES; // if we know a card has a static length, we don't need to ask the BIN service
}
__block BOOL hasBINRanges = NO;
dispatch_sync([self _retrievalQueue], ^{
NSString *binPrefixKey = [binPrefix stp_safeSubstringToIndex:kPrefixLengthForMetadataRequest];
hasBINRanges = binPrefixKey.length == kPrefixLengthForMetadataRequest && sRetrievedRanges[binPrefixKey] != nil;
});
return hasBINRanges;
}
+ (BOOL)isInvalidBINPrefix:(NSString *)binPrefix {
NSString *firstFive = [binPrefix stp_safeSubstringToIndex:kPrefixLengthForMetadataRequest - 1];
return ((STPBINRange *)[self mostSpecificBINRangeForNumber:firstFive]).brand == STPCardBrandUnknown;
}
+ (BOOL)isVariableLengthBINPrefix:(NSString *)binPrefix {
NSString *firstFive = [binPrefix stp_safeSubstringToIndex:kPrefixLengthForMetadataRequest - 1];
// Only UnionPay has variable-length cards at the moment.
return ((STPBINRange *)[self mostSpecificBINRangeForNumber:firstFive]).brand == STPCardBrandUnionPay;
}
+ (void)retrieveBINRangesForPrefix:(NSString *)binPrefix completion:(STPRetrieveBINRangesCompletionBlock)completion {
dispatch_async([self _retrievalQueue], ^{
NSString *binPrefixKey = [binPrefix stp_safeSubstringToIndex:kPrefixLengthForMetadataRequest];
if (sRetrievedRanges[binPrefixKey] != nil || binPrefixKey.length < kPrefixLengthForMetadataRequest || [self isInvalidBINPrefix:binPrefixKey]) {
// if we already have a metadata response or the binPrefix isn't long enough to make a request,
// or we know that this is not a valid BIN prefix
// return the bin ranges we already have on device
dispatch_async(dispatch_get_main_queue(), ^{
completion([self binRangesForNumber:binPrefix], nil);
@ -234,7 +291,7 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
sPendingRequests[binPrefixKey] = @[[completion copy]];
[[STPAPIClient sharedClient] retrieveCardBINMetadataForPrefix:binPrefixKey
withCompletion:^(STPCardBINMetadata * _Nullable cardMetadata, NSError * _Nullable error) {
dispatch_async(sRetrievalQueue, ^{
dispatch_async([self _retrievalQueue], ^{
NSArray<STPBINRange *> *ranges = cardMetadata.ranges;
NSArray<STPRetrieveBINRangesCompletionBlock> *completionBlocks = sPendingRequests[binPrefixKey];
@ -244,6 +301,8 @@ static NSArray<STPBINRange *> *STPBINRangeAllRanges = nil;
[self _performSyncWithAllRangesLock:^{
STPBINRangeAllRanges = [STPBINRangeAllRanges arrayByAddingObjectsFromArray:ranges];
}];
} else {
[[STPAnalyticsClient sharedClient] logCardMetadataResponseFailureWithConfiguration:[STPPaymentConfiguration sharedConfiguration]];
}
dispatch_async(dispatch_get_main_queue(), ^{

View File

@ -45,7 +45,7 @@ NS_ASSUME_NONNULL_BEGIN
NSString *brand = [string lowercaseString];
if ([brand isEqualToString:@"visa"]) {
return STPCardBrandVisa;
} else if ([brand isEqualToString:@"american express"]) {
} else if ([brand isEqualToString:@"american express"] || [brand isEqualToString:@"american_express"]) {
return STPCardBrandAmex;
} else if ([brand isEqualToString:@"mastercard"]) {
return STPCardBrandMasterCard;
@ -53,7 +53,7 @@ NS_ASSUME_NONNULL_BEGIN
return STPCardBrandDiscover;
} else if ([brand isEqualToString:@"jcb"]) {
return STPCardBrandJCB;
} else if ([brand isEqualToString:@"diners club"]) {
} else if ([brand isEqualToString:@"diners club"] || [brand isEqualToString:@"diners_club"]) {
return STPCardBrandDinersClub;
} else if ([brand isEqualToString:@"unionpay"]) {
return STPCardBrandUnionPay;

View File

@ -34,6 +34,7 @@ NS_ASSUME_NONNULL_BEGIN
}
}
}
if (ranges.count == 0) {
return nil;
}

View File

@ -0,0 +1,17 @@
//
// STPCardLoadingIndicator.h
// StripeiOS
//
// Created by Cameron Sabol on 8/24/20.
// Copyright © 2020 Stripe, Inc. All rights reserved.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface STPCardLoadingIndicator : UIView
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,82 @@
//
// STPCardLoadingIndicator.m
// StripeiOS
//
// Created by Cameron Sabol on 8/24/20.
// Copyright © 2020 Stripe, Inc. All rights reserved.
//
#import "STPCardLoadingIndicator.h"
NS_ASSUME_NONNULL_BEGIN
static const CGFloat kCardLoadingIndicatorDiameter = 14.f;
static const CGFloat kCardLoadingInnerCircleDiameter = 10.f;
static const CFTimeInterval kLoadingAnimationSpinDuration = 0.6;
static NSString * const kLoadingAnimationIdentifier = @"STPCardLoadingIndicator.spinning";
@implementation STPCardLoadingIndicator {
CALayer *_indicatorLayer;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
self.backgroundColor = [UIColor colorWithRed:79.f/255.f green:86.f/255.f blue:107.f/255.f alpha:1.f];
// Make us a circle
CAShapeLayer *shape = [CAShapeLayer layer];
UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(0.5f*kCardLoadingIndicatorDiameter, 0.5f*kCardLoadingIndicatorDiameter)
radius:0.5f*kCardLoadingIndicatorDiameter
startAngle:0.f
endAngle:(2.f*M_PI)
clockwise:YES];
shape.path = path.CGPath;
self.layer.mask = shape;
// Add the inner circle
CAShapeLayer *innerCircle = [CAShapeLayer layer];
innerCircle.anchorPoint = CGPointMake(0.5f, 0.5f);
innerCircle.position = CGPointMake(0.5f*kCardLoadingIndicatorDiameter, 0.5f*kCardLoadingIndicatorDiameter);
UIBezierPath *indicatorPath = [UIBezierPath bezierPathWithArcCenter:CGPointMake(0.f, 0.f)
radius:0.5f*kCardLoadingInnerCircleDiameter
startAngle:0.f
endAngle:(9.f*M_PI/6.f)
clockwise:YES];
innerCircle.path = indicatorPath.CGPath;
innerCircle.strokeColor = [UIColor colorWithWhite:1.f alpha:0.8f].CGColor;
innerCircle.fillColor = [UIColor clearColor].CGColor;
[self.layer addSublayer:innerCircle];
_indicatorLayer = (CALayer *)innerCircle;
}
return self;
}
- (CGSize)intrinsicContentSize {
return CGSizeMake(kCardLoadingIndicatorDiameter, kCardLoadingIndicatorDiameter);
}
- (void)didMoveToWindow {
[super didMoveToWindow];
[self startAnimating];
}
- (void)startAnimating {
CABasicAnimation *spinAnimation = [CABasicAnimation animationWithKeyPath:@"transform.rotation"];
spinAnimation.byValue = [NSNumber numberWithFloat:(float)(2.0f*M_PI)];
spinAnimation.duration = kLoadingAnimationSpinDuration;
spinAnimation.repeatCount = INFINITY;
[_indicatorLayer addAnimation:spinAnimation forKey:kLoadingAnimationIdentifier];
}
- (void)stopAnimating {
[_indicatorLayer removeAnimationForKey:kLoadingAnimationIdentifier];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -15,6 +15,8 @@ NS_ASSUME_NONNULL_BEGIN
+ (NSArray<NSNumber *> *)cardNumberFormatForBrand:(STPCardBrand)brand;
+ (NSArray<NSNumber *> *)cardNumberFormatForCardNumber:(NSString *)cardNumber;
+ (BOOL)stringIsValidLuhn:(NSString *)number;
@end
NS_ASSUME_NONNULL_END

View File

@ -33,6 +33,25 @@ NS_ASSUME_NONNULL_BEGIN
return [self cardNumberFormatForBrand:binRange.brand];
}
+ (BOOL)stringIsValidLuhn:(NSString *)number {
BOOL odd = true;
int sum = 0;
NSMutableArray *digits = [NSMutableArray arrayWithCapacity:number.length];
for (int i = 0; i < (NSInteger)number.length; i++) {
[digits addObject:[number substringWithRange:NSMakeRange(i, 1)]];
}
for (NSString *digitStr in [digits reverseObjectEnumerator]) {
int digit = [digitStr intValue];
if ((odd = !odd)) digit *= 2;
if (digit > 9) digit -= 9;
sum += digit;
}
return sum % 10 == 0;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -9,7 +9,9 @@
#import "STPCardValidator.h"
#import "STPCardValidator+Private.h"
#import "STPAnalyticsClient.h"
#import "STPBINRange.h"
#import "STPPaymentConfiguration.h"
#import "NSCharacterSet+Stripe.h"
@implementation STPCardValidator
@ -157,7 +159,16 @@ static NSString * _Nonnull stringByRemovingCharactersFromSet(NSString * _Nonnull
}
if (sanitizedNumber.length == binRange.length) {
BOOL isValidLuhn = [self stringIsValidLuhn:sanitizedNumber];
return isValidLuhn ? STPCardValidationStateValid : STPCardValidationStateInvalid;
if (isValidLuhn) {
if (!binRange.isCardMetadata && [STPBINRange hasBINRangesForPrefix:sanitizedNumber]) {
// log that we didn't get a match in the metadata response so fell back to a hard coded response
[[STPAnalyticsClient sharedClient] logCardMetadataMissingRangeWithConfiguration:[STPPaymentConfiguration sharedConfiguration]];
}
return STPCardValidationStateValid;
} else {
return STPCardValidationStateInvalid;
}
} else if (sanitizedNumber.length > binRange.length) {
return STPCardValidationStateInvalid;
} else {
@ -252,25 +263,6 @@ static NSString * _Nonnull stringByRemovingCharactersFromSet(NSString * _Nonnull
return [[[self cardNumberFormatForBrand:brand] lastObject] unsignedIntegerValue];
}
+ (BOOL)stringIsValidLuhn:(NSString *)number {
BOOL odd = true;
int sum = 0;
NSMutableArray *digits = [NSMutableArray arrayWithCapacity:number.length];
for (int i = 0; i < (NSInteger)number.length; i++) {
[digits addObject:[number substringWithRange:NSMakeRange(i, 1)]];
}
for (NSString *digitStr in [digits reverseObjectEnumerator]) {
int digit = [digitStr intValue];
if ((odd = !odd)) digit *= 2;
if (digit > 9) digit -= 9;
sum += digit;
}
return sum % 10 == 0;
}
+ (NSInteger)currentYear {
NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian];
NSDateComponents *dateComponents = [calendar components:NSCalendarUnitYear fromDate:[NSDate date]];

View File

@ -12,6 +12,8 @@
#import "NSArray+Stripe.h"
#import "NSString+Stripe.h"
#import "STPBINRange.h"
#import "STPCardLoadingIndicator.h"
#import "STPCardValidator+Private.h"
#import "STPFormTextField.h"
#import "STPImageLibrary.h"
@ -22,7 +24,7 @@
#import "STPAnalyticsClient.h"
@interface STPPaymentCardTextField()<STPFormTextFieldDelegate> {
BOOL _isNumberImageRequestValid;
STPCardLoadingIndicator *_metadataLoadingIndicator;
}
@property (nonatomic, readwrite, weak) UIImageView *brandImageView;
@ -89,6 +91,7 @@ NS_INLINE CGFloat stp_ceilCGFloat(CGFloat x) {
#endif
}
static const NSTimeInterval kCardLoadingAnimationDelay = 0.1;
@implementation STPPaymentCardTextField
@ -686,7 +689,7 @@ CGFloat const STPPaymentCardTextFieldMinimumPadding = 10;
switch (fieldType) {
case STPCardFieldTypeNumber:
// no-op, we don't autoadvance for number so we want to leave as incomplete
state = self.viewModel.hasCompleteMetadataForCardNumber ? [STPCardValidator validationStateForNumber:self.viewModel.cardNumber validatingCardBrand:YES] : STPCardValidationStateIncomplete;
break;
case STPCardFieldTypeExpiration:
@ -897,7 +900,9 @@ typedef NS_ENUM(NSInteger, STPCardTextFieldState) {
}
- (CGRect)brandImageRectForBounds:(CGRect)bounds {
return CGRectMake(STPPaymentCardTextFieldDefaultPadding, -1, self.brandImageView.image.size.width, bounds.size.height);
CGFloat height = (CGFloat)MIN(bounds.size.height, self.brandImageView.image.size.height);
// the -1 to y here helps the image actually be centered
return CGRectMake(STPPaymentCardTextFieldDefaultPadding, 0.5f*bounds.size.height - 0.5f*height -1, self.brandImageView.image.size.width, height);
}
- (CGRect)fieldsRectForBounds:(CGRect)bounds {
@ -1317,15 +1322,45 @@ typedef void (^STPLayoutAnimationCompletionBlock)(BOOL completed);
[self updateCVCPlaceholder];
self.cvcField.validText = [self.viewModel validationStateForCVC] != STPCardValidationStateInvalid;
[self updateImageForFieldType:fieldType];
[self.viewModel validationStateForCardNumberWithHandler:^(STPCardValidationState state) {
if ([self.viewModel.cardNumber isEqualToString:number]) {
[self updateCVCPlaceholder];
self.cvcField.validText = [self.viewModel validationStateForCVC] != STPCardValidationStateInvalid;
formTextField.validText = state != STPCardValidationStateInvalid;
if (self.viewModel.hasCompleteMetadataForCardNumber) {
STPCardValidationState state = [STPCardValidator validationStateForNumber:self.viewModel.cardNumber validatingCardBrand:YES];
[self updateCVCPlaceholder];
self.cvcField.validText = [self.viewModel validationStateForCVC] != STPCardValidationStateInvalid;
formTextField.validText = state != STPCardValidationStateInvalid;
if (state == STPCardValidationStateValid) {
// auto-advance
[[self nextFirstResponderField] becomeFirstResponder];
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, nil);
}
// no auto-advance
}];
} else {
[self.viewModel validationStateForCardNumberWithHandler:^(STPCardValidationState state) {
if ([self.viewModel.cardNumber isEqualToString:number]) {
[self updateCVCPlaceholder];
self.cvcField.validText = [self.viewModel validationStateForCVC] != STPCardValidationStateInvalid;
formTextField.validText = state != STPCardValidationStateInvalid;
if (state == STPCardValidationStateValid) {
// log that user entered full complete PAN before we got a network response
[[STPAnalyticsClient sharedClient] logUserEnteredCompletePANBeforeMetadataLoadedWithConfiguration:[STPPaymentConfiguration sharedConfiguration]];
}
[self onChange];
}
// Update image on response because we may want to remove the loading indicator
[self updateImageForFieldType:([self currentFirstResponderField] ?: self.numberField).tag];
// no auto-advance
}];
if (self.viewModel.isNumberMaxLength) {
BOOL isValidLuhn = [STPCardValidator stringIsValidLuhn:self.viewModel.cardNumber];
formTextField.validText = isValidLuhn;
if (isValidLuhn) {
// auto-advance
[[self nextFirstResponderField] becomeFirstResponder];
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, nil);
}
}
}
break;
}
@ -1557,7 +1592,11 @@ typedef NS_ENUM(NSInteger, STPFieldEditingTransitionCallSite) {
if (validationState == STPCardValidationStateInvalid) {
return [self.class errorImageForCardBrand:self.viewModel.brand];
} else {
return [self.class brandImageForCardBrand:self.viewModel.brand];
if (self.viewModel.hasCompleteMetadataForCardNumber) {
return [self.class brandImageForCardBrand:self.viewModel.brand];
} else {
return [self.class brandImageForCardBrand:STPCardBrandUnknown];
}
}
case STPCardFieldTypeCVC:
return [self.class cvcImageForCardBrand:self.viewModel.brand];
@ -1598,67 +1637,98 @@ typedef NS_ENUM(NSInteger, STPFieldEditingTransitionCallSite) {
- (void)updateImageForFieldType:(STPCardFieldType)fieldType {
void (^applyImage)(STPCardFieldType, STPCardValidationState) = ^void(STPCardFieldType applyFieldType, STPCardValidationState validationState) {
UIImage *image = [self brandImageForFieldType:applyFieldType validationState:validationState];
if (![image isEqual:self.brandImageView.image]) {
STPCardBrand newBrand = self.viewModel.brand;
UIViewAnimationOptions imageAnimationOptions = [self brandImageAnimationOptionsForNewType:fieldType
newBrand:newBrand
oldType:self.currentBrandImageFieldType
oldBrand:self.currentBrandImageBrand];
self.currentBrandImageFieldType = applyFieldType;
self.currentBrandImageBrand = newBrand;
[UIView transitionWithView:self.brandImageView
duration:0.2
options:imageAnimationOptions
animations:^{
self.brandImageView.image = image;
}
completion:nil];
}
void (^addLoadingIndicator)(void) = ^{
if (self->_metadataLoadingIndicator == nil) {
self->_metadataLoadingIndicator = [[STPCardLoadingIndicator alloc] init];
self->_metadataLoadingIndicator.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:self->_metadataLoadingIndicator];
[NSLayoutConstraint activateConstraints:@[
[self->_metadataLoadingIndicator.rightAnchor constraintEqualToAnchor:self.brandImageView.rightAnchor],
[self->_metadataLoadingIndicator.topAnchor constraintEqualToAnchor:self.brandImageView.topAnchor],
]];
}
STPCardLoadingIndicator *loadingIndicator = self->_metadataLoadingIndicator;
if (!loadingIndicator.isHidden) {
return;
}
loadingIndicator.alpha = 0.f;
loadingIndicator.hidden = NO;
[UIView animateWithDuration:0.6 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
loadingIndicator.alpha = 1.f;
} completion:^(__unused BOOL finished) {
loadingIndicator.alpha = 1.f;
}];
};
switch (fieldType) {
void (^removeLoadingIndicator)(void) = ^{
if (self->_metadataLoadingIndicator != nil && !self->_metadataLoadingIndicator.isHidden) {
STPCardLoadingIndicator *loadingIndicator = self->_metadataLoadingIndicator;
case STPCardFieldTypeNumber: {
if (self.currentBrandImageFieldType != STPCardFieldTypeNumber) {
STPCardValidationState cardStateForImmediateUpdate = self.numberField.validText ? STPCardValidationStateIncomplete : STPCardValidationStateInvalid;
applyImage(fieldType, cardStateForImmediateUpdate);
}
STPCardFieldType currentImageFieldType = self.currentBrandImageFieldType;
NSString *currentNumber = [self.numberField.text copy];
_isNumberImageRequestValid = YES;
[self.viewModel validationStateForCardNumberWithHandler:^(STPCardValidationState state) {
if (self->_isNumberImageRequestValid &&
currentImageFieldType == self.currentBrandImageFieldType &&
([currentNumber isEqualToString:self.numberField.text] || (currentNumber == nil && self.numberField.text == nil))) {
// we haven't requested image for another field and the card number has not changed. Update with this state
applyImage(fieldType, state);
self->_isNumberImageRequestValid = NO;
}
[UIView animateWithDuration:0.6 delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
loadingIndicator.alpha = 0.f;
} completion:^(__unused BOOL finished) {
loadingIndicator.alpha = 0.f;
loadingIndicator.hidden = YES;
}];
}
break;
};
void (^applyBrandImage)(STPCardFieldType, STPCardValidationState) = ^void(STPCardFieldType applyFieldType, STPCardValidationState validationState) {
UIImage *image = [self brandImageForFieldType:applyFieldType validationState:validationState];
if (![image isEqual:self.brandImageView.image]) {
case STPCardFieldTypeExpiration:
_isNumberImageRequestValid = NO;
applyImage(fieldType, [self.viewModel validationStateForExpiration]);
break;
STPCardBrand newBrand = self.viewModel.brand;
UIViewAnimationOptions imageAnimationOptions = [self brandImageAnimationOptionsForNewType:fieldType
newBrand:newBrand
oldType:self.currentBrandImageFieldType
oldBrand:self.currentBrandImageBrand];
case STPCardFieldTypeCVC:
_isNumberImageRequestValid = NO;
applyImage(fieldType, [self.viewModel validationStateForCVC]);
break;
self.currentBrandImageFieldType = applyFieldType;
self.currentBrandImageBrand = newBrand;
case STPCardFieldTypePostalCode:
_isNumberImageRequestValid = NO;
applyImage(fieldType, [self.viewModel validationStateForPostalCode]);
break;
[UIView transitionWithView:self.brandImageView
duration:0.2
options:imageAnimationOptions
animations:^{
self.brandImageView.image = image;
}
completion:nil];
}
};
if (!self.viewModel.hasCompleteMetadataForCardNumber && [STPBINRange isLoadingCardMetadataForPrefix:self.viewModel.cardNumber]) {
applyBrandImage(STPCardFieldTypeNumber, STPCardValidationStateIncomplete);
// delay a bit before showing loading indicator because the response may come quickly
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kCardLoadingAnimationDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (!self.viewModel.hasCompleteMetadataForCardNumber && [STPBINRange isLoadingCardMetadataForPrefix:self.viewModel.cardNumber]) {
addLoadingIndicator();
}
});
} else {
removeLoadingIndicator();
switch (fieldType) {
case STPCardFieldTypeNumber:
applyBrandImage(STPCardFieldTypeNumber, [STPCardValidator validationStateForNumber:self.viewModel.cardNumber validatingCardBrand:YES]);
break;
case STPCardFieldTypeExpiration:
applyBrandImage(fieldType, [self.viewModel validationStateForExpiration]);
break;
case STPCardFieldTypeCVC:
applyBrandImage(fieldType, [self.viewModel validationStateForCVC]);
break;
case STPCardFieldTypePostalCode:
applyBrandImage(fieldType, [self.viewModel validationStateForPostalCode]);
break;
}
}
}
- (NSString *)defaultCVCPlaceholder {
@ -1702,10 +1772,17 @@ typedef NS_ENUM(NSInteger, STPFieldEditingTransitionCallSite) {
+ (NSSet<NSString *> *)keyPathsForValuesAffectingIsValid {
return [NSSet setWithArray:@[
[NSString stringWithFormat:@"%@.%@",
NSStringFromSelector(@selector(viewModel)),
NSStringFromSelector(@selector(valid))],
]];
// viewModel.valid
[NSString stringWithFormat:@"%@.%@",
NSStringFromSelector(@selector(viewModel)),
NSStringFromSelector(@selector(valid))],
// viewModel.hasCompleteMetadataForCardNumber
[NSString stringWithFormat:@"%@.%@",
NSStringFromSelector(@selector(viewModel)),
NSStringFromSelector(@selector(hasCompleteMetadataForCardNumber))],
]
];
}
@end

View File

@ -35,6 +35,8 @@ typedef NS_ENUM(NSInteger, STPCardFieldType) {
@property (nonatomic, copy, nullable) NSString *postalCodeCountryCode;
@property (nonatomic, readonly) STPCardBrand brand;
@property (nonatomic, readonly) BOOL isValid;
@property (nonatomic, readonly) BOOL hasCompleteMetadataForCardNumber;
@property (nonatomic, readonly) BOOL isNumberMaxLength;
- (NSString *)defaultPlaceholder;
- (nullable NSString *)compressedCardNumberWithPlaceholder:(nullable NSString *)placeholder;

View File

@ -13,10 +13,24 @@
#import "STPCardValidator+Private.h"
#import "STPPostalCodeValidator.h"
@interface STPPaymentCardTextFieldViewModel ()
@property (nonatomic, readwrite) BOOL hasCompleteMetadataForCardNumber;
@end
@implementation STPPaymentCardTextFieldViewModel
- (void)setCardNumber:(NSString *)cardNumber {
_cardNumber = [STPCardValidator sanitizedNumericStringForString:cardNumber];
NSString *sanitizedNumber = [STPCardValidator sanitizedNumericStringForString:cardNumber];
self.hasCompleteMetadataForCardNumber = [STPBINRange hasBINRangesForPrefix:sanitizedNumber];
if (self.hasCompleteMetadataForCardNumber) {
STPCardBrand brand = [STPCardValidator brandForNumber:sanitizedNumber];
NSInteger maxLength = [STPCardValidator maxLengthForCardBrand:brand];
_cardNumber = [sanitizedNumber stp_safeSubstringToIndex:maxLength];
} else {
_cardNumber = [sanitizedNumber stp_safeSubstringToIndex:[STPBINRange maxCardNumberLength]];
}
}
- (nullable NSString *)compressedCardNumberWithPlaceholder:(nullable NSString *)placeholder {
@ -116,6 +130,10 @@
}
}
- (BOOL)isNumberMaxLength {
return self.cardNumber.length == [STPBINRange maxCardNumberLength];
}
- (STPCardValidationState)validationStateForPostalCode {
if (self.postalCode.length > 0) {
return STPCardValidationStateValid;
@ -126,12 +144,14 @@
- (void)validationStateForCardNumberWithHandler:(void (^)(STPCardValidationState))handler {
[STPBINRange retrieveBINRangesForPrefix:self.cardNumber completion:^(__unused NSArray<STPBINRange *> * _Nullable ranges, __unused NSError * _Nullable error) {
self.hasCompleteMetadataForCardNumber = [STPBINRange hasBINRangesForPrefix:self.cardNumber];
handler([STPCardValidator validationStateForNumber:self.cardNumber validatingCardBrand:YES]);
}];
}
- (BOOL)isValid {
return ([STPCardValidator validationStateForNumber:self.cardNumber validatingCardBrand:YES] == STPCardValidationStateValid
&& self.hasCompleteMetadataForCardNumber
&& [self validationStateForExpiration] == STPCardValidationStateValid
&& [self validationStateForCVC] == STPCardValidationStateValid
&& (!self.postalCodeRequired
@ -156,6 +176,7 @@
NSStringFromSelector(@selector(postalCode)),
NSStringFromSelector(@selector(postalCodeRequested)),
NSStringFromSelector(@selector(postalCodeCountryCode)),
NSStringFromSelector(@selector(hasCompleteMetadataForCardNumber)),
]];
}

View File

@ -114,8 +114,10 @@
}
XCTAssertEqual(STPCardValidationStateIncomplete, [STPCardValidator validationStateForNumber:@"1" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateValid, [STPCardValidator validationStateForNumber:@"0000000000000000" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateValid, [STPCardValidator validationStateForNumber:@"9999999999999995" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateIncomplete, [STPCardValidator validationStateForNumber:@"0000000000000000" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateIncomplete, [STPCardValidator validationStateForNumber:@"9999999999999995" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateValid, [STPCardValidator validationStateForNumber:@"0000000000000000000" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateValid, [STPCardValidator validationStateForNumber:@"9999999999999999998" validatingCardBrand:NO]);
XCTAssertEqual(STPCardValidationStateIncomplete, [STPCardValidator validationStateForNumber:@"4242424242424" validatingCardBrand:YES]);
XCTAssertEqual(STPCardValidationStateIncomplete, [STPCardValidator validationStateForNumber:nil validatingCardBrand:YES]);
}
@ -135,7 +137,7 @@
@[@(STPCardBrandDinersClub), @[@14, @16]],
@[@(STPCardBrandJCB), @[@16]],
@[@(STPCardBrandUnionPay), @[@16]],
@[@(STPCardBrandUnknown), @[@16]],
@[@(STPCardBrandUnknown), @[@19]],
];
for (NSArray *test in tests) {
NSSet *lengths = [STPCardValidator lengthsForCardBrand:[test[0] integerValue]];

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,7 @@
@[@"4242424242424242", @"4242424242424242"],
@[@"4242 4242 4242 4242", @"4242424242424242"],
@[@"4242xxx4242", @"42424242"],
@[@"12345678901234567890", @"12345678901234567890"],
@[@"12345678901234567890", @"1234567890123456789"],
];
for (NSArray *test in tests) {
self.viewModel.cardNumber = test[0];