| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "FLTGoogleSignInPlugin.h" |
| #import "FLTGoogleSignInPlugin_Test.h" |
| |
| #import <GoogleSignIn/GoogleSignIn.h> |
| |
| // The key within `GoogleService-Info.plist` used to hold the application's |
| // client id. See https://developers.google.com/identity/sign-in/ios/start |
| // for more info. |
| static NSString *const kClientIdKey = @"CLIENT_ID"; |
| |
| static NSString *const kServerClientIdKey = @"SERVER_CLIENT_ID"; |
| |
| // These error codes must match with ones declared on Android and Dart sides. |
| static NSString *const kErrorReasonSignInRequired = @"sign_in_required"; |
| static NSString *const kErrorReasonSignInCanceled = @"sign_in_canceled"; |
| static NSString *const kErrorReasonNetworkError = @"network_error"; |
| static NSString *const kErrorReasonSignInFailed = @"sign_in_failed"; |
| |
| static FlutterError *getFlutterError(NSError *error) { |
| NSString *errorCode; |
| if (error.code == kGIDSignInErrorCodeHasNoAuthInKeychain) { |
| errorCode = kErrorReasonSignInRequired; |
| } else if (error.code == kGIDSignInErrorCodeCanceled) { |
| errorCode = kErrorReasonSignInCanceled; |
| } else if ([error.domain isEqualToString:NSURLErrorDomain]) { |
| errorCode = kErrorReasonNetworkError; |
| } else { |
| errorCode = kErrorReasonSignInFailed; |
| } |
| return [FlutterError errorWithCode:errorCode |
| message:error.domain |
| details:error.localizedDescription]; |
| } |
| |
| @interface FLTGoogleSignInPlugin () <GIDSignInDelegate> |
| @property(strong, readonly) GIDSignIn *signIn; |
| |
| // Redeclared as not a designated initializer. |
| - (instancetype)init; |
| @end |
| |
| @implementation FLTGoogleSignInPlugin { |
| FlutterResult _accountRequest; |
| NSArray<NSString *> *_additionalScopesRequest; |
| } |
| |
| + (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar { |
| FlutterMethodChannel *channel = |
| [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/google_sign_in_ios" |
| binaryMessenger:[registrar messenger]]; |
| FLTGoogleSignInPlugin *instance = [[FLTGoogleSignInPlugin alloc] init]; |
| [registrar addApplicationDelegate:instance]; |
| [registrar addMethodCallDelegate:instance channel:channel]; |
| } |
| |
| - (instancetype)init { |
| return [self initWithSignIn:GIDSignIn.sharedInstance]; |
| } |
| |
| - (instancetype)initWithSignIn:(GIDSignIn *)signIn { |
| self = [super init]; |
| if (self) { |
| _signIn = signIn; |
| _signIn.delegate = self; |
| |
| // On the iOS simulator, we get "Broken pipe" errors after sign-in for some |
| // unknown reason. We can avoid crashing the app by ignoring them. |
| signal(SIGPIPE, SIG_IGN); |
| } |
| return self; |
| } |
| |
| #pragma mark - <FlutterPlugin> protocol |
| |
| - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { |
| if ([call.method isEqualToString:@"init"]) { |
| NSString *path = [[NSBundle mainBundle] pathForResource:@"GoogleService-Info" ofType:@"plist"]; |
| if (path) { |
| NSMutableDictionary<NSString *, NSString *> *plist = |
| [[NSMutableDictionary alloc] initWithContentsOfFile:path]; |
| BOOL hasDynamicClientId = [call.arguments[@"clientId"] isKindOfClass:[NSString class]]; |
| |
| if (hasDynamicClientId) { |
| self.signIn.clientID = call.arguments[@"clientId"]; |
| } else { |
| self.signIn.clientID = plist[kClientIdKey]; |
| } |
| |
| self.signIn.serverClientID = plist[kServerClientIdKey]; |
| self.signIn.scopes = call.arguments[@"scopes"]; |
| if (call.arguments[@"hostedDomain"] == [NSNull null]) { |
| self.signIn.hostedDomain = nil; |
| } else { |
| self.signIn.hostedDomain = call.arguments[@"hostedDomain"]; |
| } |
| result(nil); |
| } else { |
| result([FlutterError errorWithCode:@"missing-config" |
| message:@"GoogleService-Info.plist file not found" |
| details:nil]); |
| } |
| } else if ([call.method isEqualToString:@"signInSilently"]) { |
| if ([self setAccountRequest:result]) { |
| [self.signIn restorePreviousSignIn]; |
| } |
| } else if ([call.method isEqualToString:@"isSignedIn"]) { |
| result(@([self.signIn hasPreviousSignIn])); |
| } else if ([call.method isEqualToString:@"signIn"]) { |
| self.signIn.presentingViewController = [self topViewController]; |
| |
| if ([self setAccountRequest:result]) { |
| @try { |
| [self.signIn signIn]; |
| } @catch (NSException *e) { |
| result([FlutterError errorWithCode:@"google_sign_in" message:e.reason details:e.name]); |
| [e raise]; |
| } |
| } |
| } else if ([call.method isEqualToString:@"getTokens"]) { |
| GIDGoogleUser *currentUser = self.signIn.currentUser; |
| GIDAuthentication *auth = currentUser.authentication; |
| [auth getTokensWithHandler:^void(GIDAuthentication *authentication, NSError *error) { |
| result(error != nil ? getFlutterError(error) : @{ |
| @"idToken" : authentication.idToken, |
| @"accessToken" : authentication.accessToken, |
| }); |
| }]; |
| } else if ([call.method isEqualToString:@"signOut"]) { |
| [self.signIn signOut]; |
| result(nil); |
| } else if ([call.method isEqualToString:@"disconnect"]) { |
| if ([self setAccountRequest:result]) { |
| [self.signIn disconnect]; |
| } |
| } else if ([call.method isEqualToString:@"requestScopes"]) { |
| GIDGoogleUser *user = self.signIn.currentUser; |
| if (user == nil) { |
| result([FlutterError errorWithCode:@"sign_in_required" |
| message:@"No account to grant scopes." |
| details:nil]); |
| return; |
| } |
| |
| NSArray<NSString *> *currentScopes = self.signIn.scopes; |
| NSArray<NSString *> *scopes = call.arguments[@"scopes"]; |
| NSArray<NSString *> *missingScopes = [scopes |
| filteredArrayUsingPredicate:[NSPredicate |
| predicateWithBlock:^BOOL(id scope, NSDictionary *bindings) { |
| return ![user.grantedScopes containsObject:scope]; |
| }]]; |
| |
| if (!missingScopes || !missingScopes.count) { |
| result(@(YES)); |
| return; |
| } |
| |
| if ([self setAccountRequest:result]) { |
| _additionalScopesRequest = missingScopes; |
| self.signIn.scopes = [currentScopes arrayByAddingObjectsFromArray:missingScopes]; |
| self.signIn.presentingViewController = [self topViewController]; |
| self.signIn.loginHint = user.profile.email; |
| @try { |
| [self.signIn signIn]; |
| } @catch (NSException *e) { |
| result([FlutterError errorWithCode:@"request_scopes" message:e.reason details:e.name]); |
| } |
| } |
| } else { |
| result(FlutterMethodNotImplemented); |
| } |
| } |
| |
| - (BOOL)setAccountRequest:(FlutterResult)request { |
| if (_accountRequest != nil) { |
| request([FlutterError errorWithCode:@"concurrent-requests" |
| message:@"Concurrent requests to account signin" |
| details:nil]); |
| return NO; |
| } |
| _accountRequest = request; |
| return YES; |
| } |
| |
| - (BOOL)application:(UIApplication *)app |
| openURL:(NSURL *)url |
| options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options { |
| return [self.signIn handleURL:url]; |
| } |
| |
| #pragma mark - <GIDSignInUIDelegate> protocol |
| |
| - (void)signIn:(GIDSignIn *)signIn presentViewController:(UIViewController *)viewController { |
| UIViewController *rootViewController = |
| [UIApplication sharedApplication].delegate.window.rootViewController; |
| [rootViewController presentViewController:viewController animated:YES completion:nil]; |
| } |
| |
| - (void)signIn:(GIDSignIn *)signIn dismissViewController:(UIViewController *)viewController { |
| [viewController dismissViewControllerAnimated:YES completion:nil]; |
| } |
| |
| #pragma mark - <GIDSignInDelegate> protocol |
| |
| - (void)signIn:(GIDSignIn *)signIn |
| didSignInForUser:(GIDGoogleUser *)user |
| withError:(NSError *)error { |
| if (error != nil) { |
| // Forward all errors and let Dart side decide how to handle. |
| [self respondWithAccount:nil error:error]; |
| } else { |
| if (_additionalScopesRequest) { |
| bool granted = YES; |
| for (NSString *scope in _additionalScopesRequest) { |
| if (![user.grantedScopes containsObject:scope]) { |
| granted = NO; |
| break; |
| } |
| } |
| _accountRequest(@(granted)); |
| _accountRequest = nil; |
| _additionalScopesRequest = nil; |
| return; |
| } else { |
| NSURL *photoUrl; |
| if (user.profile.hasImage) { |
| // Placeholder that will be replaced by on the Dart side based on screen |
| // size |
| photoUrl = [user.profile imageURLWithDimension:1337]; |
| } |
| [self respondWithAccount:@{ |
| @"displayName" : user.profile.name ?: [NSNull null], |
| @"email" : user.profile.email ?: [NSNull null], |
| @"id" : user.userID ?: [NSNull null], |
| @"photoUrl" : [photoUrl absoluteString] ?: [NSNull null], |
| @"serverAuthCode" : user.serverAuthCode ?: [NSNull null] |
| } |
| error:nil]; |
| } |
| } |
| } |
| |
| - (void)signIn:(GIDSignIn *)signIn |
| didDisconnectWithUser:(GIDGoogleUser *)user |
| withError:(NSError *)error { |
| [self respondWithAccount:@{} error:nil]; |
| } |
| |
| #pragma mark - private methods |
| |
| - (void)respondWithAccount:(NSDictionary<NSString *, id> *)account error:(NSError *)error { |
| FlutterResult result = _accountRequest; |
| _accountRequest = nil; |
| result(error != nil ? getFlutterError(error) : account); |
| } |
| |
| - (UIViewController *)topViewController { |
| return [self topViewControllerFromViewController:[UIApplication sharedApplication] |
| .keyWindow.rootViewController]; |
| } |
| |
| /** |
| * This method recursively iterate through the view hierarchy |
| * to return the top most view controller. |
| * |
| * It supports the following scenarios: |
| * |
| * - The view controller is presenting another view. |
| * - The view controller is a UINavigationController. |
| * - The view controller is a UITabBarController. |
| * |
| * @return The top most view controller. |
| */ |
| - (UIViewController *)topViewControllerFromViewController:(UIViewController *)viewController { |
| if ([viewController isKindOfClass:[UINavigationController class]]) { |
| UINavigationController *navigationController = (UINavigationController *)viewController; |
| return [self |
| topViewControllerFromViewController:[navigationController.viewControllers lastObject]]; |
| } |
| if ([viewController isKindOfClass:[UITabBarController class]]) { |
| UITabBarController *tabController = (UITabBarController *)viewController; |
| return [self topViewControllerFromViewController:tabController.selectedViewController]; |
| } |
| if (viewController.presentedViewController) { |
| return [self topViewControllerFromViewController:viewController.presentedViewController]; |
| } |
| return viewController; |
| } |
| @end |