// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.crash;
import android.content.Context;
import android.content.SharedPreferences;
import android.support.annotation.IntDef;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.StreamUtil;
import org.chromium.base.VisibleForTesting;
import org.chromium.chrome.browser.preferences.privacy.CrashReportingPermissionManager;
import org.chromium.chrome.browser.preferences.privacy.PrivacyPreferencesManager;
import org.chromium.chrome.browser.util.HttpURLConnectionFactory;
import org.chromium.chrome.browser.util.HttpURLConnectionFactoryImpl;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.util.Locale;
import java.util.concurrent.Callable;
import java.util.zip.GZIPOutputStream;
/**
* This class tries to upload a minidump to the crash server.
*
* It is implemented as a Callable<Boolean> and returns true on successful uploads,
* and false otherwise.
*/
public class MinidumpUploadCallable implements Callable<Integer> {
private static final String TAG = "MDUploadCallable";
// These preferences are obsolete and are kept only for removing from user preferences.
protected static final String PREF_DAY_UPLOAD_COUNT = "crash_day_dump_upload_count";
protected static final String PREF_LAST_UPLOAD_DAY = "crash_dump_last_upload_day";
protected static final String PREF_LAST_UPLOAD_WEEK = "crash_dump_last_upload_week";
protected static final String PREF_WEEK_UPLOAD_SIZE = "crash_dump_week_upload_size";
@VisibleForTesting
protected static final String CRASH_URL_STRING = "https://clients2.google.com/cr/report";
@VisibleForTesting
protected static final String CONTENT_TYPE_TMPL = "multipart/form-data; boundary=%s";
@IntDef({
UPLOAD_SUCCESS,
UPLOAD_FAILURE,
UPLOAD_USER_DISABLED,
UPLOAD_COMMANDLINE_DISABLED,
UPLOAD_DISABLED_BY_SAMPLING
})
public @interface MinidumpUploadStatus {}
public static final int UPLOAD_SUCCESS = 0;
public static final int UPLOAD_FAILURE = 1;
public static final int UPLOAD_USER_DISABLED = 2;
public static final int UPLOAD_COMMANDLINE_DISABLED = 3;
public static final int UPLOAD_DISABLED_BY_SAMPLING = 4;
private final File mFileToUpload;
private final File mLogfile;
private final HttpURLConnectionFactory mHttpURLConnectionFactory;
private final CrashReportingPermissionManager mPermManager;
public MinidumpUploadCallable(File fileToUpload, File logfile, Context context) {
this(fileToUpload, logfile, new HttpURLConnectionFactoryImpl(),
PrivacyPreferencesManager.getInstance());
removeOutdatedPrefs(ContextUtils.getAppSharedPreferences());
}
public MinidumpUploadCallable(File fileToUpload, File logfile,
HttpURLConnectionFactory httpURLConnectionFactory,
CrashReportingPermissionManager permManager) {
mFileToUpload = fileToUpload;
mLogfile = logfile;
mHttpURLConnectionFactory = httpURLConnectionFactory;
mPermManager = permManager;
}
@Override
public Integer call() {
// TODO(jchinlee): address proper cleanup procedures for command line flag-disabled uploads.
if (mPermManager.isUploadCommandLineDisabled()) {
Log.i(TAG, "Minidump upload is disabled by command line flag. Retaining file.");
return UPLOAD_COMMANDLINE_DISABLED;
}
if (mPermManager.isUploadEnabledForTests()) {
Log.i(TAG, "Minidump upload enabled for tests, skipping other checks.");
} else if (!CrashFileManager.isForcedUpload(mFileToUpload)) {
if (!mPermManager.isUploadUserPermitted()) {
Log.i(TAG, "Minidump upload is not permitted by user. Marking file as skipped for "
+ "cleanup to prevent future uploads.");
CrashFileManager.markUploadSkipped(mFileToUpload);
return UPLOAD_USER_DISABLED;
}
if (!mPermManager.isClientInMetricsSample()) {
Log.i(TAG, "Minidump upload skipped due to sampling. Marking file as skipped for "
+ "cleanup to prevent future uploads.");
CrashFileManager.markUploadSkipped(mFileToUpload);
return UPLOAD_DISABLED_BY_SAMPLING;
}
boolean isLimited = mPermManager.isUploadLimited();
if (isLimited || !mPermManager.isUploadPermitted()) {
Log.i(TAG, "Minidump cannot currently be uploaded due to constraints.");
return UPLOAD_FAILURE;
}
}
HttpURLConnection connection =
mHttpURLConnectionFactory.createHttpURLConnection(CRASH_URL_STRING);
if (connection == null) {
return UPLOAD_FAILURE;
}
FileInputStream minidumpInputStream = null;
try {
if (!configureConnectionForHttpPost(connection)) {
return UPLOAD_FAILURE;
}
minidumpInputStream = new FileInputStream(mFileToUpload);
streamCopy(minidumpInputStream, new GZIPOutputStream(connection.getOutputStream()));
boolean success = handleExecutionResponse(connection);
return success ? UPLOAD_SUCCESS : UPLOAD_FAILURE;
} catch (IOException e) {
// For now just log the stack trace.
Log.w(TAG, "Error while uploading " + mFileToUpload.getName(), e);
return UPLOAD_FAILURE;
} finally {
connection.disconnect();
if (minidumpInputStream != null) {
StreamUtil.closeQuietly(minidumpInputStream);
}
}
}
/**
* Configures a HttpURLConnection to send a HTTP POST request for uploading the minidump.
*
* This also reads the content-type from the minidump file.
*
* @param connection the HttpURLConnection to configure
* @return true if successful.
* @throws IOException
*/
private boolean configureConnectionForHttpPost(HttpURLConnection connection)
throws IOException {
// Read the boundary which we need for the content type.
String boundary = readBoundary();
if (boundary == null) {
return false;
}
connection.setDoOutput(true);
connection.setRequestProperty("Connection", "Keep-Alive");
connection.setRequestProperty("Content-Encoding", "gzip");
connection.setRequestProperty("Content-Type", String.format(CONTENT_TYPE_TMPL, boundary));
return true;
}
/**
* Reads the HTTP response and cleans up successful uploads.
*
* @param connection the connection to read the response from
* @return true if the upload was successful, false otherwise.
* @throws IOException
*/
private Boolean handleExecutionResponse(HttpURLConnection connection) throws IOException {
int responseCode = connection.getResponseCode();
if (isSuccessful(responseCode)) {
String responseContent = getResponseContentAsString(connection);
// The crash server returns the crash ID.
String id = responseContent != null ? responseContent : "unknown";
Log.i(TAG, "Minidump " + mFileToUpload.getName() + " uploaded successfully, id: " + id);
// TODO(acleung): MinidumpUploadService is in charge of renaming while this class is
// in charge of deleting. We should move all the file system operations into
// MinidumpUploadService instead.
CrashFileManager.markUploadSuccess(mFileToUpload);
try {
appendUploadedEntryToLog(id);
} catch (IOException ioe) {
Log.e(TAG, "Fail to write uploaded entry to log file");
}
return true;
} else {
// Log the results of the upload. Note that periodic upload failures aren't bad
// because we will need to throttle uploads in the future anyway.
String msg = String.format(Locale.US,
"Failed to upload %s with code: %d (%s).",
mFileToUpload.getName(), responseCode, connection.getResponseMessage());
Log.i(TAG, msg);
// TODO(acleung): The return status informs us about why an upload might be
// rejected. The next logical step is to put the reasons in an UMA histogram.
return false;
}
}
/**
* Records the upload entry to a log file
* similar to what is done in chrome/app/breakpad_linux.cc
*
* @param id The crash ID return from the server.
*/
private void appendUploadedEntryToLog(String id) throws IOException {
FileWriter writer = new FileWriter(mLogfile, /* Appending */ true);
// The log entries are formated like so:
// seconds_since_epoch,crash_id
StringBuilder sb = new StringBuilder();
sb.append(System.currentTimeMillis() / 1000);
sb.append(",");
sb.append(id);
sb.append('\n');
try {
// Since we are writing one line at a time, lets forget about BufferWriters.
writer.write(sb.toString());
} finally {
writer.close();
}
}
/**
* Get the boundary from the file, we need it for the content-type.
*
* @return the boundary if found, else null.
* @throws IOException
*/
private String readBoundary() throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(mFileToUpload));
String boundary = reader.readLine();
reader.close();
if (boundary == null || boundary.trim().isEmpty()) {
Log.e(TAG, "Ignoring invalid crash dump: '" + mFileToUpload + "'");
return null;
}
boundary = boundary.trim();
if (!boundary.startsWith("--") || boundary.length() < 10) {
Log.e(TAG, "Ignoring invalidly bound crash dump: '" + mFileToUpload + "'");
return null;
}
boundary = boundary.substring(2); // Remove the initial --
return boundary;
}
/**
* Returns whether the response code indicates a successful HTTP request.
*
* @param responseCode the response code
* @return true if response code indicates success, false otherwise.
*/
private static boolean isSuccessful(int responseCode) {
return responseCode == 200 || responseCode == 201 || responseCode == 202;
}
/**
* Reads the response from |connection| as a String.
*
* @param connection the connection to read the response from.
* @return the content of the response.
* @throws IOException
*/
private static String getResponseContentAsString(HttpURLConnection connection)
throws IOException {
String responseContent = null;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
streamCopy(connection.getInputStream(), baos);
if (baos.size() > 0) {
responseContent = baos.toString();
}
return responseContent;
}
/**
* Copies all available data from |inStream| to |outStream|. Closes both
* streams when done.
*
* @param inStream the stream to read
* @param outStream the stream to write to
* @throws IOException
*/
private static void streamCopy(InputStream inStream,
OutputStream outStream) throws IOException {
byte[] temp = new byte[4096];
int bytesRead = inStream.read(temp);
while (bytesRead >= 0) {
outStream.write(temp, 0, bytesRead);
bytesRead = inStream.read(temp);
}
inStream.close();
outStream.close();
}
// TODO(gayane): Remove this function and unused prefs in M51. crbug.com/555022
private void removeOutdatedPrefs(SharedPreferences sharedPreferences) {
SharedPreferences.Editor editor = sharedPreferences.edit();
editor.remove(PREF_DAY_UPLOAD_COUNT)
.remove(PREF_LAST_UPLOAD_DAY)
.remove(PREF_LAST_UPLOAD_WEEK)
.remove(PREF_WEEK_UPLOAD_SIZE)
.apply();
}
}