//------------------------------------------------------------------------------ // Copyright (c) 2012 Microsoft Corporation. All rights reserved. // // Description: See the class level JavaDoc comments. //------------------------------------------------------------------------------ package com.microsoft.live; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import org.apache.http.client.HttpClient; import org.apache.http.impl.client.DefaultHttpClient; import android.app.Activity; import android.app.Dialog; import android.content.Context; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; import android.net.Uri; import android.text.TextUtils; import android.webkit.CookieManager; import android.webkit.CookieSyncManager; import com.microsoft.live.OAuth.ErrorType; /** * {@code LiveAuthClient} is a class responsible for retrieving a {@link LiveConnectSession}, which * can be given to a {@link LiveConnectClient} in order to make requests to the Live Connect API. */ public class LiveAuthClient { private static class AuthCompleteRunnable extends AuthListenerCaller implements Runnable { private final LiveStatus status; private final LiveConnectSession session; public AuthCompleteRunnable(LiveAuthListener listener, Object userState, LiveStatus status, LiveConnectSession session) { super(listener, userState); this.status = status; this.session = session; } @Override public void run() { listener.onAuthComplete(status, session, userState); } } private static class AuthErrorRunnable extends AuthListenerCaller implements Runnable { private final LiveAuthException exception; public AuthErrorRunnable(LiveAuthListener listener, Object userState, LiveAuthException exception) { super(listener, userState); this.exception = exception; } @Override public void run() { listener.onAuthError(exception, userState); } } private static abstract class AuthListenerCaller { protected final LiveAuthListener listener; protected final Object userState; public AuthListenerCaller(LiveAuthListener listener, Object userState) { this.listener = listener; this.userState = userState; } } /** * This class observes an {@link OAuthRequest} and calls the appropriate Listener method. * On a successful response, it will call the * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)}. * On an exception or an unsuccessful response, it will call * {@link LiveAuthListener#onAuthError(LiveAuthException, Object)}. */ private class ListenerCallerObserver extends AuthListenerCaller implements OAuthRequestObserver, OAuthResponseVisitor { public ListenerCallerObserver(LiveAuthListener listener, Object userState) { super(listener, userState); } @Override public void onException(LiveAuthException exception) { new AuthErrorRunnable(listener, userState, exception).run(); } @Override public void onResponse(OAuthResponse response) { response.accept(this); } @Override public void visit(OAuthErrorResponse response) { String error = response.getError().toString().toLowerCase(); String errorDescription = response.getErrorDescription(); String errorUri = response.getErrorUri(); LiveAuthException exception = new LiveAuthException(error, errorDescription, errorUri); new AuthErrorRunnable(listener, userState, exception).run(); } @Override public void visit(OAuthSuccessfulResponse response) { session.loadFromOAuthResponse(response); new AuthCompleteRunnable(listener, userState, LiveStatus.CONNECTED, session).run(); } } /** Observer that will, depending on the response, save or clear the refresh token. */ private class RefreshTokenWriter implements OAuthRequestObserver, OAuthResponseVisitor { @Override public void onException(LiveAuthException exception) { } @Override public void onResponse(OAuthResponse response) { response.accept(this); } @Override public void visit(OAuthErrorResponse response) { if (response.getError() == ErrorType.INVALID_GRANT) { LiveAuthClient.this.clearRefreshTokenFromPreferences(); } } @Override public void visit(OAuthSuccessfulResponse response) { String refreshToken = response.getRefreshToken(); if (!TextUtils.isEmpty(refreshToken)) { this.saveRefreshTokenToPerferences(refreshToken); } } private boolean saveRefreshTokenToPerferences(String refreshToken) { assert !TextUtils.isEmpty(refreshToken); SharedPreferences settings = applicationContext.getSharedPreferences(PreferencesConstants.FILE_NAME, Context.MODE_PRIVATE); Editor editor = settings.edit(); editor.putString(PreferencesConstants.REFRESH_TOKEN_KEY, refreshToken); return editor.commit(); } } /** * An {@link OAuthResponseVisitor} that checks the {@link OAuthResponse} and if it is a * successful response, it loads the response into the given session. */ private static class SessionRefresher implements OAuthResponseVisitor { private final LiveConnectSession session; private boolean visitedSuccessfulResponse; public SessionRefresher(LiveConnectSession session) { assert session != null; this.session = session; this.visitedSuccessfulResponse = false; } @Override public void visit(OAuthErrorResponse response) { this.visitedSuccessfulResponse = false; } @Override public void visit(OAuthSuccessfulResponse response) { this.session.loadFromOAuthResponse(response); this.visitedSuccessfulResponse = true; } public boolean visitedSuccessfulResponse() { return this.visitedSuccessfulResponse; } } /** * A LiveAuthListener that does nothing on each of the call backs. * This is used so when a null listener is passed in, this can be used, instead of null, * to avoid if (listener == null) checks. */ private static final LiveAuthListener NULL_LISTENER = new LiveAuthListener() { @Override public void onAuthComplete(LiveStatus status, LiveConnectSession session, Object sender) { } @Override public void onAuthError(LiveAuthException exception, Object sender) { } }; private final Context applicationContext; private final String clientId; private boolean hasPendingLoginRequest; /** * Responsible for all network (i.e., HTTP) calls. * Tests will want to change this to mock the network and HTTP responses. * @see #setHttpClient(HttpClient) */ private HttpClient httpClient; /** saved from initialize and used in the login call if login's scopes are null. */ private Set<String> scopesFromInitialize; /** One-to-one relationship between LiveAuthClient and LiveConnectSession. */ private final LiveConnectSession session; { this.httpClient = new DefaultHttpClient(); this.hasPendingLoginRequest = false; this.session = new LiveConnectSession(this); } /** * Constructs a new {@code LiveAuthClient} instance and initializes its member variables. * * @param context Context of the Application used to save any refresh_token. * @param clientId The client_id of the Live Connect Application to login to. */ public LiveAuthClient(Context context, String clientId) { LiveConnectUtils.assertNotNull(context, "context"); LiveConnectUtils.assertNotNullOrEmpty(clientId, "clientId"); this.applicationContext = context.getApplicationContext(); this.clientId = clientId; } /** @return the client_id of the Live Connect application. */ public String getClientId() { return this.clientId; } /** * Initializes a new {@link LiveConnectSession} with the given scopes. * * The {@link LiveConnectSession} will be returned by calling * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)}. * Otherwise, the {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be * called. These methods will be called on the main/UI thread. * * If the wl.offline_access scope is used, a refresh_token is stored in the given * {@link Activity}'s {@link SharedPerfences}. * * @param scopes to initialize the {@link LiveConnectSession} with. * See <a href="http://msdn.microsoft.com/en-us/library/hh243646.aspx">MSDN Live Connect * Reference's Scopes and permissions</a> for a list of scopes and explanations. * @param listener called on either completion or error during the initialize process. */ public void initialize(Iterable<String> scopes, LiveAuthListener listener) { this.initialize(scopes, listener, null); } /** * Initializes a new {@link LiveConnectSession} with the given scopes. * * The {@link LiveConnectSession} will be returned by calling * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)}. * Otherwise, the {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be * called. These methods will be called on the main/UI thread. * * If the wl.offline_access scope is used, a refresh_token is stored in the given * {@link Activity}'s {@link SharedPerfences}. * * @param scopes to initialize the {@link LiveConnectSession} with. * See <a href="http://msdn.microsoft.com/en-us/library/hh243646.aspx">MSDN Live Connect * Reference's Scopes and permissions</a> for a list of scopes and explanations. * @param listener called on either completion or error during the initialize process * @param userState arbitrary object that is used to determine the caller of the method. */ public void initialize(Iterable<String> scopes, LiveAuthListener listener, Object userState) { if (listener == null) { listener = NULL_LISTENER; } if (scopes == null) { scopes = Arrays.asList(new String[0]); } // copy scopes for login this.scopesFromInitialize = new HashSet<String>(); for (String scope : scopes) { this.scopesFromInitialize.add(scope); } this.scopesFromInitialize = Collections.unmodifiableSet(this.scopesFromInitialize); String refreshToken = this.getRefreshTokenFromPreferences(); if (refreshToken == null) { listener.onAuthComplete(LiveStatus.UNKNOWN, null, userState); return; } RefreshAccessTokenRequest request = new RefreshAccessTokenRequest(this.httpClient, this.clientId, refreshToken, TextUtils.join(OAuth.SCOPE_DELIMITER, scopes)); TokenRequestAsync asyncRequest = new TokenRequestAsync(request); asyncRequest.addObserver(new ListenerCallerObserver(listener, userState)); asyncRequest.addObserver(new RefreshTokenWriter()); asyncRequest.execute(); } /** * Initializes a new {@link LiveConnectSession} with the given scopes. * * The {@link LiveConnectSession} will be returned by calling * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)}. * Otherwise, the {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be * called. These methods will be called on the main/UI thread. * * If the wl.offline_access scope is used, a refresh_token is stored in the given * {@link Activity}'s {@link SharedPerfences}. * * This initialize will use the last successfully used scopes from either a login or initialize. * * @param listener called on either completion or error during the initialize process. */ public void initialize(LiveAuthListener listener) { this.initialize(listener, null); } /** * Initializes a new {@link LiveConnectSession} with the given scopes. * * The {@link LiveConnectSession} will be returned by calling * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)}. * Otherwise, the {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be * called. These methods will be called on the main/UI thread. * * If the wl.offline_access scope is used, a refresh_token is stored in the given * {@link Activity}'s {@link SharedPerfences}. * * This initialize will use the last successfully used scopes from either a login or initialize. * * @param listener called on either completion or error during the initialize process. * @param userState arbitrary object that is used to determine the caller of the method. */ public void initialize(LiveAuthListener listener, Object userState) { this.initialize(null, listener, userState); } /** * Logs in an user with the given scopes. * * login displays a {@link Dialog} that will prompt the * user for a username and password, and ask for consent to use the given scopes. * A {@link LiveConnectSession} will be returned by calling * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)}. * Otherwise, the {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be * called. These methods will be called on the main/UI thread. * * @param activity {@link Activity} instance to display the Login dialog on. * @param scopes to initialize the {@link LiveConnectSession} with. * See <a href="http://msdn.microsoft.com/en-us/library/hh243646.aspx">MSDN Live Connect * Reference's Scopes and permissions</a> for a list of scopes and explanations. * @param listener called on either completion or error during the login process. * @throws IllegalStateException if there is a pending login request. */ public void login(Activity activity, Iterable<String> scopes, LiveAuthListener listener) { this.login(activity, scopes, listener, null); } /** * Logs in an user with the given scopes. * * login displays a {@link Dialog} that will prompt the * user for a username and password, and ask for consent to use the given scopes. * A {@link LiveConnectSession} will be returned by calling * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)}. * Otherwise, the {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be * called. These methods will be called on the main/UI thread. * * @param activity {@link Activity} instance to display the Login dialog on * @param scopes to initialize the {@link LiveConnectSession} with. * See <a href="http://msdn.microsoft.com/en-us/library/hh243646.aspx">MSDN Live Connect * Reference's Scopes and permissions</a> for a list of scopes and explanations. * @param listener called on either completion or error during the login process. * @param userState arbitrary object that is used to determine the caller of the method. * @throws IllegalStateException if there is a pending login request. */ public void login(Activity activity, Iterable<String> scopes, LiveAuthListener listener, Object userState) { LiveConnectUtils.assertNotNull(activity, "activity"); if (listener == null) { listener = NULL_LISTENER; } if (this.hasPendingLoginRequest) { throw new IllegalStateException(ErrorMessages.LOGIN_IN_PROGRESS); } // if no scopes were passed in, use the scopes from initialize or if those are empty, // create an empty list if (scopes == null) { if (this.scopesFromInitialize == null) { scopes = Arrays.asList(new String[0]); } else { scopes = this.scopesFromInitialize; } } // if the session is valid and contains all the scopes, do not display the login ui. boolean showDialog = this.session.isExpired() || !this.session.contains(scopes); if (!showDialog) { listener.onAuthComplete(LiveStatus.CONNECTED, this.session, userState); return; } String scope = TextUtils.join(OAuth.SCOPE_DELIMITER, scopes); String redirectUri = Config.INSTANCE.getOAuthDesktopUri().toString(); AuthorizationRequest request = new AuthorizationRequest(activity, this.httpClient, this.clientId, redirectUri, scope); request.addObserver(new ListenerCallerObserver(listener, userState)); request.addObserver(new RefreshTokenWriter()); request.addObserver(new OAuthRequestObserver() { @Override public void onException(LiveAuthException exception) { LiveAuthClient.this.hasPendingLoginRequest = false; } @Override public void onResponse(OAuthResponse response) { LiveAuthClient.this.hasPendingLoginRequest = false; } }); this.hasPendingLoginRequest = true; request.execute(); } /** * Logs out the given user. * * Also, this method clears the previously created {@link LiveConnectSession}. * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)} will be * called on completion. Otherwise, * {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be called. * * @param listener called on either completion or error during the logout process. */ public void logout(LiveAuthListener listener) { this.logout(listener, null); } /** * Logs out the given user. * * Also, this method clears the previously created {@link LiveConnectSession}. * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)} will be * called on completion. Otherwise, * {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be called. * * @param listener called on either completion or error during the logout process. * @param userState arbitrary object that is used to determine the caller of the method. */ public void logout(LiveAuthListener listener, Object userState) { if (listener == null) { listener = NULL_LISTENER; } session.setAccessToken(null); session.setAuthenticationToken(null); session.setRefreshToken(null); session.setScopes(null); session.setTokenType(null); clearRefreshTokenFromPreferences(); CookieSyncManager cookieSyncManager = CookieSyncManager.createInstance(this.applicationContext); CookieManager manager = CookieManager.getInstance(); Uri logoutUri = Config.INSTANCE.getOAuthLogoutUri(); String url = logoutUri.toString(); String domain = logoutUri.getHost(); List<String> cookieKeys = this.getCookieKeysFromPreferences(); for (String cookieKey : cookieKeys) { String value = TextUtils.join("", new String[] { cookieKey, "=; expires=Thu, 30-Oct-1980 16:00:00 GMT;domain=", domain, ";path=/;version=1" }); manager.setCookie(url, value); } cookieSyncManager.sync(); listener.onAuthComplete(LiveStatus.UNKNOWN, null, userState); } /** @return The {@link HttpClient} instance used by this {@code LiveAuthClient}. */ HttpClient getHttpClient() { return this.httpClient; } /** @return The {@link LiveConnectSession} instance that this {@code LiveAuthClient} created. */ LiveConnectSession getSession() { return session; } /** * Refreshes the previously created session. * * @return true if the session was successfully refreshed. */ boolean refresh() { String scope = TextUtils.join(OAuth.SCOPE_DELIMITER, this.session.getScopes()); String refreshToken = this.session.getRefreshToken(); if (TextUtils.isEmpty(refreshToken)) { return false; } RefreshAccessTokenRequest request = new RefreshAccessTokenRequest(this.httpClient, this.clientId, refreshToken, scope); OAuthResponse response; try { response = request.execute(); } catch (LiveAuthException e) { return false; } SessionRefresher refresher = new SessionRefresher(this.session); response.accept(refresher); response.accept(new RefreshTokenWriter()); return refresher.visitedSuccessfulResponse(); } /** * Sets the {@link HttpClient} that is used for HTTP requests by this {@code LiveAuthClient}. * Tests will want to change this to mock the network/HTTP responses. * @param client The new HttpClient to be set. */ void setHttpClient(HttpClient client) { assert client != null; this.httpClient = client; } /** * Clears the refresh token from this {@code LiveAuthClient}'s * {@link Activity#getPreferences(int)}. * * @return true if the refresh token was successfully cleared. */ private boolean clearRefreshTokenFromPreferences() { SharedPreferences settings = getSharedPreferences(); Editor editor = settings.edit(); editor.remove(PreferencesConstants.REFRESH_TOKEN_KEY); return editor.commit(); } private SharedPreferences getSharedPreferences() { return applicationContext.getSharedPreferences(PreferencesConstants.FILE_NAME, Context.MODE_PRIVATE); } private List<String> getCookieKeysFromPreferences() { SharedPreferences settings = getSharedPreferences(); String cookieKeys = settings.getString(PreferencesConstants.COOKIES_KEY, ""); return Arrays.asList(TextUtils.split(cookieKeys, PreferencesConstants.COOKIE_DELIMITER)); } /** * Retrieves the refresh token from this {@code LiveAuthClient}'s * {@link Activity#getPreferences(int)}. * * @return the refresh token from persistent storage. */ private String getRefreshTokenFromPreferences() { SharedPreferences settings = getSharedPreferences(); return settings.getString(PreferencesConstants.REFRESH_TOKEN_KEY, null); } }