package dk.slott.super_volley.managers; import java.io.File; import java.lang.ref.WeakReference; import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.lang.reflect.Type; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.Locale; import java.util.Map; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import com.android.volley.Cache; import com.android.volley.DefaultRetryPolicy; import com.android.volley.Request; import com.android.volley.Response; import com.android.volley.VolleyError; import com.android.volley.toolbox.JsonObjectRequest; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; import android.app.Activity; import android.util.Base64; import android.util.Log; import dk.slott.super_volley.MainApplication; import dk.slott.super_volley.R; import dk.slott.super_volley.config.Config; import dk.slott.super_volley.models.ErrorModel; import dk.slott.super_volley.models.JSonModel; import dk.slott.super_volley.requests.FileRequest; import dk.slott.super_volley.requests.GsonRequest; import dk.slott.super_volley.requests.JsonArrayAuthRequest; import dk.slott.super_volley.tasks.ProcessJSONResponseArrayTask; /** * Data manager with tight integration to Volley. * * @author Morten Slott Hansen */ public class DataManagerHelper { private static final String TAG = DataManagerHelper.class.getSimpleName(); public static final String REST_PREFIX = Config.SERVER_ADDRESS; protected static int DEFAULT_CACHE_TIMEOUT = 3600; protected static int DAY_CACHE_TIMEOUT = 86400; // Use cached data and right away request new data. protected static int CACHE_AND_REQUEST = 0; private WeakReference<Activity> activity; private int progressBarCount = 0; private final Gson gson; protected static String CACHE = "cache"; final static Map<String, String> authParams = new HashMap<String, String>(); /** * We use the activity to show a progress spinner whenever there is network activity. This is also * used to tag every request to the activity that spawn them so they can be terminated when an activity * is paused. * * @param activity */ public DataManagerHelper(final Activity activity) { this.activity = new WeakReference<Activity>(activity); this.gson = createGsonBuilder(Config.DATE_FORMAT); } /** * Create a gson builder with a more advanced date parser. * http://danwiechert.blogspot.dk/2013/02/gson-and-date-formatting.html * * @param dateFormat * @return */ private static Gson createGsonBuilder(final String dateFormat) { final GsonBuilder builder = new GsonBuilder(); builder.registerTypeAdapter(Date.class, new JsonDeserializer<Date>() { final DateFormat df = new SimpleDateFormat(dateFormat, Locale.US); @Override public Date deserialize(final JsonElement json, final Type typeOfT, final JsonDeserializationContext context) throws JsonParseException { try { return df.parse(json.getAsString()); } catch (final java.text.ParseException e) { Log.e(TAG, "ParseException: " + e); return null; } } }); return builder.create(); } /** * Show the spinning progressbar in title. * MSH: Synchronized together with @hideProgressBar */ protected void showProgressBar() { synchronized (this) { if (this.progressBarCount == 0) setProgressBarIndeterminateVisibility(true); this.progressBarCount++; Log.d(TAG, "showProgressBar: " + this.progressBarCount); } } /** * Hide the spinning progressbar in title. * MSH: Synchronized together with @showProgressBar */ protected void hideProgressBar() { synchronized (this) { this.progressBarCount--; if (this.progressBarCount == 0) setProgressBarIndeterminateVisibility(false); Log.d(TAG, "hideProgressBar: " + this.progressBarCount); } } /** * Show/Hide Progressbar (aka. spinner) in the action bar. * Activity is null if datamanager is created without an activity ie. from inside a provioder with no UI. * * @param b */ private void setProgressBarIndeterminateVisibility(final boolean b) { final Activity activity = this.getActivity(); if (activity != null) { Log.d(TAG, "class name: " + activity.getClass().getName()); // MSH: Need this to determine if current activity is of type SherlockFragmentActivity. Class<?> sherlockFragmentActivity = null; try { sherlockFragmentActivity = Class.forName("com.actionbarsherlock.app.SherlockFragmentActivity"); } catch (ClassNotFoundException e) { Log.d(TAG, "ClassNotFoundException: " + e); } // MSH: Take action based on activity type. if (sherlockFragmentActivity != null && sherlockFragmentActivity.isInstance(activity)) { Log.d(TAG, "We have a SherlockFragmentActivity!"); // MSH: Trigger support method. try { Method method = activity.getClass().getMethod("setSupportProgressBarIndeterminateVisibility", boolean.class); method.invoke(activity, b); } catch (Exception e) { Log.e(TAG, "Exception: " + e); } Log.d(TAG, "Done calling setSupportProgressBarIndeterminateVisibility..."); } else { Log.d(TAG, "We have a regular activity..."); // MSH: Trigger regular method. activity.setProgressBarIndeterminateVisibility(b); Log.d(TAG, "Done calling setProgressBarIndeterminateVisibility..."); } } } /** * Factory method for returning a query map with a user defined template for converting the map to a string. * @return QueryMap */ public static QueryMap getQueryMap() { return new QueryMap(); } /** * Factory method for returning a resource map. * http://en.wikipedia.org/wiki/Representational_state_transfer#Applied_to_web_services * @return resource map for storing url resources * @see ResourceMap */ public static ResourceMap getResourceMap() { return new ResourceMap(); } /** * Base64 encode string. * * @param input * @return */ protected static String base64Encode(final String input) { return Base64.encodeToString(input.getBytes(), Base64.NO_WRAP); } private void applyQueryTemplate(final QueryMap qm) { if (qm != null) qm.setQueryTemplate(Config.QUERY_PATTERN); } /** * Extracts cache timeout from query string. Returns -1 if undefined. * * @param qm * @return */ private int processCacheTimeout(final QueryMap qm) { if (qm != null) { // MSH: Look for cache timeout. if (qm.containsKey(CACHE)) { final int cache = (Integer) qm.get(CACHE); // MSH: Remove cache key so it is not sent to the server. qm.remove(CACHE); return cache; } else return -1; // Default is disabled } else return -1; // Default is disabled } /** * Volley caches all request data and uses them when offline. However untill the server can correctly supply Cache-Control headers we still * get a better performance if we handle some caching ourselves. * * @param url * @param requestMethod * @param resultListener */ protected void requestDataUsingCache(final Enum<?> area, final Enum<?> function, final int requestMethod, final QueryMap qm, final ResultListenerNG<JSONObject> resultListener) { applyQueryTemplate(qm); final int cacheTimeout = processCacheTimeout(qm); Log.d(TAG, "cacheTimeout: " + cacheTimeout); // MSH: Generate url based on Request Method. JSONObject jsonRequest = null; final String url; // MSH: If it is a post parameters must be sent as part of the requestBody - not regular url parameters. if (requestMethod == Request.Method.POST) { jsonRequest = mapToJson(qm); url = generateUrl(area, function); } // MSH: Else just append as a regular url query string. else { if (qm != null) { url = generateUrl(area, function, qm.toString()); } else url = generateUrl(area, function); } Log.d(TAG, "requestData url: " + url); if (jsonRequest != null) Log.d(TAG, "requestData jsonRequest: " + jsonRequest.toString()); final CacheManager cacheManager = new CacheManager(md5(url), cacheTimeout); // Lookup data in cache. if (cacheManager.isCached()) { Log.d(TAG, "Cache hit"); // MSH: Fetch cache on BG thread. final Runnable r = new Runnable() { @Override public void run() { try { final JSONObject cachedJasonObject = new JSONObject(cacheManager.readCache()); getActivity().runOnUiThread(new Runnable() { public void run() { // Show cached result regardless of expiration date. If expired a new request will be made and the data will get updarted once the server returns the data. resultListener.onSuccess(cachedJasonObject); } }); } catch (JSONException e) { Log.e(TAG, "JSONException: " + e); } } }; final Thread t = new Thread(r); t.start(); // Has cache expired? if (cacheManager.isExpired()) { Log.d(TAG, "Cache has expired - request new data"); } else { Log.d(TAG, "Cache is still valid - no need to request new data"); return; } } else Log.d(TAG, "Cache miss"); // MSH: Setup request for fetching data. final JsonObjectRequest jr = new JsonObjectRequest(requestMethod, url, jsonRequest, new Response.Listener<JSONObject>() { @Override public void onResponse(final JSONObject response) { hideProgressBar(); // Cache response. cacheManager.writeCache(response); resultListener.onSuccess(response); } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { hideProgressBar(); resultListener.onError(processVolleyError(error)); } } ); processRequest(jr); } /** * Generic method for requesting JSON Object data. * Note that requestMethod should be Request.Method.GET/POST/DELETE/PUT * The post data is sent to the request class through the 3rd parameter as a JSON object. * @param resultListener */ /** * This is used for raw array without a name ie. JSONArray and not JSONObject. * * @param url * @param resultListener */ public void requestData(final String url, final ResultListenerNG<JSONArray> resultListener) { Log.d(TAG, "Requesting json data from: " + url); final JsonArrayAuthRequest jr = new JsonArrayAuthRequest(url, new Response.Listener<JSONArray>() { @Override public void onResponse(JSONArray response) { hideProgressBar(); resultListener.onSuccess(response); } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { hideProgressBar(); resultListener.onError(processVolleyError(error)); } } ); processRequest(jr); } /** * TODO: Make private as it should not be called directly. * @param url * @param resultListener */ protected void requestString(final String url, final ResultListenerNG<String> resultListener) { } /** * Method for requesting JSON data and parse on background thread with resulting GSON objects. * * @param <T> * @param url REST URL * @param modelClass class which will be used to map the JSON response to objects of the specified class * @param resultListener Callback which will be notified upon GSON parsing is done */ protected <T> void gsonGenericRequest(final int method, final String url, final JSONObject jsonRequest, final int cacheTimeout, final Class<T> clazz, final ResultListenerNG<T> resultListener) { final CacheManager cacheManager = new CacheManager(md5(url), cacheTimeout); // Lookup data in cache. if (cacheManager.isCached()) { Log.d(TAG, "Cache hit"); // MSH: retrieve data from cache and parse into and object before returning. final String json = cacheManager.readCache(); final Runnable r = new Runnable() { @Override public void run() { try { Log.d(TAG, "Parsing response into an object"); final T result = gson.fromJson(json, clazz); // MSH: Return parsed result on the UI thread. getActivity().runOnUiThread(new Runnable() { public void run() { resultListener.onSuccess(result); } }); } catch (Exception e) { Log.e(TAG, "Exception: " + e); } } }; final Thread t = new Thread(r); t.start(); // MSH: Cache is still valid - no need to request new data. if (cacheManager.isExpired()) Log.d(TAG, "Cache has expired - request new data"); else { Log.d(TAG, "Cache is still valid - no need to request new data"); // Return and avoid sending a request to the server. return; } } else Log.d(TAG, "Cache miss"); // MSH: Setup request object which will fetch data from the server. final GsonRequest<T> request = new GsonRequest<T>(method, url, jsonRequest, clazz, cacheTimeout, new Response.Listener<T>() { @Override public void onResponse(final T response) { hideProgressBar(); // MSH: Run on bg thread to avoid hanging on the UI while converting. final Runnable r = new Runnable() { @Override public void run() { if (!cacheManager.isDisabled()) { //CLPET: https://code.google.com/p/google-gson/issues/detail?id=162 Gson gson = new GsonBuilder().setDateFormat(Config.DATE_FORMAT).create();//CLPET: Threading issues... cacheManager.writeCache(gson.toJson(response)); } } }; final Thread t = new Thread(r); t.start(); if(resultListener != null) resultListener.onSuccess(response); } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { hideProgressBar(); if(resultListener != null) resultListener.onError(processVolleyError(error)); } } ); processRequest(request); } /** * Tag request, schedule for execution and show progress bar. * Note that the TTL is applies here which is then picked up by * the HttpStack object ie. our custom @ExHttpClientStack * * @param r */ private void processRequest(final Request<?> r) { Log.d(TAG, "processRequest"); final Activity activity = getActivity(); if(activity != null) { // MSH: Tag request with current activity so that we may cancel all requests when leaving the activity. r.setTag(getActivity()); Log.d(TAG, "Request tagged with activity: " + getActivity().getClass().getSimpleName()); } Log.d(TAG, "Request not tagged!"); // MSH: Inject Cache headers so etag gets processed in Network code if (r.getCacheEntry() == null) r.setCacheEntry(new Cache.Entry()); // MSH: Change timeout policy. - http://stackoverflow.com/questions/17094718/android-volley-timeout if (DefaultRetryPolicy.DEFAULT_TIMEOUT_MS != Config.DEFAULT_TIMEOUT_MS) r.setRetryPolicy(new DefaultRetryPolicy(Config.DEFAULT_TIMEOUT_MS, DefaultRetryPolicy.DEFAULT_MAX_RETRIES, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT)); // Schedule request. RequestManager.getRequestQueue().add(r); showProgressBar(); } /** * Process VolleyError into a generic error model. * * @param error * @return */ protected static ErrorModel processVolleyError(final VolleyError error) { // MSH: Avoid returning null. ErrorModel em = new ErrorModel(); try { em = new ErrorModel(error); } catch (JSONException e) { Log.e(TAG, "JSONException: " + e); em = new ErrorModel(); em.setErrorNumber(-1); em.setErrorMsg("Unknown error!!"); } Log.d(TAG, "processVolleyError: " + em.getErrorNumber()); // MSH: Always show a toast if there is a connection problem. if (em.getErrorNumber() == 0) { Log.d(TAG, "exception: " + error.getLocalizedMessage()); Log.d(TAG, "msg: " + em.getErrorMsg()); // MSH: Only show internet error if network related. final String message = error.getLocalizedMessage(); if (message != null && message.indexOf("UnknownHostException") != -1) MainApplication.displayToast(R.string.no_internet_title); } return em; } /** * MD5 value of a string. * * @param s * @return */ private static synchronized String md5(final String string) { try { // Create MD5 Hash final MessageDigest digest = java.security.MessageDigest.getInstance("MD5"); digest.update(string.getBytes()); byte messageDigest[] = digest.digest(); // Create Hex String final StringBuffer hexString = new StringBuffer(); for (int i = 0; i < messageDigest.length; i++) hexString.append(Integer.toHexString(0xFF & messageDigest[i])); return hexString.toString(); } catch (NoSuchAlgorithmException e) { Log.e(TAG, "NoSuchAlgorithmException: " + e); } return ""; } /** * Convert Map into a generic JSON object. * * @param map * @return */ protected static JSONObject mapToJson(final Map<String, Object> map) { final Gson gson = createGsonBuilder(Config.DATE_FORMAT); final String json = gson.toJson(map); try { return new JSONObject(json); } catch (JSONException e) { Log.e(TAG, "JSONException: " + e); return new JSONObject(); } } /** * Generate REST url. Api key and session id not included as they are sent as headers. * * @param area * @param function * @param queryString * @return */ private static String generateUrl(final Enum<?> area, final Enum<?> function) { return generateUrl(area, null, function, ""); } private static String generateUrl(final Enum<?> area, final Enum<?> function, final String queryString) { return generateUrl(area, null, function, queryString); } private static String generateUrl(final Enum<?> area, final ResourceMap rm, final Enum<?> function) { return generateUrl(area, rm, function, ""); } protected static String generateUrl(final Enum<?> area, final Enum<?> function, final QueryMap qm) { return generateUrl(area, null, function, qm.toString()); } protected static String generateUrl(final Enum<?> area, final ResourceMap rm, final Enum<?> function, final String queryString) { return String.format(Config.URL_PATTERN, REST_PREFIX, area.toString(), (rm != null) ? rm.toString() : "", (function != null) ? function.toString() : "", (queryString != null) ? queryString : ""); } /** * MSH: Process cache paramter before sending to @gsonGenericRequest with extracted cache timeout. * * @param area * @param function * @param qm * @param clazz * @param resultListener */ protected <T> void gsonGenericRequest(final Enum<?> area, final Enum<?> function, final QueryMap qm, final int requestMethod, final Class<T> clazz, final ResultListenerNG<T> resultListener) { gsonGenericRequest(area, null, function, qm, requestMethod, clazz, resultListener); } protected <T> void gsonGenericRequest(final Enum<?> area, final ResourceMap rm, final Enum<?> function, final QueryMap qm, final int requestMethod, final Class<T> clazz, final ResultListenerNG<T> resultListener) { applyQueryTemplate(qm); final int cacheTimeout = processCacheTimeout(qm); Log.d(TAG, "cacheTimeout: " + cacheTimeout); // MSH: Generate url based on Request Method. JSONObject jsonRequest = null; final String url; // MSH: If it is a post parameters must be sent as part of the requestBody - not regular url parameters. if (requestMethod == Request.Method.POST) { jsonRequest = mapToJson(qm); url = generateUrl(area, rm ,function); } // MSH: Else just append as a regular url query string. else { url = generateUrl(area, rm, function, (qm != null) ? qm.toString() : null); } Log.d(TAG, "gsonGenericRequest: " + url); // Add caching logic here and remove it from the gson request class. // Should be faster as we skip adding the request object to the queue... gsonGenericRequest(requestMethod, url, jsonRequest, cacheTimeout, clazz, resultListener); } /** * Request a single JSON object. * * @param area * @param function * @param qm * @param requestMethod * @param resultListener * @param jsonModel */ protected <T> void requestGenericSingleObject(final Enum<?> area, final Enum<?> function, final QueryMap qm, final int requestMethod) { requestGenericSingleObject(area, function, qm, requestMethod, null, null); } protected <T> void requestGenericSingleObject(final Enum<?> area, final Enum<?> function, final QueryMap qm, final int requestMethod, final ResultListenerNG<JSonModel> resultListener, final Class<? extends JSonModel> jsonModel) { requestDataUsingCache(area, function, requestMethod, qm, new ResultListenerNG<JSONObject>() { @Override public void onSuccess(JSONObject response) { if (resultListener != null) { Constructor<?> c; try { c = jsonModel.getConstructor(new Class[]{JSONObject.class}); resultListener.onSuccess((JSonModel) c.newInstance(new Object[]{response})); } catch (Exception e) { Log.e(TAG, "Exception: " + e); } } } @Override public void onError(ErrorModel error) { if (resultListener != null) resultListener.onError(error); } }); } // MSH: Temp method while we wait for correct url Otherwise we should overload the correct way! protected void requestFile(final String url, final String localFilename, final ResultListenerNG<File> resultListener) { Log.d(TAG, "url: " + url); final Request<File> r = new FileRequest(Request.Method.GET, url, null, localFilename, new Response.Listener<File>() { @Override public void onResponse(File response) { hideProgressBar(); resultListener.onSuccess(response); } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { hideProgressBar(); resultListener.onError(processVolleyError(error)); } } ); // MSH: Change timeout policy. - http://stackoverflow.com/questions/17094718/android-volley-timeout if (DefaultRetryPolicy.DEFAULT_TIMEOUT_MS != Config.DEFAULT_TIMEOUT_MS) r.setRetryPolicy(new DefaultRetryPolicy(Config.DEFAULT_TIMEOUT_MS, DefaultRetryPolicy.DEFAULT_MAX_RETRIES, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT)); processRequest(r); } protected void requestFile(final Enum<?> area, final Enum<?> function, final int requestMethod, final QueryMap qm, final String localFilename, final ResultListenerNG<File> resultListener) { applyQueryTemplate(qm); final String url = generateUrl(area, function, qm.toString()); Log.d(TAG, "url: " + url); final Request<File> r = new FileRequest(requestMethod, url, null, localFilename, new Response.Listener<File>() { @Override public void onResponse(File response) { hideProgressBar(); resultListener.onSuccess(response); } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { hideProgressBar(); resultListener.onError(processVolleyError(error)); } } ); // MSH: Change timeout policy. - http://stackoverflow.com/questions/17094718/android-volley-timeout if (DefaultRetryPolicy.DEFAULT_TIMEOUT_MS != Config.DEFAULT_TIMEOUT_MS) r.setRetryPolicy(new DefaultRetryPolicy(Config.DEFAULT_TIMEOUT_MS, DefaultRetryPolicy.DEFAULT_MAX_RETRIES, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT)); processRequest(r); } protected <T> void requestGenericArray(final Enum<?> area, final Enum<?> function, final QueryMap qm, final ResultListenerNG<ArrayList<T>> resultListener, final Class<? extends JSonModel> jsonModel) { applyQueryTemplate(qm); final int cacheTimeout = processCacheTimeout(qm); Log.d(TAG, "cacheTimeout: " + cacheTimeout); final String url = generateUrl(area, function, qm != null ? qm.toString() : ""); final CacheManager cacheManager = new CacheManager(md5(url), cacheTimeout); // Lookup data in cache. if (cacheManager.isCached()) { Log.d(TAG, "Cache hit"); try { final JSONArray response = new JSONArray(cacheManager.readCache()); // Show cached result regardless of expiration date. If expired a new request will be made and the data will get updarted once the server returns the data. final ProcessJSONResponseArrayTask<T> processResponseTask = new ProcessJSONResponseArrayTask<T>(resultListener, jsonModel); processResponseTask.execute(response); } catch (JSONException e) { Log.e(TAG, "JSONException: " + e); } // Has cache expired? if (cacheManager.isExpired()) { Log.d(TAG, "Cache has expired - request new data"); } else { Log.d(TAG, "Cache is still valid - no need to request new data"); return; } } else Log.d(TAG, "Cache miss"); requestData(url, new ResultListenerNG<JSONArray>() { @Override public void onSuccess(JSONArray response) { cacheManager.writeCache(response); // MSH: Process result on background thread. final ProcessJSONResponseArrayTask<T> processResponseTask = new ProcessJSONResponseArrayTask<T>(resultListener, jsonModel); processResponseTask.execute(response); } @Override public void onError(ErrorModel error) { if (resultListener != null) resultListener.onError(error); } }); } /** * Cancel all pending requests tagged with this activity. * This should be called when an activity is move to the background ie onPause. */ public void cancelAll() { synchronized (this) { RequestManager.getRequestQueue().cancelAll(this.getActivity()); this.progressBarCount = 0; setProgressBarIndeterminateVisibility(false); } } /** * Return activity from weak reference. * * @return */ public Activity getActivity() { return this.activity.get(); } /** * Methods for handling auth params used by the Request classes. */ public static void putAuthParam(final String key, final String value) { authParams.put(key, value); } public static Map<String, String> getAuthParams() { return authParams; } public static void clearAuthParams() { authParams.clear(); } }