blob: 5ad69e2ad052c25abc014cf602939edd80c85d5c [file] [log] [blame]
// 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