blob: 53d247bd5c4e1f441500b1cc51bd125f2ba29ab6 [file] [log] [blame]
// Copyright 2017 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.chromecast.shell;
import org.chromium.base.Log;
import org.chromium.components.minidump_uploader.CrashReportMimeWriter;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.SequenceInputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.UnsupportedCharsetException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Crash crashdump uploader. Scans the crash dump location provided by CastCrashReporterClient for
* dump files, attempting to upload all crash dumps to the crash server.
*
* <p>Uploading is intended to happen in a background thread, and this method will likely be called
* on startup, looking for crash dumps from previous runs, since Chromium's crash code explicitly
* blocks any post-dump hooks or uploading for Android builds.
*/
public final class CastCrashUploader {
private static final String TAG = "cr_CastCrashUploader";
private static final String CRASH_REPORT_HOST = "clients2.google.com";
private static final String CAST_SHELL_USER_AGENT = android.os.Build.MODEL + "/CastShell";
// Multipart dump filename has format "[random string].dmp[pid]", e.g.
// 20597a65-b822-008e-31f8fc8e-02bb45c0.dmp18169
private static final String DUMP_FILE_REGEX = ".*\\.dmp\\d*";
private final ScheduledExecutorService mExecutorService;
private final ElidedLogcatProvider mLogcatProvider;
private final String mCrashDumpPath;
private final String mCrashReportPath;
private final String mCrashReportUploadUrl;
private final String mUuid;
private final String mApplicationFeedback;
private final Runnable mQueueAllCrashDumpUploadsRunnable = () -> checkForCrashDumps();
public CastCrashUploader(ScheduledExecutorService executorService,
ElidedLogcatProvider logcatProvider, String crashDumpPath, String crashReportPath,
String uuid, String applicationFeedback, boolean uploadCrashToStaging) {
mExecutorService = executorService;
mLogcatProvider = logcatProvider;
mCrashDumpPath = crashDumpPath;
mCrashReportPath = crashReportPath;
mUuid = uuid;
mApplicationFeedback = applicationFeedback;
mCrashReportUploadUrl = uploadCrashToStaging
? "https://clients2.google.com/cr/staging_report"
: "https://clients2.google.com/cr/report";
}
@SuppressWarnings("FutureReturnValueIgnored")
public void uploadOnce() {
mExecutorService.schedule(mQueueAllCrashDumpUploadsRunnable, 0, TimeUnit.MINUTES);
}
public void removeCrashDumps() {
Log.i(TAG, "Remove crash dumps");
File crashReportDirectory = new File(mCrashReportPath);
for (File potentialDump : crashReportDirectory.listFiles()) {
if (potentialDump.getName().matches(DUMP_FILE_REGEX)) {
potentialDump.delete();
}
}
}
/**
* Searches for files matching the given regex in the crash dump folder, queueing each one for
* upload.
*
* @param synchronous Whether or not this function should block on queued uploads
* @param log Log to include, if any
*/
private void checkForCrashDumps() {
if (mCrashDumpPath == null) return;
Log.i(TAG, "Checking for crash dumps");
File crashDumpDirectory = new File(mCrashDumpPath);
File crashReportDirectory = new File(mCrashReportPath);
if (!crashDumpDirectory.isDirectory() || !crashReportDirectory.isDirectory()) {
return;
}
CrashReportMimeWriter.rewriteMinidumpsAsMIMEs(crashDumpDirectory, crashReportDirectory);
int numCrashDumps = crashReportDirectory.listFiles().length;
if (numCrashDumps > 0) {
Log.i(TAG, numCrashDumps + " crash dumps found");
mLogcatProvider.getElidedLogcat(
(String logs) -> queueAllCrashDumpUploadsWithLogs(crashReportDirectory, logs));
}
}
private void queueAllCrashDumpUploadsWithLogs(File crashDumpDirectory, String logs) {
for (final File potentialDump : crashDumpDirectory.listFiles()) {
String dumpName = potentialDump.getName();
if (dumpName.matches(DUMP_FILE_REGEX)) {
mExecutorService.submit(() -> uploadCrashDump(potentialDump, logs));
}
}
}
/** Enqueues a background task to upload a single crash dump file. */
private void uploadCrashDump(final File dumpFile, final String log) {
Log.i(TAG, "Uploading dump crash log: %s", dumpFile.getName());
try {
InputStream uploadCrashDumpStream = new FileInputStream(dumpFile);
// Dump file is already in multipart MIME format and has a boundary throughout.
// Scrape the first line, remove two dashes, call that the "boundary" and add it
// to the content-type.
FileInputStream dumpFileStream = new FileInputStream(dumpFile);
String dumpFirstLine = getFirstLine(dumpFileStream);
String mimeBoundary = dumpFirstLine.substring(2);
if (!log.equals("")) {
Log.i(TAG, "Including log file");
StringBuilder logHeader = new StringBuilder();
logHeader.append(dumpFirstLine);
logHeader.append("\n");
logHeader.append(
"Content-Disposition: form-data; name=\"log.txt\"; filename=\"log.txt\"\n");
logHeader.append("Content-Type: text/plain\n\n");
logHeader.append(log);
logHeader.append("\n");
InputStream logHeaderStream = new ByteArrayInputStream(
logHeader.toString().getBytes(Charset.forName("UTF-8")));
// Upload: prepend the log file for uploading
uploadCrashDumpStream =
new SequenceInputStream(logHeaderStream, uploadCrashDumpStream);
}
Log.d(TAG, "UUID: " + mUuid);
if (!mUuid.equals("")) {
StringBuilder uuidBuilder = new StringBuilder();
uuidBuilder.append(dumpFirstLine);
uuidBuilder.append("\n");
uuidBuilder.append("Content-Disposition: form-data; name=\"comments\"\n");
uuidBuilder.append("Content-Type: text/plain\n\n");
uuidBuilder.append(mUuid);
uuidBuilder.append("\n");
uploadCrashDumpStream = new SequenceInputStream(
new ByteArrayInputStream(
uuidBuilder.toString().getBytes(Charset.forName("UTF-8"))),
uploadCrashDumpStream);
} else {
Log.d(TAG, "No UUID");
}
if (!mApplicationFeedback.equals("")) {
Log.i(TAG, "Including feedback");
StringBuilder feedbackHeader = new StringBuilder();
feedbackHeader.append(dumpFirstLine);
feedbackHeader.append("\n");
feedbackHeader.append(
"Content-Disposition: form-data; name=\"application_feedback.txt\"; filename=\"application.txt\"\n");
feedbackHeader.append("Content-Type: text/plain\n\n");
feedbackHeader.append(mApplicationFeedback);
feedbackHeader.append("\n");
InputStream feedbackHeaderStream = new ByteArrayInputStream(
feedbackHeader.toString().getBytes(Charset.forName("UTF-8")));
// Upload: prepend the log file for uploading
uploadCrashDumpStream =
new SequenceInputStream(feedbackHeaderStream, uploadCrashDumpStream);
} else {
Log.d(TAG, "No Feedback");
}
HttpURLConnection connection =
(HttpURLConnection) new URL(mCrashReportUploadUrl).openConnection();
// Expect a report ID as the entire response
try {
connection.setDoOutput(true);
connection.setRequestProperty(
"Content-Type", "multipart/form-data; boundary=" + mimeBoundary);
streamCopy(uploadCrashDumpStream, connection.getOutputStream());
String responseLine = getFirstLine(connection.getInputStream());
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK
|| responseCode == HttpURLConnection.HTTP_CREATED
|| responseCode == HttpURLConnection.HTTP_ACCEPTED) {
Log.i(TAG, "Successfully uploaded to %s, report ID: %s", mCrashReportUploadUrl,
responseLine);
} else {
Log.e(TAG, "Failed response (%d): %s", responseCode,
connection.getResponseMessage());
// 400 Bad Request is returned if the dump file is malformed. If request
// is not malformed, short-circuit before cleanup to avoid deletion and
// retry later, otherwise pass through and delete malformed file.
if (responseCode != HttpURLConnection.HTTP_BAD_REQUEST) {
return;
}
}
} catch (FileNotFoundException fnfe) {
// Android's HttpURLConnection implementation fires FNFE on some errors.
Log.e(TAG, "Failed response: " + connection.getResponseCode(), fnfe);
} catch (UnsupportedCharsetException e) {
Log.wtf(TAG, "UTF-8 not supported");
} finally {
connection.disconnect();
dumpFileStream.close();
}
// Delete the file so we don't re-upload it next time.
dumpFile.delete();
} catch (IOException e) {
Log.e(TAG, "Error occurred trying to upload crash dump", e);
}
}
/**
* 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();
}
/**
* Gets the first line from an input stream
*
* @return First line of the input stream.
* @throws IOException
*/
private String getFirstLine(InputStream inputStream) throws IOException {
try (InputStreamReader streamReader = new InputStreamReader(inputStream, "UTF-8");
BufferedReader reader = new BufferedReader(streamReader)) {
return reader.readLine();
} catch (UnsupportedCharsetException e) {
Log.wtf(TAG, "UTF-8 not supported");
return "";
}
}
/**
* Waits until Future is propagated
*
* @return Whether thread should continue waiting
*/
private boolean waitOnTask(Future task) {
try {
task.get();
return true;
} catch (InterruptedException e) {
// Was interrupted while waiting, tell caller to cancel waiting
return false;
} catch (ExecutionException e) {
// Task execution may have failed, but this is fine as long as it finished
// executing
return true;
}
}
}