package com.instructure.canvasapi.utilities; import android.content.Context; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.text.TextUtils; import android.util.Log; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.instructure.canvasapi.model.CanvasContext; import com.squareup.okhttp.Cache; import com.squareup.okhttp.Interceptor; import com.squareup.okhttp.OkHttpClient; import com.squareup.okhttp.Response; import java.io.File; import java.io.IOException; import java.util.concurrent.TimeUnit; import retrofit.Profiler; import retrofit.RequestInterceptor; import retrofit.RestAdapter; import retrofit.client.OkClient; import retrofit.converter.GsonConverter; /** * * Copyright (c) 2015 Instructure. All rights reserved. */ public class CanvasRestAdapter { private static int numberOfItemsPerPage = 30; private static int TIMEOUT_IN_SECONDS = 60; private static CanvasOkClient okHttpClient; public static int getNumberOfItemsPerPage() { return numberOfItemsPerPage; } private static final Interceptor mCacheControlInterceptor = new Interceptor() { @Override public Response intercept(Chain chain) throws IOException { com.squareup.okhttp.Request request = chain.request(); Response response = chain.proceed(request); // Re-write response CC header to force use of cache // Displayed cached data will always be followed by a response from the server with the latest data. return response.newBuilder() .header("Cache-Control", "public, max-age=1209600") //60*60*24*14 = 1209600 2 weeks; Essentially means cached data will only be valid offline for 2 weeks. When network is available, the cache is always updated on every request. .build(); } }; public static void deleteHttpCache() { if(okHttpClient != null) { try { okHttpClient.getClient().getCache().evictAll(); } catch (IOException e) { Log.d(APIHelpers.LOG_TAG, "Failed deleting the cache"); } } } private static OkClient getOkHttp(Context context) { if (okHttpClient == null) { File httpCacheDirectory = new File(context.getCacheDir(), "responses"); Cache cache = new Cache(httpCacheDirectory, 20 * 1024 * 1024); // cache size OkHttpClient httpClient = new OkHttpClient(); httpClient.setCache(cache); httpClient.setReadTimeout(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS); /** Dangerous interceptor that rewrites the server's cache-control header. */ httpClient.networkInterceptors().add(mCacheControlInterceptor); okHttpClient = new CanvasOkClient(httpClient); } return okHttpClient; } private static OkClient getOkHttpNoRedirects(Context context) { CanvasOkClient client = (CanvasOkClient)getOkHttp(context); client.getClient().setFollowRedirects(false); return client; } /** * Returns a RestAdapter Instance that points at :domain/api/v1 * * @param context An Android context. * @return A Canvas RestAdapterInstance. If setupInstance() hasn't been called, returns an invalid RestAdapter. */ public static RestAdapter buildAdapter(final Context context) { return buildAdapterHelper(context, null, false, true); } /** * Returns a RestAdapter Instance that points at :domain/api/v1 * * @param callback A Canvas Callback * @return A Canvas RestAdapterInstance. If setupInstance() hasn't been called, returns an invalid RestAdapter. */ public static RestAdapter buildAdapter(CanvasCallback callback) { callback.setFinished(false); return buildAdapterHelper(callback.getContext(), null, false, true); } /** * Returns a RestAdapter Instance that points at domain * * @param callback A Canvas Callback * @param domain Domain that you want to use for the API call * @return A Canvas RestAdapterInstance. If setupInstance() hasn't been called, returns an invalid RestAdapter. */ public static RestAdapter buildAdapter(String domain, CanvasCallback callback) { callback.setFinished(false); return buildAdapterHelper(callback.getContext(), domain, null, false, true); } /** * Returns a RestAdapter instance that points at :domain/api/v1/groups or :domain/api/v1/courses depending on the CanvasContext * * If CanvasContext is null, it returns an instance that simply points to :domain/api/v1/ * * @param callback A Canvas Callback * @param canvasContext A Canvas Context * @return A Canvas RestAdapterInstance. If setupInstance() hasn't been called, returns an invalid RestAdapter. */ public static RestAdapter buildAdapter(CanvasCallback callback, CanvasContext canvasContext) { callback.setFinished(false); return buildAdapterHelper(callback.getContext(), canvasContext, false, true); } public static RestAdapter buildAdapter(CanvasCallback callback, boolean isOnlyReadFromCache, CanvasContext canvasContext) { callback.setFinished(false); return buildAdapterHelper(callback.getContext(), canvasContext, isOnlyReadFromCache, true); } public static RestAdapter buildAdapter(CanvasCallback callback, String domain, boolean isOnlyReadFromCache, CanvasContext canvasContext) { callback.setFinished(false); return buildAdapterHelper(callback.getContext(), domain, canvasContext, isOnlyReadFromCache, true); } /** * Returns a RestAdapter instance that points at :domain/api/v1/groups or :domain/api/v1/courses depending on the CanvasContext ** * @param callback A Canvas Callback * @param addPerPageQueryParam Specify if you want to add the per page query param * @return A Canvas RestAdapterInstance. If setupInstance() hasn't been called, returns an invalid RestAdapter. */ public static RestAdapter buildAdapter(CanvasCallback callback, boolean addPerPageQueryParam) { callback.setFinished(false); return buildAdapterHelper(callback.getContext(), null, false, addPerPageQueryParam); } /** * Returns a RestAdapter instance that points at :domain/api/v1/groups or :domain/api/v1/courses depending on the CanvasContext * * If CanvasContext is null, it returns an instance that simply points to :domain/api/v1/ * * @param context An Android context. * @param canvasContext A Canvas Context * @return A Canvas RestAdapterInstance. If setupInstance() hasn't been called, returns an invalid RestAdapter. */ public static RestAdapter buildAdapter(final Context context, CanvasContext canvasContext) { return buildAdapterHelper(context, canvasContext, false, true); } public static RestAdapter buildAdapter(final Context context, final boolean addPerPageQueryParam) { return buildAdapterHelper(context, null, false, addPerPageQueryParam); } public static RestAdapter buildAdapter(final Context context, boolean isOnlyReadFromCache, final boolean addPerPageQueryParam) { return buildAdapterHelper(context, null, isOnlyReadFromCache, addPerPageQueryParam); } /** * Returns a RestAdapter instance that points at :domain/api/v1/groups or :domain/api/v1/courses depending on the CanvasContext * * If CanvasContext is null, it returns an instance that simply points to :domain/api/v1/ * @param callback A Canvas Callback * @param canvasContext A Canvas Context * @param isOnlyReadFromCache Specify if you only want to read from cache * @param addPerPageQueryParam Specify if you want to add the per page query param * @return */ public static RestAdapter buildAdapter(CanvasCallback callback, CanvasContext canvasContext, boolean isOnlyReadFromCache, boolean addPerPageQueryParam) { callback.setFinished(false); return buildAdapterHelper(callback.getContext(), canvasContext, isOnlyReadFromCache, addPerPageQueryParam); } /** * Returns a RestAdapter instance that points at :domain/groups or :domain/courses depending on the CanvasContext * * If CanvasContext is null, it returns an instance that simply points to :domain/api/v1/ * @param callback A Canvas Callback * @param domain Domain that you want to use for the API call * @param canvasContext A Canvas Context * @param isOnlyReadFromCache Specify if you only want to read from cache * @param addPerPageQueryParam Specify if you want to add the per page query param * @return */ public static RestAdapter buildAdapter(CanvasCallback callback, String domain, CanvasContext canvasContext, boolean isOnlyReadFromCache, boolean addPerPageQueryParam) { callback.setFinished(false); return buildAdapterHelper(callback.getContext(), domain, canvasContext, isOnlyReadFromCache, addPerPageQueryParam); } /** * Returns a RestAdapter instance that points at :domain/groups or :domain/courses depending on the CanvasContext * * If CanvasContext is null, it returns an instance that simply points to :domain/api/v1/ * @param callback A Canvas Callback * @param domain Domain that you want to use for the API call * @param canvasContext A Canvas Context * @param isOnlyReadFromCache Specify if you only want to read from cache * @param addPerPageQueryParam Specify if you want to add the per page query param * @return */ public static RestAdapter buildAdapterNoRedirects(CanvasCallback callback, String domain, CanvasContext canvasContext, boolean isOnlyReadFromCache, boolean addPerPageQueryParam) { callback.setFinished(false); //Check for null values or invalid CanvasContext types. if(callback.getContext() == null) { return null; } if (callback.getContext() instanceof APIStatusDelegate) { ((APIStatusDelegate)callback.getContext()).onCallbackStarted(); } //Can make this check as we KNOW that the setter doesn't allow empty strings. if (domain == null || domain.equals("")) { Log.d(APIHelpers.LOG_TAG, "The RestAdapter hasn't been set up yet. Call setupInstance(context,token,domain)"); return new RestAdapter.Builder().setEndpoint("http://invalid.domain.com").build(); } String apiContext = ""; if (canvasContext != null) { if (canvasContext.getType() == CanvasContext.Type.COURSE) { apiContext = "courses/"; } else if (canvasContext.getType() == CanvasContext.Type.GROUP) { apiContext = "groups/"; } else if (canvasContext.getType() == CanvasContext.Type.SECTION) { apiContext = "sections/"; } else { apiContext = "users/"; } } GsonConverter gsonConverter = new GsonConverter(getGSONParser()); //Sets the auth token, user agent, and handles masquerading. return new RestAdapter.Builder() .setEndpoint(domain + apiContext) // The base API endpoint. .setRequestInterceptor(new CanvasRequestInterceptor(callback.getContext(), addPerPageQueryParam, isOnlyReadFromCache)) .setConverter(gsonConverter) .setClient(getOkHttpNoRedirects(callback.getContext())).build(); } /** * Returns a RestAdapter instance that points at :domain/api/v1/groups or :domain/api/v1/courses depending on the CanvasContext * * If CanvasContext is null, it returns an instance that simply points to :domain/api/v1/ * @param context An Android context. * @param canvasContext A Canvas Context * @param isOnlyReadFromCache Specify if you only want to read from cache * @param addPerPageQueryParam Specify if you want to add the per page query param * @return */ public static RestAdapter buildAdapter(final Context context, CanvasContext canvasContext, boolean isOnlyReadFromCache, boolean addPerPageQueryParam) { return buildAdapterHelper(context, canvasContext, isOnlyReadFromCache, addPerPageQueryParam); } private static RestAdapter buildAdapterHelper(final Context context, CanvasContext canvasContext, boolean isForcedCache, boolean addPerPageQueryParam) { //Check for null values or invalid CanvasContext types. if(context == null) { return null; } if (context instanceof APIStatusDelegate) { ((APIStatusDelegate)context).onCallbackStarted(); } String domain = APIHelpers.getFullDomain(context); //Can make this check as we KNOW that the setter doesn't allow empty strings. if (domain == null || domain.equals("")) { Log.d(APIHelpers.LOG_TAG, "The RestAdapter hasn't been set up yet. Call setupInstance(context,token,domain)"); return new RestAdapter.Builder().setEndpoint("http://invalid.domain.com").build(); } String apiContext = ""; if (canvasContext != null) { if (canvasContext.getType() == CanvasContext.Type.COURSE) { apiContext = "courses/"; } else if (canvasContext.getType() == CanvasContext.Type.GROUP) { apiContext = "groups/"; } else if (canvasContext.getType() == CanvasContext.Type.SECTION) { apiContext = "sections/"; } else { apiContext = "users/"; } } GsonConverter gsonConverter = new GsonConverter(getGSONParser()); //Sets the auth token, user agent, and handles masquerading. return new RestAdapter.Builder() .setEndpoint(domain + "/api/v1/" + apiContext) // The base API endpoint. .setRequestInterceptor(new CanvasRequestInterceptor(context, addPerPageQueryParam, isForcedCache)) .setConverter(gsonConverter) .setClient(getOkHttp(context)).build(); } /** * This helper can be used when you don't want to use the saved domain or the /api/v1/ in the API call * * @param context Android context * @param domain domain that you want to use for the API call * @param canvasContext A Canvas Context * @param isForcedCache Specify if you only want to read from cache * @param addPerPageQueryParam Specify if you want to add the per page query param * @return */ private static RestAdapter buildAdapterHelper(final Context context, String domain, CanvasContext canvasContext, boolean isForcedCache, boolean addPerPageQueryParam) { //Check for null values or invalid CanvasContext types. if(context == null) { return null; } if (context instanceof APIStatusDelegate) { ((APIStatusDelegate)context).onCallbackStarted(); } //Can make this check as we KNOW that the setter doesn't allow empty strings. if (domain == null || domain.equals("")) { Log.d(APIHelpers.LOG_TAG, "The RestAdapter hasn't been set up yet. Call setupInstance(context,token,domain)"); return new RestAdapter.Builder().setEndpoint("http://invalid.domain.com").build(); } String apiContext = ""; if (canvasContext != null) { if (canvasContext.getType() == CanvasContext.Type.COURSE) { apiContext = "courses/"; } else if (canvasContext.getType() == CanvasContext.Type.GROUP) { apiContext = "groups/"; } else if (canvasContext.getType() == CanvasContext.Type.SECTION) { apiContext = "sections/"; } else { apiContext = "users/"; } } GsonConverter gsonConverter = new GsonConverter(getGSONParser()); //Sets the auth token, user agent, and handles masquerading. return new RestAdapter.Builder() .setEndpoint(domain + apiContext) // The base API endpoint. .setRequestInterceptor(new CanvasRequestInterceptor(context, addPerPageQueryParam, isForcedCache)) .setConverter(gsonConverter) .setClient(getOkHttp(context)).build(); } /** * This adapter can be used for generic canvas requests that don't require a token. For example, getting account domains requires that you don't have * a token set * * @param context Android context * @param isForcedCache Specify if you only want to read from cache * @param addPerPageQueryParam Specify if you want to add the per page query param * @return */ public static RestAdapter buildGenericAdapter(final Context context, String domain, boolean isForcedCache, boolean addPerPageQueryParam, boolean shouldIgnoreToken) { //Check for null values or invalid CanvasContext types. if(context == null) { return null; } if (context instanceof APIStatusDelegate) { ((APIStatusDelegate)context).onCallbackStarted(); } //Can make this check as we KNOW that the setter doesn't allow empty strings. if (domain == null || domain.equals("")) { Log.d(APIHelpers.LOG_TAG, "The RestAdapter hasn't been set up yet. Call setupInstance(context,token,domain)"); return new RestAdapter.Builder().setEndpoint("http://invalid.domain.com").build(); } GsonConverter gsonConverter = new GsonConverter(getGSONParser()); //Sets the auth token, user agent, and handles masquerading. return new RestAdapter.Builder() .setEndpoint(domain + "/api/v1/") // The base API endpoint. .setRequestInterceptor(new CanvasRequestInterceptor(context, addPerPageQueryParam, isForcedCache, shouldIgnoreToken)) .setConverter(gsonConverter) .setClient(getOkHttp(context)).build(); } /** * Returns a RestAdapter Instance that points at :domain/ * * Used ONLY in the login flow! * * @param context An Android context. */ public static RestAdapter buildTokenRestAdapter(final Context context){ if(context == null ){ return null; } String domain = APIHelpers.getFullDomain(context); return new RestAdapter.Builder() .setEndpoint(domain) // The base API endpoint. .setRequestInterceptor(new CanvasRequestInterceptor(context, true)) .build(); } /** * Returns a RestAdapter Instance that points at :domain/ * * Used ONLY in the login flow! * */ public static RestAdapter buildTokenRestAdapter(final String token, final String protocol, final String domain){ if(token == null || protocol == null || domain == null ){ return null; } RetrofitCounter.increment(); return new RestAdapter.Builder() .setEndpoint(protocol + "://" + domain) // The base API endpoint. .setRequestInterceptor(new RequestInterceptor() { @Override public void intercept(RequestFacade requestFacade) { requestFacade.addHeader("Authorization", "Bearer " + token); } }) .build(); } /** * Creates a new RestAdapter for a generic endpoint. Useful for 3rd party api calls such as amazon s3 uploads. * @param hostUrl : url for desired endpoint * @return */ public static RestAdapter getGenericHostAdapter(String hostUrl){ RetrofitCounter.increment(); RestAdapter restAdapter = new RestAdapter.Builder() .setEndpoint(hostUrl) .build(); return restAdapter; } /** * Creates a RestAdapter to ping an endpoint so we can get elapsed time of API calls * @param url * @return */ public static RestAdapter buildPingRestAdapter(String url, Profiler profiler) { if(TextUtils.isEmpty(url)) { return null; } RetrofitCounter.increment(); return new RestAdapter.Builder() .setEndpoint(url) .setProfiler(profiler).build(); } /** * Class that's used as to inject the user agent, token, and handles masquerading. */ public static class CanvasRequestInterceptor implements RequestInterceptor{ Context context; boolean addPerPageQueryParam; boolean isForcedCache; boolean shouldIgnoreToken; CanvasRequestInterceptor(Context context, boolean addPerPageQueryParam){ this.context = context; this.addPerPageQueryParam = addPerPageQueryParam; } CanvasRequestInterceptor(Context context, boolean addPerPageQueryParam, boolean isForcedCache){ this.context = context; this.addPerPageQueryParam = addPerPageQueryParam; this.isForcedCache = isForcedCache; } CanvasRequestInterceptor(Context context, boolean addPerPageQueryParam, boolean isForcedCache, boolean shouldIgnoreToken){ this.context = context; this.addPerPageQueryParam = addPerPageQueryParam; this.isForcedCache = isForcedCache; this.shouldIgnoreToken = shouldIgnoreToken; } @Override public void intercept(RequestFacade requestFacade) { RetrofitCounter.increment(); final String token = APIHelpers.getToken(context); final String userAgent = APIHelpers.getUserAgent(context); final String domain = APIHelpers.loadProtocol(context) + "://" + APIHelpers.getDomain(context); //Set the UserAgent if(userAgent != null && !userAgent.equals("")) requestFacade.addHeader("User-Agent", userAgent); //Authenticate if possible if(!shouldIgnoreToken && token != null && !token.equals("")){ requestFacade.addHeader("Authorization", "Bearer " + token); } if (isForcedCache) { requestFacade.addHeader("Cache-Control", "only-if-cached"); } else { requestFacade.addHeader("Cache-Control", "no-cache"); } //HTTP referer (originally a misspelling of referrer) is an HTTP header field that identifies the address of the webpage that linked to the resource being requested //Source: https://en.wikipedia.org/wiki/HTTP_referer //Some schools use an LTI tool called SlideShare that whitelists domains to be able to inject content into assignments //They check the referrer in order to do this. 203 requestFacade.addHeader("Referer", domain); //Masquerade if necessary if (Masquerading.isMasquerading(context)) { requestFacade.addQueryParam("as_user_id", Long.toString(Masquerading.getMasqueradingId(context))); } if(addPerPageQueryParam) { //Sets the per_page count so we can get back more items with less round-trip calls. requestFacade.addQueryParam("per_page", Integer.toString(numberOfItemsPerPage)); } } } public static boolean isNetworkAvaliable(Context context) { ConnectivityManager connectivity =(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivity == null) { return false; } else { NetworkInfo[] info = connectivity.getAllNetworkInfo(); if (info != null) { for (int i = 0; i < info.length; i++) { if (info[i].getState() == NetworkInfo.State.CONNECTED) { return true; } } } } return false; } /** * set a new default for the number of items returned per page. * * @param itemsPerPage */ public static void setDefaultNumberOfItemsPerPage(int itemsPerPage) { if(itemsPerPage > 0){ numberOfItemsPerPage = itemsPerPage; } } /** * Gets our custom GSON parser. * * @return Our custom GSON parser with custom deserializers. */ public static Gson getGSONParser(){ GsonBuilder b = new GsonBuilder(); //TODO:Register custom parsers here! return b.create(); } /** * Sets up the CanvasRestAdapter. * * Short hand for setdomain, setToken, and setProtocol. * * Clears out any old data before setting the new data. * * @param context An Android context. * @param token An OAuth2 Token * @param domain The domain for the signed in user. * @param itemsPerPage The number of items to return per page. Default is 30. * @return Whether or not the instance was setup. Only returns false if the data is empty or invalid. */ public static boolean setupInstance(Context context, String token, String domain, int itemsPerPage){ setDefaultNumberOfItemsPerPage(itemsPerPage); return setupInstance(context,token,domain); } /** * Sets up the CanvasRestAdapter. * * Short hand for setdomain, setToken, and setProtocol. * * Clears out any old data before setting the new data. * * @param context An Android context. * @param token An OAuth2 Token * @param domain The domain for the signed in user. * * @return Whether or not the instance was setup. Only returns false if the data is empty or invalid. */ public static boolean setupInstance(Context context, String token, String domain){ if (token == null || token.equals("") || domain == null) { return false; } String protocol = "https"; if(domain.startsWith("http://")) { protocol = "http"; } return (APIHelpers.setDomain(context, domain) && APIHelpers.setToken(context, token) && APIHelpers.setProtocol(protocol, context)); } }