/* * Copyright 2014 Google Inc. All rights reserved. * * 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. */ package com.google.samples.apps.iosched.login; import android.app.Activity; import android.app.Dialog; import android.content.Context; import android.content.Intent; import android.content.IntentSender; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import com.google.android.gms.auth.GoogleAuthException; import com.google.android.gms.auth.GoogleAuthUtil; import com.google.android.gms.auth.GooglePlayServicesAvailabilityException; import com.google.android.gms.auth.UserRecoverableAuthException; import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.GooglePlayServicesUtil; import com.google.android.gms.common.Scopes; import com.google.android.gms.common.api.GoogleApiClient; import com.google.android.gms.common.api.PendingResult; import com.google.android.gms.common.api.ResultCallback; import com.google.android.gms.common.api.Scope; import com.google.android.gms.plus.People; import com.google.android.gms.plus.Plus; import com.google.android.gms.plus.model.people.Person; import com.google.android.gms.plus.model.people.PersonBuffer; import com.google.samples.apps.iosched.settings.SettingsUtils; import com.google.samples.apps.iosched.util.AccountUtils; import com.google.samples.apps.iosched.util.FirebaseUtils; import java.io.IOException; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import static com.google.samples.apps.iosched.util.LogUtils.LOGD; import static com.google.samples.apps.iosched.util.LogUtils.LOGE; import static com.google.samples.apps.iosched.util.LogUtils.LOGW; import static com.google.samples.apps.iosched.util.LogUtils.makeLogTag; /** * This helper handles the UI flow for signing in and authenticating an account. It handles * connecting to the Google+ API to fetch profile data (name, cover photo, etc) and also getting the * auth token for the necessary scopes. The life of this object is tied to an Activity. Do not * attempt to share it across Activities, as unhappiness will result. */ public class LoginAndAuthWithGoogleApi implements LoginAndAuth, GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener, ResultCallback<People.LoadPeopleResult> { // Request codes for the UIs that we show private static final int REQUEST_AUTHENTICATE = 100; private static final int REQUEST_RECOVER_FROM_AUTH_ERROR = 101; private static final int REQUEST_RECOVER_FROM_PLAY_SERVICES_ERROR = 102; private static final int REQUEST_PLAY_SERVICES_ERROR_DIALOG = 103; // Auth scopes we need private static final List<String> AUTH_SCOPES = new ArrayList<>(Arrays.asList( Scopes.PLUS_LOGIN, Scopes.DRIVE_APPFOLDER, "https://www.googleapis.com/auth/plus.profile.emails.read")); public static final String AUTH_TOKEN_TYPE; static { // Initialize oauth scope StringBuilder sb = new StringBuilder(); sb.append("oauth2:"); for (String scope : AUTH_SCOPES) { sb.append(scope); sb.append(" "); } AUTH_TOKEN_TYPE = sb.toString().trim(); } private static final String TAG = makeLogTag(LoginAndAuthWithGoogleApi.class); Context mAppContext; // Controls whether or not we can show sign-in UI. Starts as true; // when sign-in *fails*, we will show the UI only once and set this flag to false. // After that, we don't attempt again in order not to annoy the user. private static boolean sCanShowSignInUi = true; private static boolean sCanShowAuthUi = true; // The Activity this object is bound to (we use a weak ref to avoid context leaks) WeakReference<Activity> mActivityRef; // Callbacks interface we invoke to notify the user of this class of useful events WeakReference<LoginAndAuthListener> mCallbacksRef; // Name of the account to log in as (e.g. "foo@example.com") String mAccountName; // API client to interact with Google services private GoogleApiClient mGoogleApiClient; // Async task that fetches the token GetTokenTask mTokenTask = null; // Are we in the started state? Started state is between onStart and onStop. boolean mStarted = false; // True if we are currently showing UIs to resolve a connection error. boolean mResolving = false; public LoginAndAuthWithGoogleApi(Activity activity, LoginAndAuthListener callback, String accountName) { LOGD(TAG, "Helper created. Account: " + mAccountName); mActivityRef = new WeakReference<Activity>(activity); mCallbacksRef = new WeakReference<LoginAndAuthListener>(callback); mAppContext = activity.getApplicationContext(); mAccountName = accountName; if (SettingsUtils.hasUserRefusedSignIn(activity)) { // If we know the user refused sign-in, let's not annoy them. sCanShowSignInUi = sCanShowAuthUi = false; } } /** List of OAuth scopes to be requested from the Google sign-in API */ public static List<String> GetAuthScopes() { return AUTH_SCOPES; } @Override public boolean isStarted() { return mStarted; } @Override public String getAccountName() { return mAccountName; } private Activity getActivity(String methodName) { Activity activity = mActivityRef.get(); if (activity == null) { LOGD(TAG, "Helper lost Activity reference, ignoring (" + methodName + ")"); } return activity; } @Override public void retryAuthByUserRequest() { LOGD(TAG, "Retrying sign-in/auth (user-initiated)."); if (!mGoogleApiClient.isConnected()) { sCanShowAuthUi = sCanShowSignInUi = true; SettingsUtils.markUserRefusedSignIn(mAppContext, false); mGoogleApiClient.connect(); } else if (!AccountUtils.hasToken(mAppContext, mAccountName)) { sCanShowAuthUi = sCanShowSignInUi = true; SettingsUtils.markUserRefusedSignIn(mAppContext, false); mTokenTask = new GetTokenTask(); mTokenTask.execute(); } else { LOGD(TAG, "No need to retry auth: GoogleApiClient is connected and we have auth token."); } } /** * Starts the helper. Call this from your Activity's onStart(). */ @Override public void start() { Activity activity = getActivity("start()"); if (activity == null) { return; } if (mStarted) { LOGW(TAG, "Helper already started. Ignoring redundant call."); return; } mStarted = true; if (mResolving) { // if resolving, don't reconnect the plus client LOGD(TAG, "Helper ignoring signal to start because we're resolving a failure."); return; } LOGD(TAG, "Helper starting. Connecting " + mAccountName); if (mGoogleApiClient == null) { LOGD(TAG, "Creating client."); GoogleApiClient.Builder builder = new GoogleApiClient.Builder(activity); for (String scope : AUTH_SCOPES) { builder.addScope(new Scope(scope)); } mGoogleApiClient = builder.addApi(Plus.API) .addConnectionCallbacks(this) .addOnConnectionFailedListener(this) .setAccountName(mAccountName) .build(); } LOGD(TAG, "Connecting client."); mGoogleApiClient.connect(); } // Called when the Google+ client is connected. @Override public void onConnected(Bundle bundle) { Activity activity = getActivity("onConnected()"); if (activity == null) { return; } LOGD(TAG, "Helper connected, account " + mAccountName); // load user's Google+ profile, if we don't have it yet if (!AccountUtils.hasPlusInfo(activity, mAccountName)) { LOGD(TAG, "We don't have Google+ info for " + mAccountName + " yet, so loading."); PendingResult<People.LoadPeopleResult> result = Plus.PeopleApi.load(mGoogleApiClient, "me"); result.setResultCallback(this); } else { LOGD(TAG, "No need for Google+ info, we already have it."); } // try to authenticate, if we don't have a token yet if (!AccountUtils.hasToken(activity, mAccountName)) { LOGD(TAG, "We don't have auth token for " + mAccountName + " yet, so getting it."); mTokenTask = new GetTokenTask(); mTokenTask.execute(); } else { LOGD(TAG, "No need for auth token, we already have it."); reportAuthSuccess(false); } } @Override public void onConnectionSuspended(int i) { LOGD(TAG, "onConnectionSuspended."); } /** * Stop the helper. Call this from your Activity's onStop(). */ @Override public void stop() { if (!mStarted) { LOGW(TAG, "Helper already stopped. Ignoring redundant call."); return; } LOGD(TAG, "Helper stopping."); if (mTokenTask != null) { LOGD(TAG, "Helper cancelling token task."); mTokenTask.cancel(false); } mStarted = false; if (mGoogleApiClient.isConnected()) { LOGD(TAG, "Helper disconnecting client."); mGoogleApiClient.disconnect(); } mResolving = false; } // Called when the connection to Google Play Services fails. @Override public void onConnectionFailed(ConnectionResult connectionResult) { Activity activity = getActivity("onConnectionFailed()"); if (activity == null) { return; } if (connectionResult.hasResolution()) { if (sCanShowSignInUi) { LOGD(TAG, "onConnectionFailed, with resolution. Attempting to resolve."); sCanShowSignInUi = false; try { mResolving = true; connectionResult.startResolutionForResult(activity, REQUEST_RECOVER_FROM_PLAY_SERVICES_ERROR); } catch (IntentSender.SendIntentException e) { LOGE(TAG, "SendIntentException occurred: " + e.getMessage()); e.printStackTrace(); } } else { LOGD(TAG, "onConnectionFailed with resolution but sCanShowSignInUi==false."); reportAuthFailure(); } return; } LOGD(TAG, "onConnectionFailed, no resolution."); final int errorCode = connectionResult.getErrorCode(); if (GooglePlayServicesUtil.isUserRecoverableError(errorCode) && sCanShowSignInUi) { sCanShowSignInUi = false; GooglePlayServicesUtil.getErrorDialog(errorCode, activity, REQUEST_PLAY_SERVICES_ERROR_DIALOG).show(); } else { reportAuthFailure(); } } // Called asynchronously -- result of loadPeople() call @Override public void onResult(People.LoadPeopleResult loadPeopleResult) { LOGD(TAG, "onPeopleLoaded, status=" + loadPeopleResult.getStatus().toString()); if (loadPeopleResult.getStatus().isSuccess()) { PersonBuffer personBuffer = loadPeopleResult.getPersonBuffer(); if (personBuffer != null && personBuffer.getCount() > 0) { LOGD(TAG, "Got plus profile for account " + mAccountName); Person currentUser = personBuffer.get(0); personBuffer.close(); // Record profile ID, image URL and name LOGD(TAG, "Saving plus profile ID: " + currentUser.getId()); AccountUtils.setPlusProfileId(mAppContext, mAccountName, currentUser.getId()); String imageUrl = currentUser.getImage().getUrl(); if (imageUrl != null) { imageUrl = Uri.parse(imageUrl) .buildUpon().appendQueryParameter("sz", "256").build().toString(); } LOGD(TAG, "Saving plus image URL: " + imageUrl); AccountUtils.setPlusImageUrl(mAppContext, mAccountName, imageUrl); LOGD(TAG, "Saving plus display name: " + currentUser.getDisplayName()); AccountUtils.setPlusName(mAppContext, mAccountName, currentUser.getDisplayName()); Person.Cover cover = currentUser.getCover(); if (cover != null) { Person.Cover.CoverPhoto coverPhoto = cover.getCoverPhoto(); if (coverPhoto != null) { LOGD(TAG, "Saving plus cover URL: " + coverPhoto.getUrl()); AccountUtils .setPlusCoverUrl(mAppContext, mAccountName, coverPhoto.getUrl()); } } else { LOGD(TAG, "Profile has no cover."); } LoginAndAuthListener callbacks; if (null != (callbacks = mCallbacksRef.get())) { callbacks.onPlusInfoLoaded(mAccountName); } } else { LOGE(TAG, "Plus response was empty! Failed to load profile."); } } else { LOGE(TAG, "Failed to load plus proflie, error " + loadPeopleResult.getStatus().getStatusCode()); } } /** * Handles an Activity result. Call this from your Activity's onActivityResult(). */ @Override public boolean onActivityResult(int requestCode, int resultCode, Intent data) { Activity activity = getActivity("onActivityResult()"); if (activity == null) { return false; } if (requestCode == REQUEST_AUTHENTICATE || requestCode == REQUEST_RECOVER_FROM_AUTH_ERROR || requestCode == REQUEST_PLAY_SERVICES_ERROR_DIALOG) { LOGD(TAG, "onActivityResult, req=" + requestCode + ", result=" + resultCode); if (requestCode == REQUEST_RECOVER_FROM_PLAY_SERVICES_ERROR) { mResolving = false; } if (resultCode == Activity.RESULT_OK) { if (mGoogleApiClient != null) { LOGD(TAG, "Since activity result was RESULT_OK, reconnecting client."); mGoogleApiClient.connect(); } else { LOGD(TAG, "Activity result was RESULT_OK, but we have no client to reconnect."); } } else if (resultCode == Activity.RESULT_CANCELED) { LOGD(TAG, "User explicitly cancelled sign-in/auth flow."); // Save the refusal so the user isn't annoyed again. SettingsUtils.markUserRefusedSignIn(mAppContext, true); } else { LOGW(TAG, "Failed to recover from a login/auth failure, resultCode=" + resultCode); } return true; } return false; } private void showRecoveryDialog(int statusCode) { Activity activity = getActivity("showRecoveryDialog()"); if (activity == null) { return; } if (sCanShowAuthUi) { sCanShowAuthUi = false; LOGD(TAG, "Showing recovery dialog for status code " + statusCode); final Dialog d = GooglePlayServicesUtil.getErrorDialog( statusCode, activity, REQUEST_RECOVER_FROM_PLAY_SERVICES_ERROR); d.show(); } else { LOGD(TAG, "Not showing Play Services recovery dialog because sCanShowSignInUi==false."); reportAuthFailure(); } } private void showAuthRecoveryFlow(Intent intent) { Activity activity = getActivity("showAuthRecoveryFlow()"); if (activity == null) { return; } if (sCanShowAuthUi) { sCanShowAuthUi = false; LOGD(TAG, "Starting auth recovery Intent."); activity.startActivityForResult(intent, REQUEST_RECOVER_FROM_AUTH_ERROR); } else { LOGD(TAG, "Not showing auth recovery flow because sCanShowSignInUi==false."); reportAuthFailure(); } } private void reportAuthSuccess(boolean newlyAuthenticated) { LOGD(TAG, "Auth success for account " + mAccountName + ", newlyAuthenticated=" + newlyAuthenticated); LoginAndAuthListener callback; if (null != (callback = mCallbacksRef.get())) { callback.onAuthSuccess(mAccountName, newlyAuthenticated); } } private void reportAuthFailure() { LOGD(TAG, "Auth FAILURE for account " + mAccountName); LoginAndAuthListener callback; if (null != (callback = mCallbacksRef.get())) { callback.onAuthFailure(mAccountName); } } /** * Async task that obtains the auth token. */ private class GetTokenTask extends AsyncTask<Void, Void, Void> { public GetTokenTask() {} @Override protected Void doInBackground(Void... params) { try { if (isCancelled()) { LOGD(TAG, "doInBackground: task cancelled, so giving up on auth."); return null; } LOGD(TAG, "Starting background auth for " + mAccountName); final String token = GoogleAuthUtil .getToken(mAppContext, mAccountName, AUTH_TOKEN_TYPE); final String accountId = GoogleAuthUtil.getAccountId(mAppContext, mAccountName); // Save auth token. LOGD(TAG, "Saving token: " + (token == null ? "(null)" : "(length " + token.length() + ")") + " for account " + mAccountName); AccountUtils.setAuthToken(mAppContext, mAccountName, token); // Set the Firebase shard associated with the chosen account. FirebaseUtils.setFirebaseUrl(mAppContext, accountId); } catch (GooglePlayServicesAvailabilityException e) { postShowRecoveryDialog(e.getConnectionStatusCode()); } catch (UserRecoverableAuthException e) { postShowAuthRecoveryFlow(e.getIntent()); } catch (IOException e) { LOGE(TAG, "IOException encountered: " + e.getMessage()); } catch (GoogleAuthException e) { LOGE(TAG, "GoogleAuthException encountered: " + e.getMessage()); } catch (RuntimeException e) { LOGE(TAG, "RuntimeException encountered: " + e.getMessage()); } return null; } @Override protected void onPostExecute(Void nothing) { super.onPostExecute(nothing); if (isCancelled()) { LOGD(TAG, "Task cancelled, so not reporting auth success."); } else if (!mStarted) { LOGD(TAG, "Activity not started, so not reporting auth success."); } else { LOGD(TAG, "GetTokenTask reporting auth success."); reportAuthSuccess(true); } } private void postShowRecoveryDialog(final int statusCode) { Activity activity = getActivity("postShowRecoveryDialog()"); if (activity == null) { return; } if (isCancelled()) { LOGD(TAG, "Task cancelled, so not showing recovery dialog."); return; } LOGD(TAG, "Requesting display of recovery dialog for status code " + statusCode); activity.runOnUiThread(new Runnable() { @Override public void run() { if (mStarted) { showRecoveryDialog(statusCode); } else { LOGE(TAG, "Activity not started, so not showing recovery dialog."); } } }); } private void postShowAuthRecoveryFlow(final Intent intent) { Activity activity = getActivity("postShowAuthRecoveryFlow()"); if (activity == null) { return; } if (isCancelled()) { LOGD(TAG, "Task cancelled, so not showing auth recovery flow."); return; } LOGD(TAG, "Requesting display of auth recovery flow."); activity.runOnUiThread(new Runnable() { @Override public void run() { if (mStarted) { showAuthRecoveryFlow(intent); } else { LOGE(TAG, "Activity not started, so not showing auth recovery flow."); } } }); } } }