diff --git a/Stripe.xcodeproj/project.pbxproj b/Stripe.xcodeproj/project.pbxproj index c67db2c5089..1356cb7431e 100644 --- a/Stripe.xcodeproj/project.pbxproj +++ b/Stripe.xcodeproj/project.pbxproj @@ -644,6 +644,7 @@ F15675401DB544D3004468E3 /* STPAddCardViewControllerLocalizationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F156753F1DB544D3004468E3 /* STPAddCardViewControllerLocalizationTests.m */; }; F15AC18E1DBA9CA90009EADE /* FBSnapshotTestCase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F15AC18D1DBA9CA90009EADE /* FBSnapshotTestCase.framework */; }; F15AC1901DBA9CC60009EADE /* FBSnapshotTestCase.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = F15AC18D1DBA9CA90009EADE /* FBSnapshotTestCase.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + F16AA26F1F5A0F1700207FFF /* AlipaySource.json in Resources */ = {isa = PBXBuildFile; fileRef = F16AA26D1F5A05A100207FFF /* AlipaySource.json */; }; F1852F931D80B6EC00367C86 /* STPStringUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = F1852F911D80B6EC00367C86 /* STPStringUtils.h */; }; F1852F941D80B6EC00367C86 /* STPStringUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = F1852F911D80B6EC00367C86 /* STPStringUtils.h */; }; F1852F951D80B6EC00367C86 /* STPStringUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = F1852F921D80B6EC00367C86 /* STPStringUtils.m */; }; @@ -1159,6 +1160,7 @@ F15232301EA93E6800D65C67 /* Contacts.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Contacts.framework; path = System/Library/Frameworks/Contacts.framework; sourceTree = SDKROOT; }; F156753F1DB544D3004468E3 /* STPAddCardViewControllerLocalizationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = STPAddCardViewControllerLocalizationTests.m; sourceTree = ""; }; F15AC18D1DBA9CA90009EADE /* FBSnapshotTestCase.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FBSnapshotTestCase.framework; path = Carthage/Build/iOS/FBSnapshotTestCase.framework; sourceTree = ""; }; + F16AA26D1F5A05A100207FFF /* AlipaySource.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = AlipaySource.json; sourceTree = ""; }; F1852F911D80B6EC00367C86 /* STPStringUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = STPStringUtils.h; sourceTree = ""; }; F1852F921D80B6EC00367C86 /* STPStringUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = STPStringUtils.m; sourceTree = ""; }; F19491D81E5F606F001E1FC2 /* STPSourceCardDetails.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = STPSourceCardDetails.m; sourceTree = ""; }; @@ -1471,6 +1473,7 @@ F1BA241C1E57BE5700E4A1CF /* CardSource.json */, F152322E1EA9344000D65C67 /* iDEALSource.json */, 8BD2133D1F045D31007F6FD1 /* SEPADebitSource.json */, + F16AA26D1F5A05A100207FFF /* AlipaySource.json */, ); name = Source; sourceTree = ""; @@ -2406,6 +2409,7 @@ 8BD213391F0457A1007F6FD1 /* FileUpload.json in Resources */, C1CFCB7A1ED5F88D00BE45DF /* stp_test_upload_image.jpeg in Resources */, F152322F1EA9344600D65C67 /* iDEALSource.json in Resources */, + F16AA26F1F5A0F1700207FFF /* AlipaySource.json in Resources */, 8BD2133C1F0458F5007F6FD1 /* BitcoinSource.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Stripe/PublicHeaders/STPRedirectContext.h b/Stripe/PublicHeaders/STPRedirectContext.h index e6b2bffb6b6..0cb42c26a3f 100644 --- a/Stripe/PublicHeaders/STPRedirectContext.h +++ b/Stripe/PublicHeaders/STPRedirectContext.h @@ -125,6 +125,10 @@ NS_EXTENSION_UNAVAILABLE("Redirect based sources are not available in extensions over the redirect method, you can use `startSafariViewControllerRedirectFlowFromViewController` or `startSafariAppRedirectFlow` + + If the source supports a native app, and that app is is installed on the user's + device, this call will do a direct app-to-app redirect instead of showing + a web url. @note This method does nothing if the context is not in the `STPRedirectContextStateNotStarted` state. diff --git a/Stripe/STPRedirectContext.m b/Stripe/STPRedirectContext.m index 7855637bad2..f29350a5f55 100644 --- a/Stripe/STPRedirectContext.m +++ b/Stripe/STPRedirectContext.m @@ -11,6 +11,7 @@ #import "STPDispatchFunctions.h" #import "STPSource.h" #import "STPURLCallbackHandler.h" +#import "STPWeakStrongMacros.h" #import "NSError+Stripe.h" #import @@ -19,10 +20,13 @@ NS_ASSUME_NONNULL_BEGIN +typedef void (^STPBoolCompletionBlock)(BOOL success); + @interface STPRedirectContext () @property (nonatomic, copy) STPRedirectContextCompletionBlock completion; @property (nonatomic, strong) STPSource *source; @property (nonatomic, strong, nullable) SFSafariViewController *safariVC; +@property (nonatomic, assign, readwrite) STPRedirectContextState state; @end @implementation STPRedirectContext @@ -31,8 +35,10 @@ - (nullable instancetype)initWithSource:(STPSource *)source completion:(STPRedirectContextCompletionBlock)completion { if (source.flow != STPSourceFlowRedirect - || source.redirect.url == nil - || source.redirect.returnURL == nil) { + || source.status != STPSourceStatusPending + || source.redirect.returnURL == nil + || (source.redirect.url == nil + && [self nativeRedirectURLForSource:source] == nil)) { return nil; } @@ -48,16 +54,64 @@ - (void)dealloc { [self unsubscribeFromNotificationsAndDismissPresentedViewControllers]; } -- (void)startRedirectFlowFromViewController:(UIViewController *)presentingViewController { +- (void)performAppRedirectIfPossibleWithCompletion:(STPBoolCompletionBlock)onCompletion { FAUXPAS_IGNORED_IN_METHOD(APIAvailability) - if ([SFSafariViewController class] != nil) { - [self startSafariViewControllerRedirectFlowFromViewController:presentingViewController]; + + if (self.state == STPRedirectContextStateNotStarted) { + NSURL *nativeUrl = [self nativeRedirectURLForSource:self.source]; + if (!nativeUrl) { + onCompletion(NO); + return; + } + + // Optimistically start listening in case we get app switched away. + // If the app switch fails we'll undo this later + self.state = STPRedirectContextStateInProgress; + [self subscribeToUrlAndForegroundNotifications]; + + UIApplication *application = [UIApplication sharedApplication]; + if ([application respondsToSelector:@selector(openURL:options:completionHandler:)]) { + + WEAK(self); + [application openURL:nativeUrl options:@{} completionHandler:^(BOOL success) { + if (!success) { + STRONG(self); + self.state = STPRedirectContextStateNotStarted; + [self unsubscribeFromNotifications]; + } + onCompletion(success); + }]; + } + else { + _state = STPRedirectContextStateInProgress; + BOOL opened = [application openURL:nativeUrl]; + if (!opened) { + self.state = STPRedirectContextStateNotStarted; + [self unsubscribeFromNotifications]; + } + onCompletion(opened); + } } else { - [self startSafariAppRedirectFlow]; + onCompletion(NO); } } +- (void)startRedirectFlowFromViewController:(UIViewController *)presentingViewController { + FAUXPAS_IGNORED_IN_METHOD(APIAvailability) + + [self performAppRedirectIfPossibleWithCompletion:^(BOOL success) { + if (!success) { + if ([SFSafariViewController class] != nil) { + [self startSafariViewControllerRedirectFlowFromViewController:presentingViewController]; + } + else { + [self startSafariAppRedirectFlow]; + } + } + }]; +} + - (void)startSafariViewControllerRedirectFlowFromViewController:(UIViewController *)presentingViewController { FAUXPAS_IGNORED_IN_METHOD(APIAvailability) if (self.state == STPRedirectContextStateNotStarted) { @@ -73,7 +127,7 @@ - (void)startSafariViewControllerRedirectFlowFromViewController:(UIViewControlle - (void)startSafariAppRedirectFlow { if (self.state == STPRedirectContextStateNotStarted) { - _state = STPRedirectContextStateInProgress; + self.state = STPRedirectContextStateInProgress; [self subscribeToUrlAndForegroundNotifications]; [[UIApplication sharedApplication] openURL:self.source.redirect.url]; } @@ -81,7 +135,7 @@ - (void)startSafariAppRedirectFlow { - (void)cancel { if (self.state == STPRedirectContextStateInProgress) { - _state = STPRedirectContextStateCancelled; + self.state = STPRedirectContextStateCancelled; [self unsubscribeFromNotificationsAndDismissPresentedViewControllers]; } } @@ -128,7 +182,7 @@ - (void)handleRedirectCompletionWithError:(nullable NSError *)error return; } - _state = STPRedirectContextStateCompleted; + self.state = STPRedirectContextStateCompleted; [self unsubscribeFromNotifications]; @@ -167,6 +221,21 @@ - (void)dismissPresentedViewController { } } +- (nullable NSURL *)nativeRedirectURLForSource:(STPSource *)source { + NSString *nativeUrlString = nil; + switch (source.type) { + case STPSourceTypeAlipay: + nativeUrlString = source.details[@"native_url"]; + break; + default: + // All other sources currently have no native url support + break; + } + + NSURL *nativeUrl = nativeUrlString ? [NSURL URLWithString:nativeUrlString] : nil; + return nativeUrl; +} + @end NS_ASSUME_NONNULL_END diff --git a/Stripe/STPSourceParams.m b/Stripe/STPSourceParams.m index 599e0c589f7..e037db513d0 100644 --- a/Stripe/STPSourceParams.m +++ b/Stripe/STPSourceParams.m @@ -9,6 +9,7 @@ #import "STPSourceParams.h" #import "STPSourceParams+Private.h" +#import "NSBundle+Stripe_AppName.h" #import "STPCardParams.h" #import "STPFormEncoder.h" #import "STPSource+Private.h" @@ -256,6 +257,17 @@ + (STPSourceParams *)alipayParamsWithAmount:(NSUInteger)amount params.amount = @(amount); params.currency = currency; params.redirect = @{ @"return_url": returnURL }; + + NSString *bundleID = [[NSBundle mainBundle] bundleIdentifier]; + NSString *versionKey = [NSBundle stp_applicationVersion]; + if (bundleID && versionKey) { + params.additionalAPIParameters = @{ + @"alipay": @{ + @"app_bundle_id": bundleID, + @"app_version_key": versionKey, + }, + }; + } return params; } diff --git a/Tests/Tests/AlipaySource.json b/Tests/Tests/AlipaySource.json new file mode 100644 index 00000000000..1f0ad79a014 --- /dev/null +++ b/Tests/Tests/AlipaySource.json @@ -0,0 +1,33 @@ +{ + "id": "src_123", + "object": "source", + "amount": 1099, + "client_secret": "src_client_secret_123", + "created": 1445277809, + "currency": "usd", + "flow": "redirect", + "livemode": true, + "owner": { + "address": null, + "email": null, + "name": null, + "phone": null, + "verified_address": null, + "verified_email": null, + "verified_name": null, + "verified_phone": null, + }, + "redirect": { + "return_url": "https://shop.foo.com/crtABC", + "status": "pending", + "url": "https://pay.stripe.com/redirect/src_123?client_secret=src_client_secret_123" + }, + "statement_descriptor": null, + "status": "pending", + "type": "alipay", + "usage": "single_use", + "alipay": { + "statement_descriptor": null, + "native_url": null + } +} diff --git a/Tests/Tests/STPFixtures.h b/Tests/Tests/STPFixtures.h index aa93d130353..9a6a7c60c55 100644 --- a/Tests/Tests/STPFixtures.h +++ b/Tests/Tests/STPFixtures.h @@ -54,6 +54,16 @@ */ + (STPSource *)iDEALSource; +/** + A Source object with type Alipay + */ ++ (STPSource *)alipaySource; + +/** + A Source object with type Alipay and a native redirect url + */ ++ (STPSource *)alipaySourceWithNativeUrl; + /** A PaymentConfiguration object with a fake publishable key. Use this to avoid triggering our asserts when publishable key is nil or invalid. All other values diff --git a/Tests/Tests/STPFixtures.m b/Tests/Tests/STPFixtures.m index d1098139705..028ad0673e8 100644 --- a/Tests/Tests/STPFixtures.m +++ b/Tests/Tests/STPFixtures.m @@ -71,6 +71,18 @@ + (STPSource *)iDEALSource { return [STPSource decodedObjectFromAPIResponse:[STPTestUtils jsonNamed:@"iDEALSource"]]; } ++ (STPSource *)alipaySource { + return [STPSource decodedObjectFromAPIResponse:[STPTestUtils jsonNamed:@"AlipaySource"]]; +} + ++ (STPSource *)alipaySourceWithNativeUrl { + NSMutableDictionary *dictionary = [STPTestUtils jsonNamed:@"AlipaySource"].mutableCopy; + NSMutableDictionary *detailsDictionary = ((NSDictionary *)dictionary[@"alipay"]).mutableCopy; + detailsDictionary[@"native_url"] = @"alipay://test"; + dictionary[@"alipay"] = detailsDictionary; + return [STPSource decodedObjectFromAPIResponse:dictionary]; +} + + (STPPaymentConfiguration *)paymentConfiguration { STPPaymentConfiguration *config = [STPPaymentConfiguration new]; config.publishableKey = @"pk_fake_publishable_key"; diff --git a/Tests/Tests/STPRedirectContextTest.m b/Tests/Tests/STPRedirectContextTest.m index bd4004ff320..1e15f4facdc 100644 --- a/Tests/Tests/STPRedirectContextTest.m +++ b/Tests/Tests/STPRedirectContextTest.m @@ -282,4 +282,62 @@ - (void)testSafariAppRedirectFlow_noNotification { OCMReject([sut dismissPresentedViewController]); } +/** + If a source type that supports native redirect is used and it contains a native + url, an app to app redirect should attempt to be initiated. + */ +- (void)testNativeRedirectSupportingSourceFlow_validNativeURL { + STPSource *source = [STPFixtures alipaySourceWithNativeUrl]; + NSURL *sourceURL = [NSURL URLWithString:source.details[@"native_url"]]; + + STPRedirectContext *context = [[STPRedirectContext alloc] initWithSource:source + completion:^(__unused NSString *sourceID, __unused NSString *clientSecret, __unused NSError *error) { + XCTFail(@"completion called"); + }]; + id sut = OCMPartialMock(context); + + id applicationMock = OCMClassMock([UIApplication class]); + OCMStub([applicationMock sharedApplication]).andReturn(applicationMock); + OCMStub([applicationMock openURL:[OCMArg any] + options:[OCMArg any] + completionHandler:([OCMArg invokeBlockWithArgs:@YES, nil])]); + + id mockVC = OCMClassMock([UIViewController class]); + [context startRedirectFlowFromViewController:mockVC]; + + OCMReject([sut startSafariViewControllerRedirectFlowFromViewController:[OCMArg any]]); + OCMReject([sut startSafariAppRedirectFlow]); + OCMVerify([applicationMock openURL:[OCMArg isEqual:sourceURL] + options:[OCMArg isEqual:@{}] + completionHandler:[OCMArg isNotNil]]); +} + +/** + If a source type that supports native redirect is used and it does not + contain a native url, standard web view redirect should be attempted + */ +- (void)testNativeRedirectSupportingSourceFlow_invalidNativeURL { + STPSource *source = [STPFixtures alipaySource]; + + STPRedirectContext *context = [[STPRedirectContext alloc] initWithSource:source + completion:^(__unused NSString *sourceID, __unused NSString *clientSecret, __unused NSError *error) { + XCTFail(@"completion called"); + }]; + id sut = OCMPartialMock(context); + + id applicationMock = OCMClassMock([UIApplication class]); + OCMStub([applicationMock sharedApplication]).andReturn(applicationMock); + + id mockVC = OCMClassMock([UIViewController class]); + [context startRedirectFlowFromViewController:mockVC]; + + OCMVerify([sut startSafariViewControllerRedirectFlowFromViewController:[OCMArg isEqual:mockVC]]); + OCMReject([applicationMock openURL:[OCMArg any] + options:[OCMArg any] + completionHandler:[OCMArg any]]); + OCMVerify([mockVC presentViewController:[OCMArg isKindOfClass:[SFSafariViewController class]] + animated:YES + completion:[OCMArg isNil]]); +} + @end