diff options
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java')
-rw-r--r-- | mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java | 385 |
1 files changed, 385 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java new file mode 100644 index 0000000000..691686e230 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java @@ -0,0 +1,385 @@ +/* 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 <a href="https://wiki.mozilla.org/Socorro">Socorro</a> 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<String> IGNORE_KEYS = + Arrays.asList(PAGE_URL_KEY, SERVER_URL_KEY, STACK_TRACES_KEY); + + /** + * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a> + * crash report server. <br> + * The {@code appName} needs to be whitelisted for the server to accept the crash. <a + * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> 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<String> 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 <a href="https://wiki.mozilla.org/Socorro">Socorro</a> + * crash report server. <br> + * The {@code appName} needs to be whitelisted for the server to accept the crash. <a + * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> 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<String> 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 <a href="https://wiki.mozilla.org/Socorro">Socorro</a> + * crash report server. <br> + * The {@code appName} needs to be whitelisted for the server to accept the crash. <a + * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> 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<String> 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 <a href="https://wiki.mozilla.org/Socorro">Socorro</a> + * 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<String> 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<String, String> 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<String, String> readStringsFromReader(final BufferedReader reader) + throws IOException { + String line; + final HashMap<String, String> 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"); + } +} |