From b0e4a3048e952628ef72f4d21d25af0a87d89923 Mon Sep 17 00:00:00 2001 From: William Denniss Date: Thu, 8 Feb 2018 20:59:48 -0800 Subject: [PATCH] Add custom browser support - An UICoordinator for iOS to support auth with a custom iOS browser like Chrome or Firefox. - Implements #200. --- AppAuth.xcodeproj/project.pbxproj | 10 ++ Source/AppAuth.h | 1 + Source/Framework/AppAuth.h | 1 + Source/OIDURLQueryComponent.h | 6 + Source/OIDURLQueryComponent.m | 14 +- ...DAuthorizationUICoordinatorCustomBrowser.h | 106 +++++++++++ ...DAuthorizationUICoordinatorCustomBrowser.m | 168 ++++++++++++++++++ 7 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 Source/iOS/OIDAuthorizationUICoordinatorCustomBrowser.h create mode 100644 Source/iOS/OIDAuthorizationUICoordinatorCustomBrowser.m diff --git a/AppAuth.xcodeproj/project.pbxproj b/AppAuth.xcodeproj/project.pbxproj index 6657b7c8b..5f424427b 100644 --- a/AppAuth.xcodeproj/project.pbxproj +++ b/AppAuth.xcodeproj/project.pbxproj @@ -341,6 +341,9 @@ 343AAB9B1E834A8800F9D36E /* AppAuth.h in Headers */ = {isa = PBXBuildFile; fileRef = 343AAA4D1E8345B600F9D36E /* AppAuth.h */; settings = {ATTRIBUTES = (Public, ); }; }; 343AAB9C1E834A8900F9D36E /* AppAuth.h in Headers */ = {isa = PBXBuildFile; fileRef = 343AAA4D1E8345B600F9D36E /* AppAuth.h */; settings = {ATTRIBUTES = (Public, ); }; }; 343AAB9D1E834A8A00F9D36E /* AppAuth.h in Headers */ = {isa = PBXBuildFile; fileRef = 343AAA4D1E8345B600F9D36E /* AppAuth.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 345AE747202D526900738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.m in Sources */ = {isa = PBXBuildFile; fileRef = 345AE745202D526800738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.m */; }; + 345AE748202D526900738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.m in Sources */ = {isa = PBXBuildFile; fileRef = 345AE745202D526800738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.m */; }; + 345AE749202D526900738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.h in Headers */ = {isa = PBXBuildFile; fileRef = 345AE746202D526800738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.h */; settings = {ATTRIBUTES = (Public, ); }; }; 347423E41E7F3C4000D3E6D6 /* OIDAuthorizationResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = 341741B71C5D8243000EF209 /* OIDAuthorizationResponse.m */; }; 347423FF1E7F4BA000D3E6D6 /* OIDAuthorizationRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = 341741B51C5D8243000EF209 /* OIDAuthorizationRequest.m */; }; 347424001E7F4BA000D3E6D6 /* OIDAuthorizationResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = 341741B71C5D8243000EF209 /* OIDAuthorizationResponse.m */; }; @@ -542,6 +545,8 @@ 343AAAAE1E83489A00F9D36E /* AppAuth_tvOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AppAuth_tvOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 343AAAC21E8348A900F9D36E /* AppAuth.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppAuth.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 343AAACA1E8348AA00F9D36E /* AppAuth_macOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AppAuth_macOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 345AE745202D526800738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OIDAuthorizationUICoordinatorCustomBrowser.m; path = iOS/OIDAuthorizationUICoordinatorCustomBrowser.m; sourceTree = ""; }; + 345AE746202D526800738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OIDAuthorizationUICoordinatorCustomBrowser.h; path = iOS/OIDAuthorizationUICoordinatorCustomBrowser.h; sourceTree = ""; }; 347423F61E7F4B5600D3E6D6 /* libAppAuth-watchOS.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libAppAuth-watchOS.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 34D5EC431E6D1AD900814354 /* OIDAppAuthTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OIDAppAuthTests-Bridging-Header.h"; sourceTree = ""; }; 34D5EC441E6D1AD900814354 /* OIDSwiftTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OIDSwiftTests.swift; sourceTree = ""; }; @@ -847,6 +852,8 @@ children = ( F6F60FB31D2BFEFE00325CB3 /* OIDAuthorizationService+IOS.h */, F6F60FB11D2BFEFE00325CB3 /* OIDAuthorizationService+IOS.m */, + 345AE746202D526800738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.h */, + 345AE745202D526800738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.m */, F6F60FB51D2BFEFE00325CB3 /* OIDAuthState+IOS.h */, F6F60FB01D2BFEFE00325CB3 /* OIDAuthState+IOS.m */, F6F60FB41D2BFEFE00325CB3 /* OIDAuthorizationUICoordinatorIOS.h */, @@ -874,6 +881,7 @@ 343AAAE41E83499000F9D36E /* OIDAuthorizationResponse.h in Headers */, 343AAAF31E83499000F9D36E /* OIDScopes.h in Headers */, 343AAAE81E83499000F9D36E /* OIDAuthStateChangeDelegate.h in Headers */, + 345AE749202D526900738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.h in Headers */, 343AAA6B1E83465500F9D36E /* AppAuth.h in Headers */, 343AAA6E1E83466B00F9D36E /* OIDAuthorizationUICoordinatorIOS.h in Headers */, 343AAAF21E83499000F9D36E /* OIDResponseTypes.h in Headers */, @@ -1457,6 +1465,7 @@ 60140F7A1DE4276800DA0DC3 /* OIDClientMetadataParameters.m in Sources */, 341741DE1C5D8243000EF209 /* OIDAuthState.m in Sources */, 341741DD1C5D8243000EF209 /* OIDAuthorizationService.m in Sources */, + 345AE747202D526900738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.m in Sources */, 340DAECD1D582DE100EC285B /* OIDAuthorizationUICoordinatorIOS.m in Sources */, 341741EB1C5D8243000EF209 /* OIDURLQueryComponent.m in Sources */, 341741E11C5D8243000EF209 /* OIDFieldMapping.m in Sources */, @@ -1579,6 +1588,7 @@ 343AAA931E83478900F9D36E /* OIDTokenUtilities.m in Sources */, 343AAA901E83478900F9D36E /* OIDServiceDiscovery.m in Sources */, 343AAA911E83478900F9D36E /* OIDTokenRequest.m in Sources */, + 345AE748202D526900738D22 /* OIDAuthorizationUICoordinatorCustomBrowser.m in Sources */, 343AAA6F1E83467D00F9D36E /* OIDAuthorizationService+IOS.m in Sources */, 343AAA8F1E83478900F9D36E /* OIDServiceConfiguration.m in Sources */, 343AAA891E83478900F9D36E /* OIDRegistrationResponse.m in Sources */, diff --git a/Source/AppAuth.h b/Source/AppAuth.h index cfd3f9c62..fae50153f 100644 --- a/Source/AppAuth.h +++ b/Source/AppAuth.h @@ -43,6 +43,7 @@ #elif TARGET_OS_IOS #import "OIDAuthState+IOS.h" #import "OIDAuthorizationService+IOS.h" +#import "OIDAuthorizationUICoordinatorCustomBrowser.h" #import "OIDAuthorizationUICoordinatorIOS.h" #elif TARGET_OS_MAC #import "OIDAuthState+Mac.h" diff --git a/Source/Framework/AppAuth.h b/Source/Framework/AppAuth.h index 1275e36f2..c5a3ca26b 100644 --- a/Source/Framework/AppAuth.h +++ b/Source/Framework/AppAuth.h @@ -50,6 +50,7 @@ FOUNDATION_EXPORT const unsigned char AppAuthVersionString[]; #elif TARGET_OS_IOS #import #import +#import #import #elif TARGET_OS_MAC #import diff --git a/Source/OIDURLQueryComponent.h b/Source/OIDURLQueryComponent.h index 068dcc369..2a87d4f81 100644 --- a/Source/OIDURLQueryComponent.h +++ b/Source/OIDURLQueryComponent.h @@ -79,6 +79,12 @@ extern BOOL gOIDURLQueryComponentForceIOS7Handling; */ - (NSString *)URLEncodedParameters; +/*! @brief A NSMutableCharacterSet containing allowed characters in URL parameter values (that is + the "value" part of "?key=value"). This has less allowed characters than + @c URLQueryAllowedCharacterSet, as the query component includes both the key & value. + */ ++ (NSMutableCharacterSet *)URLParamValueAllowedCharacters; + @end NS_ASSUME_NONNULL_END diff --git a/Source/OIDURLQueryComponent.m b/Source/OIDURLQueryComponent.m index 6a70d91ad..0a94ec27a 100644 --- a/Source/OIDURLQueryComponent.m +++ b/Source/OIDURLQueryComponent.m @@ -124,6 +124,15 @@ - (void)addParameters:(NSDictionary *)parameters { return queryParameters; } ++ (NSMutableCharacterSet *)URLParamValueAllowedCharacters { + // Starts with the standard URL-allowed character set. + NSMutableCharacterSet *allowedParamCharacters = + [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy]; + // Removes additional characters we don't want to see in the query component. + [allowedParamCharacters removeCharactersInString:kQueryStringParamAdditionalDisallowedCharacters]; + return allowedParamCharacters; +} + /*! @brief Builds a query string that can be set to @c NSURLComponents.percentEncodedQuery @discussion This string is percent encoded, and shouldn't be used with @c NSURLComponents.query. @@ -133,10 +142,7 @@ - (NSString *)percentEncodedQueryString { NSMutableArray *parameterizedValues = [NSMutableArray array]; // Starts with the standard URL-allowed character set. - NSMutableCharacterSet *allowedParamCharacters = - [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy]; - // Removes additional characters we don't want to see in the query component. - [allowedParamCharacters removeCharactersInString:kQueryStringParamAdditionalDisallowedCharacters]; + NSMutableCharacterSet *allowedParamCharacters = [[self class] URLParamValueAllowedCharacters]; for (NSString *parameterName in _parameters.allKeys) { NSString *encodedParameterName = diff --git a/Source/iOS/OIDAuthorizationUICoordinatorCustomBrowser.h b/Source/iOS/OIDAuthorizationUICoordinatorCustomBrowser.h new file mode 100644 index 000000000..32c083236 --- /dev/null +++ b/Source/iOS/OIDAuthorizationUICoordinatorCustomBrowser.h @@ -0,0 +1,106 @@ +/*! @file OIDAuthorizationUICoordinatorCustomBrowser.h + @brief AppAuth iOS SDK + @copyright + Copyright 2018 Google LLC + @copydetails + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "OIDAuthorizationUICoordinator.h" + +NS_ASSUME_NONNULL_BEGIN + +/*! @brief A block that transforms a regular http/https URL into one that will open in an + alternative browser. + @param requestURL the http/https request URL to be transformed. + @return transformed URL. + */ +typedef NSURL *_Nullable (^OIDCustomBrowserURLTransformation)(NSURL *_Nullable requestURL); + +/*! @brief An implementation of the OIDAuthorizationUICoordinator protocol for iOS that uses + a custom browser (i.e. not Safari) for authorization. It is suitable for browsers that + offer a custom url scheme that simply replaces the "https" scheme. It is not designed + for browsers that require other modifications to the URL. If the browser is not installed + the user will be prompted to install it. + */ +@interface OIDAuthorizationUICoordinatorCustomBrowser : NSObject + +/*! @internal + @brief Unavailable. Please use @c initWithURLTransformation:canOpenURLScheme:appStoreURL: + */ +- (nonnull instancetype)init NS_UNAVAILABLE; + +/*! @brief UICoordinator for a custom browser. @c presentAuthorizationRequest:session method + will return NO if the browser isn't installed. + */ +- (nullable instancetype)initWithURLTransformation:(OIDCustomBrowserURLTransformation)URLTransformation; + +/*! @brief The designated initializer. + @param URLTransformation the transformation block to translate the URL into one that will open + in the desired custom browser. + @param canOpenURLScheme any scheme supported by the browser used to check if the browser is + installed. + @param appStoreURL URL of the browser in the app store. When this and @c canOpenURLScheme + are non-nil, @c presentAuthorizationRequest:session will redirect the user to the app store + if the browser is not installed. + */ +- (nullable instancetype)initWithURLTransformation:(OIDCustomBrowserURLTransformation)URLTransformation + canOpenURLScheme:(nullable NSString *)canOpenURLScheme + appStoreURL:(nullable NSURL *)appStoreURL + NS_DESIGNATED_INITIALIZER; + +/*! @brief URL transformation block for the browser. + */ +@property(nonatomic, readonly) OIDCustomBrowserURLTransformation URLTransformation; + +/*! @brief URL Scheme used to test for whether the browser is installed. + */ +@property(nonatomic, readonly, nullable) NSString *canOpenURLScheme; + +/*! @brief URL of the browser's App Store listing. + */ +@property(nonatomic, readonly, nullable) NSURL *appStoreURL; + +/*! @brief Creates a @c OIDCustomBrowserURLTransformation with the URL prefix method used by + iOS browsers like Firefox. + */ ++ (OIDCustomBrowserURLTransformation) URLTransformationSchemeConcatPrefix:(NSString*)URLprefix; + +/*! @brief Creates a @c OIDCustomBrowserURLTransformation using the scheme substitution method used + iOS browsers like Chrome and Firefox. + */ ++ (OIDCustomBrowserURLTransformation) + URLTransformationSchemeSubstitutionHTTPS:(NSString *)browserSchemeHTTPS + HTTP:(nullable NSString *)browserSchemeHTTP; + +/*! @brief An instance of @c OIDAuthorizationUICoordinatorCustomBrowser for Chrome. + */ ++ (OIDAuthorizationUICoordinatorCustomBrowser*)CustomBrowserChrome; + +/*! @brief An instance of @c OIDAuthorizationUICoordinatorCustomBrowser for Firefox. + */ ++ (OIDAuthorizationUICoordinatorCustomBrowser*)CustomBrowserFirefox; + +/*! @brief An instance of @c OIDAuthorizationUICoordinatorCustomBrowser for Opera. + */ ++ (OIDAuthorizationUICoordinatorCustomBrowser*)CustomBrowserOpera; + +/*! @brief An instance of @c OIDAuthorizationUICoordinatorCustomBrowser for Safari. + */ ++ (OIDAuthorizationUICoordinatorCustomBrowser*)CustomBrowserSafari; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Source/iOS/OIDAuthorizationUICoordinatorCustomBrowser.m b/Source/iOS/OIDAuthorizationUICoordinatorCustomBrowser.m new file mode 100644 index 000000000..b397d6cf6 --- /dev/null +++ b/Source/iOS/OIDAuthorizationUICoordinatorCustomBrowser.m @@ -0,0 +1,168 @@ +/*! @file OIDAuthorizationUICoordinatorCustomBrowser.m + @brief AppAuth iOS SDK + @copyright + Copyright 2018 Google LLC + @copydetails + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "OIDAuthorizationUICoordinatorCustomBrowser.h" + +#import + +#import "OIDAuthorizationRequest.h" +#import "OIDAuthorizationService.h" +#import "OIDErrorUtilities.h" +#import "OIDURLQueryComponent.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation OIDAuthorizationUICoordinatorCustomBrowser { + OIDCustomBrowserURLTransformation _URLTransformation; + NSString *_canOpenURLScheme; + NSURL *_appStoreURL; +} + +@synthesize URLTransformation = _URLTransformation; +@synthesize canOpenURLScheme = _canOpenURLScheme; +@synthesize appStoreURL = _appStoreURL; + ++ (OIDCustomBrowserURLTransformation) + URLTransformationSchemeSubstitutionHTTPS:(NSString *)browserSchemeHTTPS + HTTP:(nullable NSString *)browserSchemeHTTP { + OIDCustomBrowserURLTransformation transform = ^NSURL *(NSURL *requestURL) { + // Replace the URL Scheme with the Chrome equivalent. + NSString *newScheme = nil; + if ([requestURL.scheme isEqualToString:@"https"]) { + newScheme = browserSchemeHTTPS; + } else if ([requestURL.scheme isEqualToString:@"http"]) { + if (!browserSchemeHTTP) { + NSAssert(false, @"No HTTP scheme registered for browser"); + return nil; + } + newScheme = browserSchemeHTTP; + } + + // Replaces the URI scheme with the custom scheme + NSURLComponents *components = [NSURLComponents componentsWithURL:requestURL + resolvingAgainstBaseURL:YES]; + components.scheme = newScheme; + return components.URL; + }; + return transform; +} + ++ (OIDCustomBrowserURLTransformation)URLTransformationSchemeConcatPrefix:(NSString *)URLprefix { + OIDCustomBrowserURLTransformation transform = ^NSURL *(NSURL *requestURL) { + NSString *requestURLString = [requestURL absoluteString]; + NSMutableCharacterSet *allowedParamCharacters = + [OIDURLQueryComponent URLParamValueAllowedCharacters]; + NSString *encodedUrl = [requestURLString stringByAddingPercentEncodingWithAllowedCharacters:allowedParamCharacters]; + NSString *newURL = [NSString stringWithFormat:@"%@%@", URLprefix, encodedUrl]; + return [NSURL URLWithString:newURL]; + }; + return transform; +} + ++ (OIDAuthorizationUICoordinatorCustomBrowser *)CustomBrowserChrome { + // Chrome iOS documentation: https://developer.chrome.com/multidevice/ios/links + OIDCustomBrowserURLTransformation transform = [[self class] URLTransformationSchemeSubstitutionHTTPS:@"googlechromes" HTTP:@"googlechrome"]; + NSURL *appStoreURL = + [NSURL URLWithString:@"itms-apps://itunes.apple.com/us/app/chrome/id535886823"]; + return [[[self class] alloc] initWithURLTransformation:transform + canOpenURLScheme:@"googlechromes" + appStoreURL:appStoreURL]; +} + ++ (OIDAuthorizationUICoordinatorCustomBrowser *)CustomBrowserFirefox { + // Firefox iOS documentation: https://github.com/mozilla-mobile/firefox-ios-open-in-client + OIDCustomBrowserURLTransformation transform = + [[self class] URLTransformationSchemeConcatPrefix:@"firefox://open-url?url="]; + NSURL *appStoreURL = + [NSURL URLWithString:@"itms-apps://itunes.apple.com/us/app/firefox-web-browser/id989804926"]; + return [[[self class] alloc] initWithURLTransformation:transform + canOpenURLScheme:@"firefox" + appStoreURL:appStoreURL]; +} + ++ (OIDAuthorizationUICoordinatorCustomBrowser *)CustomBrowserOpera { + OIDCustomBrowserURLTransformation transform = + [[self class] URLTransformationSchemeSubstitutionHTTPS:@"opera-https" HTTP:@"opera-http"]; + NSURL *appStoreURL = + [NSURL URLWithString:@"itms-apps://itunes.apple.com/us/app/opera-mini-web-browser/id363729560"]; + return [[[self class] alloc] initWithURLTransformation:transform + canOpenURLScheme:@"opera-https" + appStoreURL:appStoreURL]; +} + ++ (OIDAuthorizationUICoordinatorCustomBrowser *)CustomBrowserSafari { + OIDCustomBrowserURLTransformation transformNOP = ^NSURL *(NSURL *requestURL) { + return requestURL; + }; + OIDAuthorizationUICoordinatorCustomBrowser *coordinator = + [[[self class] alloc] initWithURLTransformation:transformNOP]; + return coordinator; +} + +- (nullable instancetype)initWithURLTransformation: + (OIDCustomBrowserURLTransformation)URLTransformation { + return [self initWithURLTransformation:URLTransformation canOpenURLScheme:nil appStoreURL:nil]; +} + +- (nullable instancetype) + initWithURLTransformation:(OIDCustomBrowserURLTransformation)URLTransformation + canOpenURLScheme:(nullable NSString *)canOpenURLScheme + appStoreURL:(nullable NSURL *)appStoreURL { + self = [super init]; + if (self) { + _URLTransformation = URLTransformation; + _canOpenURLScheme = canOpenURLScheme; + _appStoreURL = appStoreURL; + } + return self; +} + +- (BOOL)presentAuthorizationRequest:(OIDAuthorizationRequest *)request + session:(id)session { + // If the app store URL is set, checks if the app is installed and if not opens the app store. + if (_appStoreURL && _canOpenURLScheme) { + + // Verifies existence of LSApplicationQueriesSchemes key. + NSArray __unused* canOpenURLs = + [[NSBundle mainBundle] objectForInfoDictionaryKey:@"LSApplicationQueriesSchemes"]; + NSAssert(canOpenURLs, @"plist missing LSApplicationQueriesSchemes key"); + NSAssert1([canOpenURLs containsObject:_canOpenURLScheme], + @"plist missing LSApplicationQueriesSchemes entry for '%@'", _canOpenURLScheme); + + // Opens AppStore if app isn't installed + NSString *testURLString = [NSString stringWithFormat:@"%@://example.com", _canOpenURLScheme]; + NSURL *testURL = [NSURL URLWithString:testURLString]; + if (![[UIApplication sharedApplication] canOpenURL:testURL]) { + [[UIApplication sharedApplication] openURL:_appStoreURL]; + return NO; + } + } + + NSURL *requestURL = [request authorizationRequestURL]; + requestURL = _URLTransformation(requestURL); + BOOL openedInBrowser = [[UIApplication sharedApplication] openURL:requestURL]; + return openedInBrowser; +} + +- (void)dismissAuthorizationAnimated:(BOOL)animated completion:(nonnull void (^)(void))completion { + completion(); +} + +@end + +NS_ASSUME_NONNULL_END