1 /*
2  * Copyright 2013, Leanplum, Inc. All rights reserved.
3  *
4  * Licensed to the Apache Software Foundation (ASF) under one
5  * or more contributor license agreements.  See the NOTICE file
6  * distributed with this work for additional information
7  * regarding copyright ownership.  The ASF licenses this file
8  * to you under the Apache License, Version 2.0 (the
9  * "License"); you may not use this file except in compliance
10  * with the License.  You may obtain a copy of the License at
11  *
12  *        http://www.apache.org/licenses/LICENSE-2.0
13  *
14  * Unless required by applicable law or agreed to in writing,
15  * software distributed under the License is distributed on an
16  * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
17  * KIND, either express or implied.  See the License for the
18  * specific language governing permissions and limitations
19  * under the License.
20  */
21 
22 package com.leanplum.internal;
23 
24 import android.content.Context;
25 import android.content.SharedPreferences;
26 import android.os.AsyncTask;
27 
28 import com.leanplum.Leanplum;
29 
30 import org.json.JSONException;
31 import org.json.JSONObject;
32 
33 import java.io.EOFException;
34 import java.io.File;
35 import java.io.FileOutputStream;
36 import java.io.IOException;
37 import java.io.InputStream;
38 import java.net.HttpURLConnection;
39 import java.net.SocketTimeoutException;
40 import java.net.URL;
41 import java.util.ArrayList;
42 import java.util.Date;
43 import java.util.HashMap;
44 import java.util.List;
45 import java.util.Locale;
46 import java.util.Map;
47 import java.util.Stack;
48 
49 /**
50  * Leanplum request class.
51  *
52  * @author Andrew First
53  */
54 public class Request {
55   private static final long DEVELOPMENT_MIN_DELAY_MS = 100;
56   private static final long DEVELOPMENT_MAX_DELAY_MS = 5000;
57   private static final long PRODUCTION_DELAY = 60000;
58   private static final String LEANPLUM = "__leanplum__";
59 
60   private static String appId;
61   private static String accessKey;
62   private static String deviceId;
63   private static String userId;
64   private static final Map<String, Boolean> fileTransferStatus = new HashMap<>();
65   private static int pendingDownloads;
66   private static NoPendingDownloadsCallback noPendingDownloadsBlock;
67 
68   // The token is saved primarily for legacy SharedPreferences decryption. This could
69   // likely be removed in the future.
70   private static String token = null;
71   private static final Object lock = Request.class;
72   private static final Map<File, Long> fileUploadSize = new HashMap<>();
73   private static final Map<File, Double> fileUploadProgress = new HashMap<>();
74   private static String fileUploadProgressString = "";
75   private static long lastSendTimeMs;
76   private static final Object uploadFileLock = new Object();
77 
78   private final String httpMethod;
79   private final String apiMethod;
80   private final Map<String, Object> params;
81   private ResponseCallback response;
82   private ErrorCallback error;
83   private boolean sent;
84 
85   private static ApiResponseCallback apiResponse;
86 
setAppId(String appId, String accessKey)87   public static void setAppId(String appId, String accessKey) {
88     Request.appId = appId;
89     Request.accessKey = accessKey;
90   }
91 
setDeviceId(String deviceId)92   public static void setDeviceId(String deviceId) {
93     Request.deviceId = deviceId;
94   }
95 
setUserId(String userId)96   public static void setUserId(String userId) {
97     Request.userId = userId;
98   }
99 
setToken(String token)100   public static void setToken(String token) {
101     Request.token = token;
102   }
103 
token()104   public static String token() {
105     return token;
106   }
107 
loadToken()108   public static void loadToken() {
109     Context context = Leanplum.getContext();
110     SharedPreferences defaults = context.getSharedPreferences(
111         LEANPLUM, Context.MODE_PRIVATE);
112     String token = defaults.getString(Constants.Defaults.TOKEN_KEY, null);
113     if (token == null) {
114       return;
115     }
116     setToken(token);
117   }
118 
saveToken()119   public static void saveToken() {
120     Context context = Leanplum.getContext();
121     SharedPreferences defaults = context.getSharedPreferences(
122         LEANPLUM, Context.MODE_PRIVATE);
123     SharedPreferences.Editor editor = defaults.edit();
124     editor.putString(Constants.Defaults.TOKEN_KEY, Request.token());
125     try {
126       editor.apply();
127     } catch (NoSuchMethodError e) {
128       editor.commit();
129     }
130   }
131 
appId()132   public static String appId() {
133     return appId;
134   }
135 
deviceId()136   public static String deviceId() {
137     return deviceId;
138   }
139 
userId()140   public static String userId() {
141     return Request.userId;
142   }
143 
Request(String httpMethod, String apiMethod, Map<String, Object> params)144   public Request(String httpMethod, String apiMethod, Map<String, Object> params) {
145     this.httpMethod = httpMethod;
146     this.apiMethod = apiMethod;
147     this.params = params != null ? params : new HashMap<String, Object>();
148 
149     // Make sure the Handler is initialized on the main thread.
150     OsHandler.getInstance();
151   }
152 
get(String apiMethod, Map<String, Object> params)153   public static Request get(String apiMethod, Map<String, Object> params) {
154     Log.LeanplumLogType level = Constants.Methods.LOG.equals(apiMethod) ?
155         Log.LeanplumLogType.DEBUG : Log.LeanplumLogType.VERBOSE;
156     Log.log(level, "Will call API method " + apiMethod + " with arguments " + params);
157     return RequestFactory.getInstance().createRequest("GET", apiMethod, params);
158   }
159 
post(String apiMethod, Map<String, Object> params)160   public static Request post(String apiMethod, Map<String, Object> params) {
161     Log.LeanplumLogType level = Constants.Methods.LOG.equals(apiMethod) ?
162         Log.LeanplumLogType.DEBUG : Log.LeanplumLogType.VERBOSE;
163     Log.log(level, "Will call API method " + apiMethod + " with arguments " + params);
164     return RequestFactory.getInstance().createRequest("POST", apiMethod, params);
165   }
166 
onResponse(ResponseCallback response)167   public void onResponse(ResponseCallback response) {
168     this.response = response;
169   }
170 
onError(ErrorCallback error)171   public void onError(ErrorCallback error) {
172     this.error = error;
173   }
174 
onApiResponse(ApiResponseCallback apiResponse)175   public void onApiResponse(ApiResponseCallback apiResponse) {
176     Request.apiResponse = apiResponse;
177   }
178 
createArgsDictionary()179   private Map<String, Object> createArgsDictionary() {
180     Map<String, Object> args = new HashMap<>();
181     args.put(Constants.Params.DEVICE_ID, deviceId);
182     args.put(Constants.Params.USER_ID, userId);
183     args.put(Constants.Params.ACTION, apiMethod);
184     args.put(Constants.Params.SDK_VERSION, Constants.LEANPLUM_VERSION);
185     args.put(Constants.Params.DEV_MODE, Boolean.toString(Constants.isDevelopmentModeEnabled));
186     args.put(Constants.Params.TIME, Double.toString(new Date().getTime() / 1000.0));
187     if (token != null) {
188       args.put(Constants.Params.TOKEN, token);
189     }
190     args.putAll(params);
191     return args;
192   }
193 
saveRequestForLater(Map<String, Object> args)194   private static void saveRequestForLater(Map<String, Object> args) {
195     synchronized (lock) {
196       Context context = Leanplum.getContext();
197       SharedPreferences preferences = context.getSharedPreferences(
198           LEANPLUM, Context.MODE_PRIVATE);
199       SharedPreferences.Editor editor = preferences.edit();
200       int count = preferences.getInt(Constants.Defaults.COUNT_KEY, 0);
201       String itemKey = String.format(Locale.US, Constants.Defaults.ITEM_KEY, count);
202       editor.putString(itemKey, JsonConverter.toJson(args));
203       count++;
204       editor.putInt(Constants.Defaults.COUNT_KEY, count);
205       try {
206         editor.apply();
207       } catch (NoSuchMethodError e) {
208         editor.commit();
209       }
210     }
211   }
212 
send()213   public void send() {
214     this.sendEventually();
215     if (Constants.isDevelopmentModeEnabled) {
216       long currentTimeMs = System.currentTimeMillis();
217       long delayMs;
218       if (lastSendTimeMs == 0 || currentTimeMs - lastSendTimeMs > DEVELOPMENT_MAX_DELAY_MS) {
219         delayMs = DEVELOPMENT_MIN_DELAY_MS;
220       } else {
221         delayMs = (lastSendTimeMs + DEVELOPMENT_MAX_DELAY_MS) - currentTimeMs;
222       }
223       OsHandler.getInstance().postDelayed(new Runnable() {
224         @Override
225         public void run() {
226           try {
227             sendIfConnected();
228           } catch (Throwable t) {
229             Util.handleException(t);
230           }
231         }
232       }, delayMs);
233     }
234   }
235 
236   /**
237    * Wait 1 second for potential other API calls, and then sends the call synchronously if no other
238    * call has been sent within 1 minute.
239    */
sendIfDelayed()240   public void sendIfDelayed() {
241     sendEventually();
242     OsHandler.getInstance().postDelayed(new Runnable() {
243       @Override
244       public void run() {
245         try {
246           sendIfDelayedHelper();
247         } catch (Throwable t) {
248           Util.handleException(t);
249         }
250       }
251     }, 1000);
252   }
253 
254   /**
255    * Sends the call synchronously if no other call has been sent within 1 minute.
256    */
sendIfDelayedHelper()257   private void sendIfDelayedHelper() {
258     if (Constants.isDevelopmentModeEnabled) {
259       send();
260     } else {
261       long currentTimeMs = System.currentTimeMillis();
262       if (lastSendTimeMs == 0 || currentTimeMs - lastSendTimeMs > PRODUCTION_DELAY) {
263         sendIfConnected();
264       }
265     }
266   }
267 
sendIfConnected()268   public void sendIfConnected() {
269     if (Util.isConnected()) {
270       this.sendNow();
271     } else {
272       this.sendEventually();
273       Log.i("Device is offline, will send later");
274       triggerErrorCallback(new Exception("Not connected to the Internet"));
275     }
276   }
277 
triggerErrorCallback(Exception e)278   private void triggerErrorCallback(Exception e) {
279     if (error != null) {
280       error.error(e);
281     }
282     if (apiResponse != null) {
283       List<Map<String, Object>> requests = getUnsentRequests();
284       apiResponse.response(requests, null);
285     }
286   }
287 
288   @SuppressWarnings("BooleanMethodIsAlwaysInverted")
attachApiKeys(Map<String, Object> dict)289   private boolean attachApiKeys(Map<String, Object> dict) {
290     if (appId == null || accessKey == null) {
291       Log.e("API keys are not set. Please use Leanplum.setAppIdForDevelopmentMode or "
292           + "Leanplum.setAppIdForProductionMode.");
293       return false;
294     }
295     dict.put(Constants.Params.APP_ID, appId);
296     dict.put(Constants.Params.CLIENT_KEY, accessKey);
297     dict.put(Constants.Params.CLIENT, Constants.CLIENT);
298     return true;
299   }
300 
301   public interface ResponseCallback {
response(JSONObject response)302     void response(JSONObject response);
303   }
304 
305   public interface ApiResponseCallback {
response(List<Map<String, Object>> requests, JSONObject response)306     void response(List<Map<String, Object>> requests, JSONObject response);
307   }
308 
309   public interface ErrorCallback {
error(Exception e)310     void error(Exception e);
311   }
312 
313   public interface NoPendingDownloadsCallback {
noPendingDownloads()314     void noPendingDownloads();
315   }
316 
parseResponseJson(JSONObject responseJson, List<Map<String, Object>> requestsToSend, Exception error)317   private void parseResponseJson(JSONObject responseJson, List<Map<String, Object>> requestsToSend,
318       Exception error) {
319     if (apiResponse != null) {
320       apiResponse.response(requestsToSend, responseJson);
321     }
322 
323     if (responseJson != null) {
324       Exception lastResponseError = null;
325       int numResponses = Request.numResponses(responseJson);
326       for (int i = 0; i < numResponses; i++) {
327         JSONObject response = Request.getResponseAt(responseJson, i);
328         if (!Request.isResponseSuccess(response)) {
329           String errorMessage = Request.getResponseError(response);
330           if (errorMessage == null || errorMessage.length() == 0) {
331             errorMessage = "API error";
332           } else if (errorMessage.startsWith("App not found")) {
333             errorMessage = "No app matching the provided app ID was found.";
334             Constants.isInPermanentFailureState = true;
335           } else if (errorMessage.startsWith("Invalid access key")) {
336             errorMessage = "The access key you provided is not valid for this app.";
337             Constants.isInPermanentFailureState = true;
338           } else if (errorMessage.startsWith("Development mode requested but not permitted")) {
339             errorMessage = "A call to Leanplum.setAppIdForDevelopmentMode "
340                 + "with your production key was made, which is not permitted.";
341             Constants.isInPermanentFailureState = true;
342           } else {
343             errorMessage = "API error: " + errorMessage;
344           }
345           Log.e(errorMessage);
346           if (i == numResponses - 1) {
347             lastResponseError = new Exception(errorMessage);
348           }
349         }
350       }
351 
352       if (lastResponseError == null) {
353         lastResponseError = error;
354       }
355 
356       if (lastResponseError != null && this.error != null) {
357         this.error.error(lastResponseError);
358       } else if (this.response != null) {
359         this.response.response(responseJson);
360       }
361     } else if (error != null && this.error != null) {
362       this.error.error(error);
363     }
364   }
365 
sendNow()366   private void sendNow() {
367     if (Constants.isTestMode) {
368       return;
369     }
370     if (appId == null) {
371       Log.e("Cannot send request. appId is not set.");
372       return;
373     }
374     if (accessKey == null) {
375       Log.e("Cannot send request. accessKey is not set.");
376       return;
377     }
378 
379     this.sendEventually();
380 
381     final List<Map<String, Object>> requestsToSend = popUnsentRequests();
382     if (requestsToSend.isEmpty()) {
383       return;
384     }
385 
386     final Map<String, Object> multiRequestArgs = new HashMap<>();
387     multiRequestArgs.put(Constants.Params.DATA, jsonEncodeUnsentRequests(requestsToSend));
388     multiRequestArgs.put(Constants.Params.SDK_VERSION, Constants.LEANPLUM_VERSION);
389     multiRequestArgs.put(Constants.Params.ACTION, Constants.Methods.MULTI);
390     multiRequestArgs.put(Constants.Params.TIME, Double.toString(new Date().getTime() / 1000.0));
391     if (!this.attachApiKeys(multiRequestArgs)) {
392       return;
393     }
394 
395     Util.executeAsyncTask(new AsyncTask<Void, Void, Void>() {
396       @Override
397       protected Void doInBackground(Void... params) {
398         JSONObject result = null;
399         HttpURLConnection op = null;
400         try {
401           try {
402             op = Util.operation(
403                 Constants.API_HOST_NAME,
404                 Constants.API_SERVLET,
405                 multiRequestArgs,
406                 httpMethod,
407                 Constants.API_SSL,
408                 Constants.NETWORK_TIMEOUT_SECONDS);
409 
410             result = Util.getJsonResponse(op);
411             int statusCode = op.getResponseCode();
412 
413             Exception errorException = null;
414             if (statusCode >= 400) {
415               errorException = new Exception("HTTP error " + statusCode);
416               if (statusCode == 408 || (statusCode >= 500 && statusCode <= 599)) {
417                 pushUnsentRequests(requestsToSend);
418               }
419             } else {
420               if (result != null) {
421                 int numResponses = Request.numResponses(result);
422                 if (numResponses != requestsToSend.size()) {
423                   Log.w("Sent " + requestsToSend.size() +
424                       " requests but only" + " received " + numResponses);
425                 }
426               } else {
427                 errorException = new Exception("Response JSON is null.");
428               }
429             }
430             parseResponseJson(result, requestsToSend, errorException);
431           } catch (JSONException e) {
432             Log.e("Error parsing JSON response: " + e.toString() + "\n" +
433                 Log.getStackTraceString(e));
434             parseResponseJson(null, requestsToSend, e);
435           } catch (Exception e) {
436             pushUnsentRequests(requestsToSend);
437             Log.e("Unable to send request: " + e.toString() + "\n" +
438                 Log.getStackTraceString(e));
439             parseResponseJson(result, requestsToSend, e);
440           } finally {
441             if (op != null) {
442               op.disconnect();
443             }
444           }
445         } catch (Throwable t) {
446           Util.handleException(t);
447         }
448         return null;
449       }
450     });
451   }
452 
sendEventually()453   public void sendEventually() {
454     if (Constants.isTestMode) {
455       return;
456     }
457     if (!sent) {
458       sent = true;
459       Map<String, Object> args = createArgsDictionary();
460       saveRequestForLater(args);
461     }
462   }
463 
popUnsentRequests()464   static List<Map<String, Object>> popUnsentRequests() {
465     return getUnsentRequests(true);
466   }
467 
getUnsentRequests()468   static List<Map<String, Object>> getUnsentRequests() {
469     return getUnsentRequests(false);
470   }
471 
getUnsentRequests(boolean remove)472   private static List<Map<String, Object>> getUnsentRequests(boolean remove) {
473     List<Map<String, Object>> requestData = new ArrayList<>();
474 
475     synchronized (lock) {
476       lastSendTimeMs = System.currentTimeMillis();
477 
478       Context context = Leanplum.getContext();
479       SharedPreferences preferences = context.getSharedPreferences(
480           LEANPLUM, Context.MODE_PRIVATE);
481       SharedPreferences.Editor editor = preferences.edit();
482 
483       int count = preferences.getInt(Constants.Defaults.COUNT_KEY, 0);
484       if (count == 0) {
485         return new ArrayList<>();
486       }
487       if (remove) {
488         editor.remove(Constants.Defaults.COUNT_KEY);
489       }
490 
491       for (int i = 0; i < count; i++) {
492         String itemKey = String.format(Locale.US, Constants.Defaults.ITEM_KEY, i);
493         Map<String, Object> requestArgs;
494         try {
495           requestArgs = JsonConverter.mapFromJson(new JSONObject(
496               preferences.getString(itemKey, "{}")));
497           requestData.add(requestArgs);
498         } catch (JSONException e) {
499           e.printStackTrace();
500         }
501         if (remove) {
502           editor.remove(itemKey);
503         }
504       }
505       if (remove) {
506         try {
507           editor.apply();
508         } catch (NoSuchMethodError e) {
509           editor.commit();
510         }
511       }
512     }
513 
514     requestData = removeIrrelevantBackgroundStartRequests(requestData);
515     return requestData;
516   }
517 
518   /**
519    * In various scenarios we can end up batching a big number of requests (e.g. device is offline,
520    * background sessions), which could make the stored API calls batch look something like:
521    * <p>
522    * <code>start(B), start(B), start(F), track, start(B), track, start(F), resumeSession</code>
523    * <p>
524    * where <code>start(B)</code> indicates a start in the background, and <code>start(F)</code>
525    * one in the foreground.
526    * <p>
527    * In this case the first two <code>start(B)</code> can be dropped because they don't contribute
528    * any relevant information for the batch call.
529    * <p>
530    * Essentially we drop every <code>start(B)</code> call, that is directly followed by any kind of
531    * a <code>start</code> call.
532    *
533    * @param requestData A list of the requests, stored on the device.
534    * @return A list of only these requests, which contain relevant information for the API call.
535    */
removeIrrelevantBackgroundStartRequests( List<Map<String, Object>> requestData)536   private static List<Map<String, Object>> removeIrrelevantBackgroundStartRequests(
537       List<Map<String, Object>> requestData) {
538     List<Map<String, Object>> relevantRequests = new ArrayList<>();
539 
540     int requestCount = requestData.size();
541     if (requestCount > 0) {
542       for (int i = 0; i < requestCount; i++) {
543         Map<String, Object> currentRequest = requestData.get(i);
544         if (i < requestCount - 1
545             && Constants.Methods.START.equals(requestData.get(i + 1).get(Constants.Params.ACTION))
546             && Constants.Methods.START.equals(currentRequest.get(Constants.Params.ACTION))
547             && Boolean.TRUE.toString().equals(currentRequest.get(Constants.Params.BACKGROUND))) {
548           continue;
549         }
550         relevantRequests.add(currentRequest);
551       }
552     }
553 
554     return relevantRequests;
555   }
556 
jsonEncodeUnsentRequests(List<Map<String, Object>> requestData)557   private static String jsonEncodeUnsentRequests(List<Map<String, Object>> requestData) {
558     Map<String, Object> data = new HashMap<>();
559     data.put(Constants.Params.DATA, requestData);
560     return JsonConverter.toJson(data);
561   }
562 
pushUnsentRequests(List<Map<String, Object>> requestData)563   private static void pushUnsentRequests(List<Map<String, Object>> requestData) {
564     if (requestData == null) {
565       return;
566     }
567     for (Map<String, Object> args : requestData) {
568       Object retryCountString = args.get("retryCount");
569       int retryCount;
570       if (retryCountString != null) {
571         retryCount = Integer.parseInt(retryCountString.toString()) + 1;
572       } else {
573         retryCount = 1;
574       }
575       args.put("retryCount", Integer.toString(retryCount));
576       saveRequestForLater(args);
577     }
578   }
579 
getSizeAsString(int bytes)580   private static String getSizeAsString(int bytes) {
581     if (bytes < (1 << 10)) {
582       return bytes + " B";
583     } else if (bytes < (1 << 20)) {
584       return (bytes >> 10) + " KB";
585     } else {
586       return (bytes >> 20) + " MB";
587     }
588   }
589 
printUploadProgress()590   private static void printUploadProgress() {
591     int totalFiles = fileUploadSize.size();
592     int sentFiles = 0;
593     int totalBytes = 0;
594     int sentBytes = 0;
595     for (Map.Entry<File, Long> entry : fileUploadSize.entrySet()) {
596       File file = entry.getKey();
597       long fileSize = entry.getValue();
598       double fileProgress = fileUploadProgress.get(file);
599       if (fileProgress == 1) {
600         sentFiles++;
601       }
602       sentBytes += (int) (fileSize * fileProgress);
603       totalBytes += fileSize;
604     }
605     String progressString = "Uploading resources. " +
606         sentFiles + '/' + totalFiles + " files completed; " +
607         getSizeAsString(sentBytes) + '/' + getSizeAsString(totalBytes) + " transferred.";
608     if (!fileUploadProgressString.equals(progressString)) {
609       fileUploadProgressString = progressString;
610       Log.i(progressString);
611     }
612   }
613 
sendFilesNow(final List<String> filenames, final List<InputStream> streams)614   public void sendFilesNow(final List<String> filenames, final List<InputStream> streams) {
615     if (Constants.isTestMode) {
616       return;
617     }
618     final Map<String, Object> dict = createArgsDictionary();
619     if (!attachApiKeys(dict)) {
620       return;
621     }
622     final List<File> filesToUpload = new ArrayList<>();
623 
624     // First set up the files for upload
625     for (int i = 0; i < filenames.size(); i++) {
626       String filename = filenames.get(i);
627       if (filename == null || Boolean.TRUE.equals(fileTransferStatus.get(filename))) {
628         continue;
629       }
630       File file = new File(filename);
631       long size;
632       try {
633         size = streams.get(i).available();
634       } catch (IOException e) {
635         size = file.length();
636       } catch (NullPointerException e) {
637         // Not good. Can't read asset.
638         Log.e("Unable to read file " + filename);
639         continue;
640       }
641       fileTransferStatus.put(filename, true);
642       filesToUpload.add(file);
643       fileUploadSize.put(file, size);
644       fileUploadProgress.put(file, 0.0);
645     }
646     if (filesToUpload.size() == 0) {
647       return;
648     }
649 
650     printUploadProgress();
651 
652     // Now upload the files
653     Util.executeAsyncTask(new AsyncTask<Void, Void, Void>() {
654       @Override
655       protected Void doInBackground(Void... params) {
656         synchronized (uploadFileLock) {  // Don't overload app and server with many upload tasks
657           JSONObject result;
658           HttpURLConnection op = null;
659 
660           try {
661             op = Util.uploadFilesOperation(
662                 Constants.Params.FILE,
663                 filesToUpload,
664                 streams,
665                 Constants.API_HOST_NAME,
666                 Constants.API_SERVLET,
667                 dict,
668                 httpMethod,
669                 Constants.API_SSL,
670                 60);
671 
672             if (op != null) {
673               result = Util.getJsonResponse(op);
674               int statusCode = op.getResponseCode();
675               if (statusCode != 200) {
676                 throw new Exception("Leanplum: Error sending request: " + statusCode);
677               }
678               if (Request.this.response != null) {
679                 Request.this.response.response(result);
680               }
681             } else {
682               if (error != null) {
683                 error.error(new Exception("Leanplum: Unable to read file."));
684               }
685             }
686           } catch (JSONException e) {
687             Log.e("Unable to convert to JSON.", e);
688             if (error != null) {
689               error.error(e);
690             }
691           } catch (SocketTimeoutException e) {
692             Log.e("Timeout uploading files. Try again or limit the number of files " +
693                 "to upload with parameters to syncResourcesAsync.");
694             if (error != null) {
695               error.error(e);
696             }
697           } catch (Exception e) {
698             Log.e("Unable to send file.", e);
699             if (error != null) {
700               error.error(e);
701             }
702           } finally {
703             if (op != null) {
704               op.disconnect();
705             }
706           }
707 
708           for (File file : filesToUpload) {
709             fileUploadProgress.put(file, 1.0);
710           }
711           printUploadProgress();
712 
713           return null;
714         }
715       }
716     });
717 
718     // TODO: Upload progress
719   }
720 
downloadFile(final String path, final String url)721   void downloadFile(final String path, final String url) {
722     if (Constants.isTestMode) {
723       return;
724     }
725     if (Boolean.TRUE.equals(fileTransferStatus.get(path))) {
726       return;
727     }
728     pendingDownloads++;
729     Log.i("Downloading resource " + path);
730     fileTransferStatus.put(path, true);
731     final Map<String, Object> dict = createArgsDictionary();
732     dict.put(Constants.Keys.FILENAME, path);
733     if (!attachApiKeys(dict)) {
734       return;
735     }
736 
737     Util.executeAsyncTask(new AsyncTask<Void, Void, Void>() {
738       @Override
739       protected Void doInBackground(Void... params) {
740         try {
741           downloadHelper(Constants.API_HOST_NAME, Constants.API_SERVLET, path, url, dict);
742         } catch (Throwable t) {
743           Util.handleException(t);
744         }
745         return null;
746       }
747     });
748     // TODO: Download progress
749   }
750 
downloadHelper(String hostName, String servlet, final String path, final String url, final Map<String, Object> dict)751   private void downloadHelper(String hostName, String servlet, final String path, final String url,
752       final Map<String, Object> dict) {
753     HttpURLConnection op = null;
754     URL originalURL = null;
755     try {
756       if (url == null) {
757         op = Util.operation(
758             hostName,
759             servlet,
760             dict,
761             httpMethod,
762             Constants.API_SSL,
763             Constants.NETWORK_TIMEOUT_SECONDS_FOR_DOWNLOADS);
764       } else {
765         op = Util.createHttpUrlConnection(url, httpMethod, url.startsWith("https://"),
766             Constants.NETWORK_TIMEOUT_SECONDS_FOR_DOWNLOADS);
767       }
768       originalURL = op.getURL();
769       op.connect();
770       int statusCode = op.getResponseCode();
771       if (statusCode != 200) {
772         throw new Exception("Leanplum: Error sending request to: " + hostName +
773             ", HTTP status code: " + statusCode);
774       }
775       Stack<String> dirs = new Stack<>();
776       String currentDir = path;
777       while ((currentDir = new File(currentDir).getParent()) != null) {
778         dirs.push(currentDir);
779       }
780       while (!dirs.isEmpty()) {
781         String directory = FileManager.fileRelativeToDocuments(dirs.pop());
782         boolean isCreated = new File(directory).mkdir();
783         if (!isCreated) {
784           Log.w("Failed to create directory: ", directory);
785         }
786       }
787 
788       FileOutputStream out = new FileOutputStream(
789           new File(FileManager.fileRelativeToDocuments(path)));
790       Util.saveResponse(op, out);
791       pendingDownloads--;
792       if (Request.this.response != null) {
793         Request.this.response.response(null);
794       }
795       if (pendingDownloads == 0 && noPendingDownloadsBlock != null) {
796         noPendingDownloadsBlock.noPendingDownloads();
797       }
798     } catch (Exception e) {
799       if (e instanceof EOFException) {
800         if (op != null && !op.getURL().equals(originalURL)) {
801           downloadHelper(null, op.getURL().toString(), path, url, new HashMap<String, Object>());
802           return;
803         }
804       }
805       Log.e("Error downloading resource:" + path, e);
806       pendingDownloads--;
807       if (error != null) {
808         error.error(e);
809       }
810       if (pendingDownloads == 0 && noPendingDownloadsBlock != null) {
811         noPendingDownloadsBlock.noPendingDownloads();
812       }
813     } finally {
814       if (op != null) {
815         op.disconnect();
816       }
817     }
818   }
819 
numPendingDownloads()820   public static int numPendingDownloads() {
821     return pendingDownloads;
822   }
823 
onNoPendingDownloads(NoPendingDownloadsCallback block)824   public static void onNoPendingDownloads(NoPendingDownloadsCallback block) {
825     noPendingDownloadsBlock = block;
826   }
827 
828 
numResponses(JSONObject response)829   public static int numResponses(JSONObject response) {
830     if (response == null) {
831       return 0;
832     }
833     try {
834       return response.getJSONArray("response").length();
835     } catch (JSONException e) {
836       Log.e("Could not parse JSON response.", e);
837       return 0;
838     }
839   }
840 
getResponseAt(JSONObject response, int index)841   public static JSONObject getResponseAt(JSONObject response, int index) {
842     try {
843       return response.getJSONArray("response").getJSONObject(index);
844     } catch (JSONException e) {
845       Log.e("Could not parse JSON response.", e);
846       return null;
847     }
848   }
849 
getLastResponse(JSONObject response)850   public static JSONObject getLastResponse(JSONObject response) {
851     int numResponses = numResponses(response);
852     if (numResponses > 0) {
853       return getResponseAt(response, numResponses - 1);
854     } else {
855       return null;
856     }
857   }
858 
isResponseSuccess(JSONObject response)859   public static boolean isResponseSuccess(JSONObject response) {
860     if (response == null) {
861       return false;
862     }
863     try {
864       return response.getBoolean("success");
865     } catch (JSONException e) {
866       Log.e("Could not parse JSON response.", e);
867       return false;
868     }
869   }
870 
getResponseError(JSONObject response)871   public static String getResponseError(JSONObject response) {
872     if (response == null) {
873       return null;
874     }
875     try {
876       JSONObject error = response.optJSONObject("error");
877       if (error == null) {
878         return null;
879       }
880       return error.getString("message");
881     } catch (JSONException e) {
882       Log.e("Could not parse JSON response.", e);
883       return null;
884     }
885   }
886 }
887