/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.geckoview;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.zip.GZIPOutputStream;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.util.ProxySelector;
/**
* Sends a crash report to the Mozilla Socorro crash
* report server.
*/
public class CrashReporter {
private static final String LOGTAG = "GeckoCrashReporter";
private static final String MINI_DUMP_PATH_KEY = "upload_file_minidump";
private static final String PAGE_URL_KEY = "URL";
private static final String MINIDUMP_SHA256_HASH_KEY = "MinidumpSha256Hash";
private static final String NOTES_KEY = "Notes";
private static final String SERVER_URL_KEY = "ServerURL";
private static final String STACK_TRACES_KEY = "StackTraces";
private static final String PRODUCT_NAME_KEY = "ProductName";
private static final String PRODUCT_ID_KEY = "ProductID";
private static final String PRODUCT_ID = "{eeb82917-e434-4870-8148-5c03d4caa81b}";
private static final List IGNORE_KEYS =
Arrays.asList(PAGE_URL_KEY, SERVER_URL_KEY, STACK_TRACES_KEY);
/**
* Sends a crash report to the Mozilla Socorro
* crash report server.
* The {@code appName} needs to be whitelisted for the server to accept the crash. File a bug if you would
* like to get your app added to the whitelist.
*
* @param context The current Context
* @param intent The Intent sent to the {@link GeckoRuntime} crash handler
* @param appName A human-readable app name.
* @throws IOException This can be thrown if there was a networking error while sending the
* report.
* @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
* invalid.
* @return A GeckoResult containing the crash ID as a String.
* @see GeckoRuntimeSettings.Builder#crashHandler(Class)
* @see GeckoRuntime#ACTION_CRASHED
*/
@AnyThread
public static @NonNull GeckoResult sendCrashReport(
@NonNull final Context context, @NonNull final Intent intent, @NonNull final String appName)
throws IOException, URISyntaxException {
return sendCrashReport(context, intent.getExtras(), appName);
}
/**
* Sends a crash report to the Mozilla Socorro
* crash report server.
* The {@code appName} needs to be whitelisted for the server to accept the crash. File a bug if you would
* like to get your app added to the whitelist.
*
* @param context The current Context
* @param intentExtras The Bundle of extras attached to the Intent received by a crash handler.
* @param appName A human-readable app name.
* @throws IOException This can be thrown if there was a networking error while sending the
* report.
* @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
* invalid.
* @return A GeckoResult containing the crash ID as a String.
* @see GeckoRuntimeSettings.Builder#crashHandler(Class)
* @see GeckoRuntime#ACTION_CRASHED
*/
@AnyThread
public static @NonNull GeckoResult sendCrashReport(
@NonNull final Context context,
@NonNull final Bundle intentExtras,
@NonNull final String appName)
throws IOException, URISyntaxException {
final File dumpFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_MINIDUMP_PATH));
final File extrasFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_EXTRAS_PATH));
return sendCrashReport(context, dumpFile, extrasFile, appName);
}
/**
* Sends a crash report to the Mozilla Socorro
* crash report server.
* The {@code appName} needs to be whitelisted for the server to accept the crash. File a bug if you would
* like to get your app added to the whitelist.
*
* @param context The current {@link Context}
* @param minidumpFile A {@link File} referring to the minidump.
* @param extrasFile A {@link File} referring to the extras file.
* @param appName A human-readable app name.
* @throws IOException This can be thrown if there was a networking error while sending the
* report.
* @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
* invalid.
* @return A GeckoResult containing the crash ID as a String.
* @see GeckoRuntimeSettings.Builder#crashHandler(Class)
* @see GeckoRuntime#ACTION_CRASHED
*/
@AnyThread
public static @NonNull GeckoResult sendCrashReport(
@NonNull final Context context,
@NonNull final File minidumpFile,
@NonNull final File extrasFile,
@NonNull final String appName)
throws IOException, URISyntaxException {
final JSONObject annotations = getCrashAnnotations(context, minidumpFile, extrasFile, appName);
final String url = annotations.optString(SERVER_URL_KEY, null);
if (url == null) {
return GeckoResult.fromException(new Exception("No server url present"));
}
for (final String key : IGNORE_KEYS) {
annotations.remove(key);
}
return sendCrashReport(url, minidumpFile, annotations);
}
/**
* Sends a crash report to the Mozilla Socorro
* crash report server.
*
* @param serverURL The URL used to submit the crash report.
* @param minidumpFile A {@link File} referring to the minidump.
* @param extras A {@link JSONObject} holding the parsed JSON from the extra file.
* @throws IOException This can be thrown if there was a networking error while sending the
* report.
* @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
* invalid.
* @return A GeckoResult containing the crash ID as a String.
* @see GeckoRuntimeSettings.Builder#crashHandler(Class)
* @see GeckoRuntime#ACTION_CRASHED
*/
@AnyThread
public static @NonNull GeckoResult sendCrashReport(
@NonNull final String serverURL,
@NonNull final File minidumpFile,
@NonNull final JSONObject extras)
throws IOException, URISyntaxException {
Log.d(LOGTAG, "Sending crash report: " + minidumpFile.getPath());
HttpURLConnection conn = null;
try {
final URL url = new URL(URLDecoder.decode(serverURL, "UTF-8"));
final URI uri =
new URI(
url.getProtocol(),
url.getUserInfo(),
url.getHost(),
url.getPort(),
url.getPath(),
url.getQuery(),
url.getRef());
conn = (HttpURLConnection) ProxySelector.openConnectionWithProxy(uri);
conn.setRequestMethod("POST");
final String boundary = generateBoundary();
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
conn.setRequestProperty("Content-Encoding", "gzip");
final OutputStream os = new GZIPOutputStream(conn.getOutputStream());
sendAnnotations(os, boundary, extras);
sendFile(os, boundary, MINI_DUMP_PATH_KEY, minidumpFile);
os.write(("\r\n--" + boundary + "--\r\n").getBytes());
os.flush();
os.close();
BufferedReader br = null;
try {
br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
final HashMap responseMap = readStringsFromReader(br);
if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
final String crashid = responseMap.get("CrashID");
if (crashid != null) {
Log.i(LOGTAG, "Successfully sent crash report: " + crashid);
return GeckoResult.fromValue(crashid);
} else {
Log.i(LOGTAG, "Server rejected crash report");
}
} else {
Log.w(
LOGTAG, "Received failure HTTP response code from server: " + conn.getResponseCode());
}
} catch (final Exception e) {
return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
} finally {
try {
if (br != null) {
br.close();
}
} catch (final IOException e) {
return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
}
}
} catch (final Exception e) {
return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
} finally {
if (conn != null) {
conn.disconnect();
}
}
return GeckoResult.fromException(new Exception("Failed to submit crash report"));
}
private static String computeMinidumpHash(@NonNull final File minidump) throws IOException {
MessageDigest md = null;
final FileInputStream stream = new FileInputStream(minidump);
try {
md = MessageDigest.getInstance("SHA-256");
final byte[] buffer = new byte[4096];
int readBytes;
while ((readBytes = stream.read(buffer)) != -1) {
md.update(buffer, 0, readBytes);
}
} catch (final NoSuchAlgorithmException e) {
throw new IOException(e);
} finally {
stream.close();
}
final byte[] digest = md.digest();
final StringBuilder hash = new StringBuilder(64);
for (int i = 0; i < digest.length; i++) {
hash.append(Integer.toHexString((digest[i] & 0xf0) >> 4));
hash.append(Integer.toHexString(digest[i] & 0x0f));
}
return hash.toString();
}
private static HashMap readStringsFromReader(final BufferedReader reader)
throws IOException {
String line;
final HashMap map = new HashMap<>();
while ((line = reader.readLine()) != null) {
int equalsPos = -1;
if ((equalsPos = line.indexOf('=')) != -1) {
final String key = line.substring(0, equalsPos);
final String val = unescape(line.substring(equalsPos + 1));
map.put(key, val);
}
}
return map;
}
private static JSONObject readExtraFile(final String filePath) throws IOException, JSONException {
final byte[] buffer = new byte[4096];
final FileInputStream inputStream = new FileInputStream(filePath);
final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
int bytesRead = 0;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
final String contents = new String(outputStream.toByteArray(), "UTF-8");
return new JSONObject(contents);
}
private static JSONObject getCrashAnnotations(
@NonNull final Context context,
@NonNull final File minidump,
@NonNull final File extra,
@NonNull final String appName)
throws IOException {
try {
final JSONObject annotations = readExtraFile(extra.getPath());
// Compute the minidump hash and generate the stack traces
try {
final String hash = computeMinidumpHash(minidump);
annotations.put(MINIDUMP_SHA256_HASH_KEY, hash);
} catch (final Exception e) {
Log.e(LOGTAG, "exception while computing the minidump hash: ", e);
}
annotations.put(PRODUCT_NAME_KEY, appName);
annotations.put(PRODUCT_ID_KEY, PRODUCT_ID);
annotations.put("Android_Manufacturer", Build.MANUFACTURER);
annotations.put("Android_Model", Build.MODEL);
annotations.put("Android_Board", Build.BOARD);
annotations.put("Android_Brand", Build.BRAND);
annotations.put("Android_Device", Build.DEVICE);
annotations.put("Android_Display", Build.DISPLAY);
annotations.put("Android_Fingerprint", Build.FINGERPRINT);
annotations.put("Android_CPU_ABI", Build.CPU_ABI);
annotations.put("Android_PackageName", context.getPackageName());
try {
annotations.put("Android_CPU_ABI2", Build.CPU_ABI2);
annotations.put("Android_Hardware", Build.HARDWARE);
} catch (final Exception ex) {
Log.e(LOGTAG, "Exception while sending SDK version 8 keys", ex);
}
annotations.put(
"Android_Version", Build.VERSION.SDK_INT + " (" + Build.VERSION.CODENAME + ")");
return annotations;
} catch (final JSONException e) {
throw new IOException(e);
}
}
private static String generateBoundary() {
// Generate some random numbers to fill out the boundary
final int r0 = (int) (Integer.MAX_VALUE * Math.random());
final int r1 = (int) (Integer.MAX_VALUE * Math.random());
return String.format("---------------------------%08X%08X", r0, r1);
}
private static void sendAnnotations(
final OutputStream os, final String boundary, final JSONObject extras) throws IOException {
os.write(
("--"
+ boundary
+ "\r\n"
+ "Content-Disposition: form-data; name=\"extra\"; "
+ "filename=\"extra.json\"\r\n"
+ "Content-Type: application/json\r\n"
+ "\r\n")
.getBytes());
os.write(extras.toString().getBytes("UTF-8"));
os.write('\n');
}
private static void sendFile(
final OutputStream os, final String boundary, final String name, final File file)
throws IOException {
os.write(
("--"
+ boundary
+ "\r\n"
+ "Content-Disposition: form-data; name=\""
+ name
+ "\"; "
+ "filename=\""
+ file.getName()
+ "\"\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "\r\n")
.getBytes());
final FileChannel fc = new FileInputStream(file).getChannel();
fc.transferTo(0, fc.size(), Channels.newChannel(os));
fc.close();
}
private static String unescape(final String string) {
return string.replaceAll("\\\\\\\\", "\\").replaceAll("\\\\n", "\n").replaceAll("\\\\t", "\t");
}
}