/*
* 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.util;
import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
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 java.io.IOException;
import java.lang.ref.WeakReference;
import static com.google.samples.apps.iosched.util.LogUtils.*;
/**
* 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 LoginAndAuthHelper implements 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
public static final String AUTH_SCOPES[] = {
Scopes.PLUS_LOGIN,
Scopes.DRIVE_APPFOLDER,
"https://www.googleapis.com/auth/plus.profile.emails.read"};
static final String AUTH_TOKEN_TYPE;
static {
StringBuilder sb = new StringBuilder();
sb.append("oauth2:");
for (String scope : AUTH_SCOPES) {
sb.append(scope);
sb.append(" ");
}
AUTH_TOKEN_TYPE = sb.toString();
}
private static final String TAG = makeLogTag(LoginAndAuthHelper.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<Callbacks> 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 interface Callbacks {
void onPlusInfoLoaded(String accountName);
void onAuthSuccess(String accountName, boolean newlyAuthenticated);
void onAuthFailure(String accountName);
}
public LoginAndAuthHelper(Activity activity, Callbacks callbacks, String accountName) {
LOGD(TAG, "Helper created. Account: " + mAccountName);
mActivityRef = new WeakReference<Activity>(activity);
mCallbacksRef = new WeakReference<Callbacks>(callbacks);
mAppContext = activity.getApplicationContext();
mAccountName = accountName;
if (PrefUtils.hasUserRefusedSignIn(activity)) {
// If we know the user refused sign-in, let's not annoy them.
sCanShowSignInUi = sCanShowAuthUi = false;
}
}
public boolean isStarted() {
return mStarted;
}
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;
}
public void retryAuthByUserRequest() {
LOGD(TAG, "Retrying sign-in/auth (user-initiated).");
if (!mGoogleApiClient.isConnected()) {
sCanShowAuthUi = sCanShowSignInUi = true;
PrefUtils.markUserRefusedSignIn(mAppContext, false);
mGoogleApiClient.connect();
} else if (!AccountUtils.hasToken(mAppContext, mAccountName)) {
sCanShowAuthUi = sCanShowSignInUi = true;
PrefUtils.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(). */
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(). */
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());
LOGD(TAG, "Saving plus image URL: " + currentUser.getImage().getUrl());
AccountUtils.setPlusImageUrl(mAppContext, mAccountName, currentUser.getImage().getUrl());
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.");
}
Callbacks 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(). */
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 this as a preference so we don't annoy the user again
PrefUtils.markUserRefusedSignIn(mAppContext);
} 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);
Callbacks callbacks;
if (null != (callbacks = mCallbacksRef.get())) {
callbacks.onAuthSuccess(mAccountName, newlyAuthenticated);
}
}
private void reportAuthFailure() {
LOGD(TAG, "Auth FAILURE for account " + mAccountName);
Callbacks callbacks;
if (null != (callbacks = mCallbacksRef.get())) {
callbacks.onAuthFailure(mAccountName);
}
}
/** Async task that obtains the auth token. */
private class GetTokenTask extends AsyncTask<Void, Void, String> {
public GetTokenTask() {}
@Override
protected String 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);
// Save auth token.
LOGD(TAG, "Saving token: " + (token == null ? "(null)" : "(length " +
token.length() + ")") + " for account " + mAccountName);
AccountUtils.setAuthToken(mAppContext, mAccountName, token);
return token;
} 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(String token) {
super.onPostExecute(token);
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.");
}
}
});
}
}
}