blob: 7beb604aaee3ebbc9e7b124ff394c7d56acdb880 [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";
static NSDictionary<NSString *, id> *loadGoogleServiceInfo() {
NSString *plistPath = [[NSBundle mainBundle] pathForResource:@"GoogleService-Info"
ofType:@"plist"];
if (plistPath) {
return [[NSDictionary alloc] initWithContentsOfFile:plistPath];
}
return nil;
}
// 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 ()
// Configuration wrapping Google Cloud Console, Google Apps, OpenID,
// and other initialization metadata.
@property(strong) GIDConfiguration *configuration;
// Permissions requested during at sign in "init" method call
// unioned with scopes requested later with incremental authorization
// "requestScopes" method call.
// The "email" and "profile" base scopes are always implicitly requested.
@property(copy) NSSet<NSString *> *requestedScopes;
// Instance used to manage Google Sign In authentication including
// sign in, sign out, and requesting additional scopes.
@property(strong, readonly) GIDSignIn *signIn;
// The contents of GoogleService-Info.plist, if it exists.
@property(strong, nullable) NSDictionary<NSString *, id> *googleServiceProperties;
// Redeclared as not a designated initializer.
- (instancetype)init;
@end
@implementation FLTGoogleSignInPlugin
+ (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 {
return [self initWithSignIn:signIn withGoogleServiceProperties:loadGoogleServiceInfo()];
}
- (instancetype)initWithSignIn:(GIDSignIn *)signIn
withGoogleServiceProperties:(nullable NSDictionary<NSString *, id> *)googleServiceProperties {
self = [super init];
if (self) {
_signIn = signIn;
_googleServiceProperties = googleServiceProperties;
// 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);
_requestedScopes = [[NSSet alloc] init];
}
return self;
}
#pragma mark - <FlutterPlugin> protocol
- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result {
if ([call.method isEqualToString:@"init"]) {
GIDConfiguration *configuration =
[self configurationWithClientIdArgument:call.arguments[@"clientId"]
serverClientIdArgument:call.arguments[@"serverClientId"]
hostedDomainArgument:call.arguments[@"hostedDomain"]];
if (configuration != nil) {
if ([call.arguments[@"scopes"] isKindOfClass:[NSArray class]]) {
self.requestedScopes = [NSSet setWithArray:call.arguments[@"scopes"]];
}
self.configuration = configuration;
result(nil);
} else {
result([FlutterError errorWithCode:@"missing-config"
message:@"GoogleService-Info.plist file not found and clientId "
@"was not provided programmatically."
details:nil]);
}
} else if ([call.method isEqualToString:@"signInSilently"]) {
[self.signIn restorePreviousSignInWithCallback:^(GIDGoogleUser *user, NSError *error) {
[self didSignInForUser:user result:result withError:error];
}];
} else if ([call.method isEqualToString:@"isSignedIn"]) {
result(@([self.signIn hasPreviousSignIn]));
} else if ([call.method isEqualToString:@"signIn"]) {
@try {
GIDConfiguration *configuration = self.configuration
?: [self configurationWithClientIdArgument:nil
serverClientIdArgument:nil
hostedDomainArgument:nil];
[self.signIn signInWithConfiguration:configuration
presentingViewController:[self topViewController]
hint:nil
additionalScopes:self.requestedScopes.allObjects
callback:^(GIDGoogleUser *user, NSError *error) {
[self didSignInForUser:user result:result withError:error];
}];
} @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 doWithFreshTokens:^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"]) {
[self.signIn disconnectWithCallback:^(NSError *error) {
[self respondWithAccount:@{} result:result error:nil];
}];
} else if ([call.method isEqualToString:@"requestScopes"]) {
id scopeArgument = call.arguments[@"scopes"];
if ([scopeArgument isKindOfClass:[NSArray class]]) {
self.requestedScopes = [self.requestedScopes setByAddingObjectsFromArray:scopeArgument];
}
NSSet<NSString *> *requestedScopes = self.requestedScopes;
@try {
[self.signIn addScopes:requestedScopes.allObjects
presentingViewController:[self topViewController]
callback:^(GIDGoogleUser *addedScopeUser, NSError *addedScopeError) {
if ([addedScopeError.domain isEqualToString:kGIDSignInErrorDomain] &&
addedScopeError.code == kGIDSignInErrorCodeNoCurrentUser) {
result([FlutterError errorWithCode:@"sign_in_required"
message:@"No account to grant scopes."
details:nil]);
} else if ([addedScopeError.domain
isEqualToString:kGIDSignInErrorDomain] &&
addedScopeError.code ==
kGIDSignInErrorCodeScopesAlreadyGranted) {
// Scopes already granted, report success.
result(@YES);
} else if (addedScopeUser == nil) {
result(@NO);
} else {
NSSet<NSString *> *grantedScopes =
[NSSet setWithArray:addedScopeUser.grantedScopes];
BOOL granted = [requestedScopes isSubsetOfSet:grantedScopes];
result(@(granted));
}
}];
} @catch (NSException *e) {
result([FlutterError errorWithCode:@"request_scopes" message:e.reason details:e.name]);
}
} else {
result(FlutterMethodNotImplemented);
}
}
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)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 - private methods
/// @return @c nil if GoogleService-Info.plist not found and clientId is not provided.
- (GIDConfiguration *)configurationWithClientIdArgument:(id)clientIDArg
serverClientIdArgument:(id)serverClientIDArg
hostedDomainArgument:(id)hostedDomainArg {
NSString *clientID;
BOOL hasDynamicClientId = [clientIDArg isKindOfClass:[NSString class]];
if (hasDynamicClientId) {
clientID = clientIDArg;
} else if (self.googleServiceProperties) {
clientID = self.googleServiceProperties[kClientIdKey];
} else {
// We couldn't resolve a clientId, without which we cannot create a GIDConfiguration.
return nil;
}
BOOL hasDynamicServerClientId = [serverClientIDArg isKindOfClass:[NSString class]];
NSString *serverClientID = hasDynamicServerClientId
? serverClientIDArg
: self.googleServiceProperties[kServerClientIdKey];
NSString *hostedDomain = nil;
if (hostedDomainArg != [NSNull null]) {
hostedDomain = hostedDomainArg;
}
return [[GIDConfiguration alloc] initWithClientID:clientID
serverClientID:serverClientID
hostedDomain:hostedDomain
openIDRealm:nil];
}
- (void)didSignInForUser:(GIDGoogleUser *)user
result:(FlutterResult)result
withError:(NSError *)error {
if (error != nil) {
// Forward all errors and let Dart side decide how to handle.
[self respondWithAccount:nil result:result error:error];
} 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]
}
result:result
error:nil];
}
}
- (void)respondWithAccount:(NSDictionary<NSString *, id> *)account
result:(FlutterResult)result
error:(NSError *)error {
result(error != nil ? getFlutterError(error) : account);
}
- (UIViewController *)topViewController {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
// TODO(stuartmorgan) Provide a non-deprecated codepath. See
// https://github.com/flutter/flutter/issues/104117
return [self topViewControllerFromViewController:[UIApplication sharedApplication]
.keyWindow.rootViewController];
#pragma clang diagnostic pop
}
/**
* 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