package com.dozuki.ifixit.util.api;
import android.accounts.Account;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.DialogInterface.OnCancelListener;
import android.os.AsyncTask;
import android.os.Build;
import android.util.Log;
import com.dozuki.ifixit.App;
import com.dozuki.ifixit.BuildConfig;
import com.dozuki.ifixit.R;
import com.dozuki.ifixit.model.auth.Authenticator;
import com.dozuki.ifixit.model.user.User;
import com.dozuki.ifixit.ui.BaseActivity;
import com.dozuki.ifixit.ui.guide.view.OfflineGuidesActivity;
import com.dozuki.ifixit.util.FileCache;
import com.dozuki.ifixit.util.JSONHelper;
import com.github.kevinsawicki.http.HttpRequest;
import com.github.kevinsawicki.http.HttpRequest.HttpRequestException;
import com.squareup.otto.DeadEvent;
import com.squareup.otto.Subscribe;
import java.io.File;
import java.util.LinkedList;
import java.util.List;
/**
* Class that performs asynchronous API calls and posts the results to the
* event bus.
*/
public class Api {
private interface Responder {
public void setResult(ApiEvent<?> result);
}
private static final int INVALID_LOGIN_CODE = 401;
private static final String TAG = "Api";
/**
* Pending API call. This is set when an authenticated request is performed
* but the user is not logged in. This is then performed once the user has
* authenticated.
*/
private static ApiCall sPendingApiCall;
/**
* List of events that have been sent but not received by any subscribers.
*/
private static List<ApiEvent<?>> sDeadApiEvents;
/**
* Returns true if the the user needs to be authenticated for the given site and endpoint.
*/
private static boolean requireAuthentication(ApiEndpoint endpoint) {
return (endpoint.mAuthenticated || !App.get().getSite().mPublic) &&
!endpoint.mForcePublic;
}
/**
* Performs the API call defined by the given Intent. This takes care of opening a
* login dialog and saving the Intent if the user isn't authenticated but should be.
*/
public static void call(Activity activity, final ApiCall apiCall) {
ApiEndpoint endpoint = apiCall.mEndpoint;
if (activity != null) {
apiCall.mActivityid = ((BaseActivity)activity).getActivityid();
} else if (apiCall.mActivityid == -1) {
Log.w(TAG, "Missing activityid!", new Exception());
}
apiCall.mSite = App.get().getSite();
User user = App.get().getUser();
apiCall.mUser = user;
if (apiCall.mAuthToken == null && user != null) {
// Set the auth token to the current user's auth token if one isn't
// explicitly set. Originally added for logout and user info because a
// user isn't associated with the auth token when the API call is performed.
apiCall.mAuthToken = user.getAuthToken();
}
// User needs to be logged in for an authenticated endpoint with the exception of login.
if (requireAuthentication(endpoint) && !App.get().isUserLoggedIn()) {
App.getBus().post(getUnauthorizedEvent(apiCall));
} else {
performRequest(apiCall, new Responder() {
public void setResult(ApiEvent<?> result) {
if (apiCall.mEndpoint.mPostResults) {
/**
* Always post the result despite any errors. This actually sends it off
* to BaseActivity which posts the underlying ApiEvent<?> if the ApiCall
* was initiated by that Activity instance.
*/
App.getBus().post(new ApiEvent.ActivityProxy(result));
}
}
});
}
}
/**
* Returns an ApiEvent that triggers a login dialog and sets up the ApiCall to be performed
* once the user successfully logs in.
*/
private static ApiEvent<?> getUnauthorizedEvent(ApiCall apiCall) {
sPendingApiCall = apiCall;
// We aren't logged in anymore so lets make sure we don't think we are.
// Note: This does _not_ remove the account from the AccountManager. The
// user still has a chance to reauthenticate and salvage the account.
App.get().shallowLogout(false);
// The ApiError doesn't matter as long as one exists.
return new ApiEvent.Unauthorized().
setCode(INVALID_LOGIN_CODE).
setError(new ApiError("", "", ApiError.Type.UNAUTHORIZED)).
setApiCall(apiCall);
}
/**
* Returns the pending API call and sets it to null. Returns null if no pending API call.
*/
public static ApiCall getAndRemovePendingApiCall(Context context) {
ApiCall pendingApiCall = sPendingApiCall;
sPendingApiCall = null;
if (pendingApiCall != null) {
return pendingApiCall;
} else {
return null;
}
}
/**
* Parse the response in the given result with the given requestTarget.
*/
private static ApiEvent<?> parseResult(ApiEvent<?> result, ApiEndpoint endpoint) {
ApiEvent<?> event;
int code = result.mCode;
String response = result.getResponse();
ApiError error = null;
if (!isSuccess(code)) {
error = JSONHelper.parseError(response, code);
}
if (error != null) {
event = result.setError(error);
} else {
try {
// We don't know the type of ApiEvent it is so we must let the endpoint's
// parseResult return the correct one...
event = endpoint.parseResult(response);
// ... and then we can copy over the other values we need.
event.mCode = code;
event.mApiCall = result.mApiCall;
event.mResponse = result.mResponse;
event.mStoredResponse = result.mStoredResponse;
} catch (Exception e) {
// This is meant to catch JSON and GSON parse exceptions but enumerating
// all different types of Exceptions and putting error handling code
// in one place is tedious.
Log.e(TAG, "API parse error", e);
result.setError(new ApiError(ApiError.Type.PARSE));
event = result;
}
if (!isSuccess(code)) {
event.setError(ApiError.getByStatusCode(code));
}
}
return event;
}
private static boolean isSuccess(int code) {
return code >= 200 && code < 300;
}
public static AlertDialog getErrorDialog(final Activity activity,
final ApiEvent<?> event) {
ApiError error = event.getError();
int positiveButton = error.mType.mTryAgain ?
R.string.try_again : R.string.error_confirm;
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setTitle(error.mTitle)
.setMessage(error.mMessage)
.setPositiveButton(positiveButton,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
// Try performing the request again.
if (event.mError.mType.mTryAgain) {
call(activity, event.mApiCall);
}
dialog.dismiss();
if (event.mError.mType.mFinishActivity) {
activity.finish();
}
}
});
// Add an "Offline Guides" button so the user can always get to the OfflineGuidesActivity.
if (error.mType == ApiError.Type.CONNECTION) {
builder.setNeutralButton(R.string.offline_guides, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
activity.startActivity(OfflineGuidesActivity.view(activity));
activity.finish();
}
});
}
AlertDialog dialog = builder.create();
dialog.setOnCancelListener(new OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
activity.finish();
}
});
return dialog;
}
public static void init() {
sDeadApiEvents = new LinkedList<ApiEvent<?>>();
App.getBus().register(new Object() {
@Subscribe
public void onDeadEvent(DeadEvent deadEvent) {
Object event = deadEvent.event;
if (BuildConfig.DEBUG) {
Log.i(TAG, "onDeadEvent: " + event.getClass().getName());
}
if (event instanceof ApiEvent<?>) {
addDeadApiEvent((ApiEvent<?>)event);
} else if (event instanceof ApiEvent.ActivityProxy) {
addDeadApiEvent(((ApiEvent.ActivityProxy)event).getApiEvent());
}
}
});
}
private static void addDeadApiEvent(ApiEvent<?> apiEvent) {
synchronized (sDeadApiEvents) {
sDeadApiEvents.add(apiEvent);
}
}
public static void retryDeadEvents(BaseActivity activity) {
synchronized (sDeadApiEvents) {
if (sDeadApiEvents.isEmpty()) {
return;
}
List<ApiEvent<?>> deadApiEvents = sDeadApiEvents;
sDeadApiEvents = new LinkedList<ApiEvent<?>>();
int activityid = activity.getActivityid();
// Iterate over all the dead events, firing off each one. If it fails,
// it is recaught by the @Subscribe onDeadEvent, and added back to the list.
for (ApiEvent<?> apiEvent : deadApiEvents) {
// Fire the event If the activityids match, otherwise add it back
// to the list of dead events so we can try it again later.
if (activityid == apiEvent.mApiCall.mActivityid) {
if (BuildConfig.DEBUG) {
Log.i(TAG, "Retrying dead event: " +
apiEvent.getClass().getName());
}
App.getBus().post(apiEvent);
} else {
if (BuildConfig.DEBUG) {
Log.i(TAG, "Adding dead event: " + apiEvent.getClass().toString());
}
sDeadApiEvents.add(apiEvent);
}
}
if (BuildConfig.DEBUG && sDeadApiEvents.size() > 0) {
Log.i(TAG, "Skipped " + sDeadApiEvents.size() + " dead events");
}
}
}
private static void performRequest(final ApiCall apiCall, final Responder responder) {
AsyncTask<String, Void, ApiEvent<?>> as = new AsyncTask<String, Void, ApiEvent<?>>() {
@Override
protected ApiEvent<?> doInBackground(String... dummy) {
return performAndParseApiCall(apiCall);
}
@Override
protected void onPostExecute(ApiEvent<?> result) {
responder.setResult(result);
}
};
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.HONEYCOMB_MR1) {
as.execute();
} else {
as.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
}
protected static ApiEvent<?> performAndParseApiCall(ApiCall apiCall) {
ApiEndpoint endpoint = apiCall.mEndpoint;
final String url = endpoint.getUrl(apiCall.mSite, apiCall.mQuery);
ApiEvent<?> event = endpoint.getEvent();
event.setApiCall(apiCall);
if (App.inDebug()) {
Log.i(TAG, "Performing API call: " + endpoint.mMethod + " " + url);
Log.i(TAG, "Request body: " + apiCall.mRequestBody);
}
try {
ApiEvent<?> response = getResponse(url, event, apiCall);
if (!response.hasError()) {
response = parseResult(response, endpoint);
}
if (!response.hasError() && endpoint.mMethod.equals("GET") &&
!response.mStoredResponse) {
storeResponse(url, apiCall, response.getResponse());
}
return response;
} catch (HttpRequestException e) {
Log.e(TAG, "API error", e);
return event.setError(new ApiError(ApiError.Type.PARSE));
}
}
private static ApiEvent<?> getResponse(String url, ApiEvent<?> event, ApiCall apiCall) {
long startTime = System.currentTimeMillis();
if (!App.get().isConnected()) {
if (apiCall.mEndpoint.mMethod.equals("GET")) {
String response = getStoredResponse(url, apiCall);
if (response != null) {
if (App.inDebug()) {
Log.i(TAG, "Using stored API response");
}
// All GETs will be 200's if they're valid.
return event.setCode(200).setResponse(response).setStoredResponse(true);
}
}
return event.setError(new ApiError(ApiError.Type.CONNECTION));
}
/**
* Unfortunately we must split the creation of the HttpRequest
* object and the appropriate actions to take for a GET vs. a POST
* request. The request headers and trustAllCerts calls must be
* made before any data is sent. However, we must have an HttpRequest
* object already.
*/
HttpRequest request;
if (apiCall.mEndpoint.mMethod.equals("GET")) {
request = HttpRequest.get(url);
} else {
/**
* For all methods other than get we actually perform a POST but send
* a header indicating the actual request we are performing. This is
* because http-request's underlying HTTPRequest library doesn't
* support PATCH requests.
*/
request = HttpRequest.post(url);
request.header("X-HTTP-Method-Override", apiCall.mEndpoint.mMethod);
}
/**
* Send along the auth token if we have one.
*/
if (apiCall.mAuthToken != null) {
request.header("Authorization", "api " + apiCall.mAuthToken);
}
request.userAgent(App.get().getUserAgent());
request.header("X-App-Id", BuildConfig.APP_ID);
request.followRedirects(false);
/**
* Continue with constructing the request body.
*/
if (apiCall.mFilePath != null) {
// POST the file if present.
request.send(new File(apiCall.mFilePath));
} else if (apiCall.mRequestBody != null) {
request.send(apiCall.mRequestBody);
}
/**
* The order is important here. If the code() is called first an IOException
* is thrown in some cases (invalid login for one, maybe more).
*/
String responseBody = request.body();
int code = request.code();
if (App.inDebug()) {
long endTime = System.currentTimeMillis();
Log.d(TAG, "Response code: " + code);
Log.d(TAG, "Response body: " + responseBody);
Log.d(TAG, "Request time: " + (endTime - startTime) + "ms");
}
/**
* If the server responds with a 401, the user is logged out even though we
* think that they are logged in. Return an Unauthorized event to prompt the
* user to log in. Don't do this if we are logging in because the login dialog
* will automatically handle these errors.
*/
if (code == INVALID_LOGIN_CODE && !App.get().isLoggingIn()) {
String newAuthToken = null;
// If mAuthToken is null that means that this is resulting from reauthenticating
// in which case the user's password has expired. Fall through to presenting
// a login dialog so the user can reenter credentials. Upon success, the account
// will be updated. If the user doesn't sign in then it will eventually be
// removed.
if (apiCall.mAuthToken != null) {
newAuthToken = attemptReauthentication(apiCall);
}
if (newAuthToken != null) {
// Try again with the new auth token.
apiCall.mAuthToken = newAuthToken;
return getResponse(url, event, apiCall);
} else {
return getUnauthorizedEvent(apiCall);
}
} else {
return event.setCode(code).setResponse(responseBody);
}
}
/**
* Attempts to reauthenticate the user with the stored credentials. Returns
* a fresh authToken if successful, null otherwise.
*/
private static String attemptReauthentication(ApiCall attemptedApiCall) {
if (!attemptedApiCall.mSite.reauthenticateOnLogout()) {
return null;
}
Authenticator authenticator = new Authenticator(App.get());
authenticator.invalidateAuthToken(attemptedApiCall.mAuthToken);
Account account = authenticator.getAccountForSite(attemptedApiCall.mSite);
// We can't reauthenticate if the account doesn't exist.
if (account == null) {
return null;
}
String email = attemptedApiCall.mUser.mEmail;
String password = authenticator.getPassword(account);
ApiCall loginApiCall = ApiCall.login(email, password);
loginApiCall.mSite = attemptedApiCall.mSite;
ApiEvent<?> result = performAndParseApiCall(loginApiCall);
if (result.hasError()) {
Log.w(TAG, "Reauthentication failed");
return null;
}
Object resultObject = result.getResult();
if (resultObject instanceof User) {
User user = (User)resultObject;
// Don't notify because this is on a different thread and Otto fails.
App.get().login(user, email, password, false);
return user.getAuthToken();
} else {
Log.w(TAG, "Reauthentication result isn't a User");
return null;
}
}
private static String getStoredResponse(String url, ApiCall apiCall) {
long startTime = System.currentTimeMillis();
String response = FileCache.get(getCacheKey(url, apiCall.mUser));
if (App.inDebug()) {
long endTime = System.currentTimeMillis();
Log.i(TAG, "Retrieved response in " + (endTime - startTime) + "ms");
}
return response;
}
private static void storeResponse(String url, ApiCall apiCall, String response) {
long startTime = System.currentTimeMillis();
FileCache.set(getCacheKey(url, apiCall.mUser), response);
if (App.inDebug()) {
long endTime = System.currentTimeMillis();
Log.i(TAG, "Stored response in " + (endTime - startTime) + "ms");
}
}
private static String getCacheKey(String url, User user) {
String key = "api_responses_" + url;
if (user != null) {
key += "_" + user.getUserid();
}
return key;
}
}