package net.hockeyapp.android.metrics; import android.annotation.TargetApi; import android.os.AsyncTask; import android.os.Build; import android.text.TextUtils; import net.hockeyapp.android.utils.AsyncTaskUtils; import net.hockeyapp.android.utils.HockeyLog; import java.io.*; import java.lang.ref.WeakReference; import java.net.HttpURLConnection; import java.net.URL; import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicInteger; import java.util.zip.GZIPOutputStream; /** * <h3>Description</h3> * * Either calls execute or executeOnExecutor on an AsyncTask depending on the * API level. **/ public class Sender { /** * Default endpoint to send the telemetry data to. */ static final String DEFAULT_ENDPOINT_URL = "https://gate.hockeyapp.net/v2/track"; /** * Read timeout for transmission. */ static final int DEFAULT_SENDER_READ_TIMEOUT = 10 * 1000; /** * Connect timeout for transmission. */ static final int DEFAULT_SENDER_CONNECT_TIMEOUT = 15 * 1000; /** * The max number of requests to perform in parallel. */ static final int MAX_REQUEST_COUNT = 10; /** * The logging tag. */ private static final String TAG = "HockeyApp-Metrics"; /** * Persistence object used to reserve, free, or delete files. */ protected WeakReference<Persistence> mWeakPersistence; /** * Thread safe counter to keep track of number of concurrent operations. */ private AtomicInteger mRequestCount; /** * Custom ingestion endpoint URL. */ private String mCustomServerURL; /** * Creates and initializes a new instance. * <p/> * Persistence has to be configured separately and has to be set directly * after initialization. */ protected Sender() { mRequestCount = new AtomicInteger(0); } /** * Triggers sending of available telemetry data in an AsyncTask. Checks with persistence * for available data, if the max amount of concurrent requests is not reached yet. * Does nothing, if the maximum number of concurrent requests is already reached or exceeded. */ protected void triggerSending() { if (requestCount() < MAX_REQUEST_COUNT) { try { AsyncTaskUtils.execute( new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { // Send the persisted data sendAvailableFiles(); return null; } } ); } catch (RejectedExecutionException e) { HockeyLog.error("Could not send events. Executor rejected async task.", e); } } else { HockeyLog.debug(TAG, "We have already 10 pending requests, not sending anything."); } } protected void triggerSendingForTesting(final HttpURLConnection connection, final File file, final String persistedData) { if (requestCount() < MAX_REQUEST_COUNT) { mRequestCount.getAndIncrement(); AsyncTaskUtils.execute( new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { // Send the persisted data send(connection, file, persistedData); return null; } } ); } } /** * Checks the persistence for available files and sends them. */ protected void sendAvailableFiles() { if (this.getPersistence() != null) { File fileToSend = this.getPersistence().nextAvailableFileInDirectory(); String persistedData = loadData(fileToSend); HttpURLConnection connection = createConnection(); if ((persistedData != null) && (connection != null)) { send(connection, fileToSend, persistedData); } } } /** * Send a file to the ingestion endpoint. * * @param connection * @param file * @param persistedData */ protected void send(HttpURLConnection connection, File file, String persistedData) { // TODO Why does this get the file and persistedData reference, even though everything is in the connection? // TODO Looks like this will have to be rewritten for its own AsyncTask subclass. if (connection != null && file != null && persistedData != null) { try { mRequestCount.getAndIncrement(); logRequest(connection, persistedData); // Starts the query connection.connect(); // read the response code while we're ready to catch the IO exception int responseCode = connection.getResponseCode(); // process the response onResponse(connection, responseCode, persistedData, file); } catch (IOException e) { // Probably offline HockeyLog.debug(TAG, "Couldn't send data with " + e.toString()); mRequestCount.getAndDecrement(); if (this.getPersistence() != null) { HockeyLog.debug(TAG, "Persisting because of IOException: We're probably offline."); this.getPersistence().makeAvailable(file); //send again later } } catch (SecurityException e) { // Permission denied HockeyLog.debug(TAG, "Couldn't send data with " + e.toString()); mRequestCount.getAndDecrement(); if (this.getPersistence() != null) { HockeyLog.debug(TAG, "Persisting because of SecurityException: Missing INTERNET permission or the user might have removed the internet permission."); this.getPersistence().makeAvailable(file); //send again later } } } } /** * Read the contents of a file from the persistence layer. * * @param file The file to read. * @return Persisted data as String, or null if the persistence is not set or the file does not exist. */ protected String loadData(File file) { String persistedData = null; if (this.getPersistence() != null) { if (file != null) { persistedData = this.getPersistence().load(file); if ((persistedData != null) && (persistedData.isEmpty())) { this.getPersistence().deleteFile(file); } } } return persistedData; } /** * Create a connection to the default or user defined server endpoint. In case creating a * custom URL for the connection fails, a connection to the default endpoint will be created. * * @return connection to the API endpoint */ @SuppressWarnings("ConstantConditions") protected HttpURLConnection createConnection() { URL url; HttpURLConnection connection = null; try { if (getCustomServerURL() == null) { url = new URL(DEFAULT_ENDPOINT_URL); } else { url = new URL(this.mCustomServerURL); // TODO The constructor of URL() will never return null but rather throw a MalformedURLException // TODO this being caught below, makes this code redundant. if (url == null) { url = new URL(DEFAULT_ENDPOINT_URL); } } // TODO Replace with HttpUrlConnectionBuilder calls - expand this if necessary. connection = (HttpURLConnection) url.openConnection(); connection.setReadTimeout(DEFAULT_SENDER_READ_TIMEOUT); connection.setConnectTimeout(DEFAULT_SENDER_CONNECT_TIMEOUT); connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/x-json-stream"); connection.setDoOutput(true); connection.setDoInput(true); connection.setUseCaches(false); } catch (IOException e) { HockeyLog.error(TAG, "Could not open connection for provided URL with exception: ", e); } return connection; } /** * Callback for the http response from the sender. * * @param connection The connection containing the response. * @param responseCode The response code from the connection. * @param payload the payload which generated this response * @param fileToSend reference to the file we want to send */ protected void onResponse(HttpURLConnection connection, int responseCode, String payload, File fileToSend) { // TODO Remove possible redundancy between response code and connection which also provides the same response code. // TODO This looks like a weird solution to keep the reference to the payload and the sent file. mRequestCount.getAndDecrement(); HockeyLog.debug(TAG, "response code " + Integer.toString(responseCode)); boolean isRecoverableError = isRecoverableError(responseCode); if (isRecoverableError) { HockeyLog.debug(TAG, "Recoverable error (probably a server error), persisting data:\n" + payload); if (this.getPersistence() != null) { this.getPersistence().makeAvailable(fileToSend); } } else { //delete in case of success or unrecoverable errors if (this.getPersistence() != null) { this.getPersistence().deleteFile(fileToSend); } //trigger send next file or log unexpected responses StringBuilder builder = new StringBuilder(); if (isExpected(responseCode)) { triggerSending(); } else { this.onUnexpected(connection, responseCode, builder); } } } /** * Determines if an HTTP response code denotes an error from which we can recover by sending the data again. * * @param responseCode The response code to check. * @return True, if we can recover from this error code, otherwise false. */ protected boolean isRecoverableError(int responseCode) { /* 429 -> TOO MANY REQUESTS 503 -> SERVICE UNAVAILABLE 511 -> NETWORK AUTHENTICATION REQUIRED All not available in HttpUrlConnection, thus listed here for reference. */ List<Integer> recoverableCodes = Arrays.asList(HttpURLConnection.HTTP_CLIENT_TIMEOUT, 429, HttpURLConnection.HTTP_INTERNAL_ERROR, 503, 511); return recoverableCodes.contains(responseCode); } /** * Determines if an HTTP response code denotes a status which we regard as successful completion. * * @param responseCode The response code to check. * @return True, if the response code means a successful operation, otherwise false. */ protected boolean isExpected(int responseCode) { return (HttpURLConnection.HTTP_OK <= responseCode && responseCode <= HttpURLConnection.HTTP_NOT_AUTHORITATIVE); } /** * Handler to be called if an unexpected response was returned from the ingestion endpoint. * * @param connection The connection containing the response. * @param responseCode The response code from the connection. * @param builder A string builder for storing the response. */ protected void onUnexpected(HttpURLConnection connection, int responseCode, StringBuilder builder) { String message = String.format(Locale.ROOT, "Unexpected response code: %d", responseCode); builder.append(message); builder.append("\n"); // log the unexpected response HockeyLog.error(TAG, message); // attempt to read the response stream this.readResponse(connection, builder); } /** * Log information about request/connection/payload to LogCat * * @param connection the connection * @param payload the payload of telemetry data */ private void logRequest(HttpURLConnection connection, String payload) throws IOException, SecurityException { // TODO Rename this to reflect the true nature of this method: Sending the payload Writer writer = null; try { if (connection != null && payload != null) { HockeyLog.debug(TAG, "Sending payload:\n" + payload); HockeyLog.debug(TAG, "Using URL:" + connection.getURL().toString()); //the following 3 lines actually appends the payload to the connection writer = getWriter(connection); writer.write(payload); writer.flush(); } } finally { if (writer != null) { try { writer.close(); } catch (IOException e) { HockeyLog.error(TAG, "Couldn't close writer with: " + e.toString()); } } } } /** * Reads the response from a connection. * * @param connection the connection which will read the response * @param builder a string builder for storing the response */ protected void readResponse(HttpURLConnection connection, StringBuilder builder) { String result = null; StringBuffer buffer = new StringBuffer(); InputStream inputStream = null; try { inputStream = connection.getErrorStream(); if (inputStream == null) { inputStream = connection.getInputStream(); } if(inputStream != null){ BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, "UTF-8")); String inputLine = ""; while ((inputLine = br.readLine()) != null) { buffer.append(inputLine); } result = buffer.toString(); } else { result = connection.getResponseMessage(); } if(!TextUtils.isEmpty(result)) { HockeyLog.verbose(result); } else { HockeyLog.verbose(TAG, "Couldn't log response, result is null or empty string"); } } catch (IOException e) { HockeyLog.error(TAG, e.toString()); } finally { if (inputStream != null) { try { inputStream.close(); } catch (IOException e) { HockeyLog.error(TAG, e.toString()); } } } } /** * Gets a writer from the connection stream (allows for test hooks into the write stream) * * @param connection the connection to which the stream will be flushed * @return a writer for the given connection stream * @throws java.io.IOException Exception thrown by GZIP (used in SDK 19+) */ @TargetApi(Build.VERSION_CODES.KITKAT) protected Writer getWriter(HttpURLConnection connection) throws IOException { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { // GZIP if we are running SDK 19 or higher connection.addRequestProperty("Content-Encoding", "gzip"); connection.setRequestProperty("Content-Type", "application/x-json-stream"); GZIPOutputStream gzip = new GZIPOutputStream(connection.getOutputStream(), true); return new OutputStreamWriter(gzip, "UTF-8"); } else { // no GZIP for older devices return new OutputStreamWriter(connection.getOutputStream(), "UTF-8"); } } protected Persistence getPersistence() { Persistence persistence = null; if (mWeakPersistence != null) { persistence = mWeakPersistence.get(); } return persistence; } /** * Set persistence used to reserve, free, or delete files (enables dependency injection). * * @param persistence a persistence used to reserve, free, or delete files */ protected void setPersistence(Persistence persistence) { mWeakPersistence = new WeakReference<>(persistence); } /** * Getter for requestCount. Important for unit testing. * * @return the number of running requests */ protected int requestCount() { return mRequestCount.get(); } protected String getCustomServerURL() { return mCustomServerURL; } /** * Set a custom server URL that will be used to send data to it. * * @param customServerURL URL for custom server endpoint to collect telemetry */ protected void setCustomServerURL(String customServerURL) { mCustomServerURL = customServerURL; } }