1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4 
5 package org.mozilla.geckoview;
6 
7 import android.content.Context;
8 import android.content.Intent;
9 import android.os.Build;
10 import android.os.Bundle;
11 import android.util.Log;
12 import androidx.annotation.AnyThread;
13 import androidx.annotation.NonNull;
14 import java.io.BufferedReader;
15 import java.io.ByteArrayOutputStream;
16 import java.io.File;
17 import java.io.FileInputStream;
18 import java.io.IOException;
19 import java.io.InputStreamReader;
20 import java.io.OutputStream;
21 import java.net.HttpURLConnection;
22 import java.net.URI;
23 import java.net.URISyntaxException;
24 import java.net.URL;
25 import java.net.URLDecoder;
26 import java.nio.channels.Channels;
27 import java.nio.channels.FileChannel;
28 import java.security.MessageDigest;
29 import java.security.NoSuchAlgorithmException;
30 import java.util.Arrays;
31 import java.util.HashMap;
32 import java.util.List;
33 import java.util.zip.GZIPOutputStream;
34 import org.json.JSONException;
35 import org.json.JSONObject;
36 import org.mozilla.gecko.util.ProxySelector;
37 
38 /**
39  * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a> crash
40  * report server.
41  */
42 public class CrashReporter {
43   private static final String LOGTAG = "GeckoCrashReporter";
44   private static final String MINI_DUMP_PATH_KEY = "upload_file_minidump";
45   private static final String PAGE_URL_KEY = "URL";
46   private static final String MINIDUMP_SHA256_HASH_KEY = "MinidumpSha256Hash";
47   private static final String NOTES_KEY = "Notes";
48   private static final String SERVER_URL_KEY = "ServerURL";
49   private static final String STACK_TRACES_KEY = "StackTraces";
50   private static final String PRODUCT_NAME_KEY = "ProductName";
51   private static final String PRODUCT_ID_KEY = "ProductID";
52   private static final String PRODUCT_ID = "{eeb82917-e434-4870-8148-5c03d4caa81b}";
53   private static final List<String> IGNORE_KEYS =
54       Arrays.asList(PAGE_URL_KEY, SERVER_URL_KEY, STACK_TRACES_KEY);
55 
56   /**
57    * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
58    * crash report server. <br>
59    * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
60    * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
61    * like to get your app added to the whitelist.
62    *
63    * @param context The current Context
64    * @param intent The Intent sent to the {@link GeckoRuntime} crash handler
65    * @param appName A human-readable app name.
66    * @throws IOException This can be thrown if there was a networking error while sending the
67    *     report.
68    * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
69    *     invalid.
70    * @return A GeckoResult containing the crash ID as a String.
71    * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
72    * @see GeckoRuntime#ACTION_CRASHED
73    */
74   @AnyThread
sendCrashReport( @onNull final Context context, @NonNull final Intent intent, @NonNull final String appName)75   public static @NonNull GeckoResult<String> sendCrashReport(
76       @NonNull final Context context, @NonNull final Intent intent, @NonNull final String appName)
77       throws IOException, URISyntaxException {
78     return sendCrashReport(context, intent.getExtras(), appName);
79   }
80 
81   /**
82    * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
83    * crash report server. <br>
84    * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
85    * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
86    * like to get your app added to the whitelist.
87    *
88    * @param context The current Context
89    * @param intentExtras The Bundle of extras attached to the Intent received by a crash handler.
90    * @param appName A human-readable app name.
91    * @throws IOException This can be thrown if there was a networking error while sending the
92    *     report.
93    * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
94    *     invalid.
95    * @return A GeckoResult containing the crash ID as a String.
96    * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
97    * @see GeckoRuntime#ACTION_CRASHED
98    */
99   @AnyThread
sendCrashReport( @onNull final Context context, @NonNull final Bundle intentExtras, @NonNull final String appName)100   public static @NonNull GeckoResult<String> sendCrashReport(
101       @NonNull final Context context,
102       @NonNull final Bundle intentExtras,
103       @NonNull final String appName)
104       throws IOException, URISyntaxException {
105     final File dumpFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_MINIDUMP_PATH));
106     final File extrasFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_EXTRAS_PATH));
107 
108     return sendCrashReport(context, dumpFile, extrasFile, appName);
109   }
110 
111   /**
112    * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
113    * crash report server. <br>
114    * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
115    * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
116    * like to get your app added to the whitelist.
117    *
118    * @param context The current {@link Context}
119    * @param minidumpFile A {@link File} referring to the minidump.
120    * @param extrasFile A {@link File} referring to the extras file.
121    * @param appName A human-readable app name.
122    * @throws IOException This can be thrown if there was a networking error while sending the
123    *     report.
124    * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
125    *     invalid.
126    * @return A GeckoResult containing the crash ID as a String.
127    * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
128    * @see GeckoRuntime#ACTION_CRASHED
129    */
130   @AnyThread
sendCrashReport( @onNull final Context context, @NonNull final File minidumpFile, @NonNull final File extrasFile, @NonNull final String appName)131   public static @NonNull GeckoResult<String> sendCrashReport(
132       @NonNull final Context context,
133       @NonNull final File minidumpFile,
134       @NonNull final File extrasFile,
135       @NonNull final String appName)
136       throws IOException, URISyntaxException {
137     final JSONObject annotations = getCrashAnnotations(context, minidumpFile, extrasFile, appName);
138 
139     final String url = annotations.optString(SERVER_URL_KEY, null);
140     if (url == null) {
141       return GeckoResult.fromException(new Exception("No server url present"));
142     }
143 
144     for (final String key : IGNORE_KEYS) {
145       annotations.remove(key);
146     }
147 
148     return sendCrashReport(url, minidumpFile, annotations);
149   }
150 
151   /**
152    * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
153    * crash report server.
154    *
155    * @param serverURL The URL used to submit the crash report.
156    * @param minidumpFile A {@link File} referring to the minidump.
157    * @param extras A {@link JSONObject} holding the parsed JSON from the extra file.
158    * @throws IOException This can be thrown if there was a networking error while sending the
159    *     report.
160    * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
161    *     invalid.
162    * @return A GeckoResult containing the crash ID as a String.
163    * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
164    * @see GeckoRuntime#ACTION_CRASHED
165    */
166   @AnyThread
sendCrashReport( @onNull final String serverURL, @NonNull final File minidumpFile, @NonNull final JSONObject extras)167   public static @NonNull GeckoResult<String> sendCrashReport(
168       @NonNull final String serverURL,
169       @NonNull final File minidumpFile,
170       @NonNull final JSONObject extras)
171       throws IOException, URISyntaxException {
172     Log.d(LOGTAG, "Sending crash report: " + minidumpFile.getPath());
173 
174     HttpURLConnection conn = null;
175     try {
176       final URL url = new URL(URLDecoder.decode(serverURL, "UTF-8"));
177       final URI uri =
178           new URI(
179               url.getProtocol(),
180               url.getUserInfo(),
181               url.getHost(),
182               url.getPort(),
183               url.getPath(),
184               url.getQuery(),
185               url.getRef());
186       conn = (HttpURLConnection) ProxySelector.openConnectionWithProxy(uri);
187       conn.setRequestMethod("POST");
188       final String boundary = generateBoundary();
189       conn.setDoOutput(true);
190       conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
191       conn.setRequestProperty("Content-Encoding", "gzip");
192 
193       final OutputStream os = new GZIPOutputStream(conn.getOutputStream());
194       sendAnnotations(os, boundary, extras);
195       sendFile(os, boundary, MINI_DUMP_PATH_KEY, minidumpFile);
196       os.write(("\r\n--" + boundary + "--\r\n").getBytes());
197       os.flush();
198       os.close();
199 
200       BufferedReader br = null;
201       try {
202         br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
203         final HashMap<String, String> responseMap = readStringsFromReader(br);
204 
205         if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
206           final String crashid = responseMap.get("CrashID");
207           if (crashid != null) {
208             Log.i(LOGTAG, "Successfully sent crash report: " + crashid);
209             return GeckoResult.fromValue(crashid);
210           } else {
211             Log.i(LOGTAG, "Server rejected crash report");
212           }
213         } else {
214           Log.w(
215               LOGTAG, "Received failure HTTP response code from server: " + conn.getResponseCode());
216         }
217       } catch (final Exception e) {
218         return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
219       } finally {
220         try {
221           if (br != null) {
222             br.close();
223           }
224         } catch (final IOException e) {
225           return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
226         }
227       }
228     } catch (final Exception e) {
229       return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
230     } finally {
231       if (conn != null) {
232         conn.disconnect();
233       }
234     }
235     return GeckoResult.fromException(new Exception("Failed to submit crash report"));
236   }
237 
computeMinidumpHash(@onNull final File minidump)238   private static String computeMinidumpHash(@NonNull final File minidump) throws IOException {
239     MessageDigest md = null;
240     final FileInputStream stream = new FileInputStream(minidump);
241     try {
242       md = MessageDigest.getInstance("SHA-256");
243 
244       final byte[] buffer = new byte[4096];
245       int readBytes;
246 
247       while ((readBytes = stream.read(buffer)) != -1) {
248         md.update(buffer, 0, readBytes);
249       }
250     } catch (final NoSuchAlgorithmException e) {
251       throw new IOException(e);
252     } finally {
253       stream.close();
254     }
255 
256     final byte[] digest = md.digest();
257     final StringBuilder hash = new StringBuilder(64);
258 
259     for (int i = 0; i < digest.length; i++) {
260       hash.append(Integer.toHexString((digest[i] & 0xf0) >> 4));
261       hash.append(Integer.toHexString(digest[i] & 0x0f));
262     }
263 
264     return hash.toString();
265   }
266 
readStringsFromReader(final BufferedReader reader)267   private static HashMap<String, String> readStringsFromReader(final BufferedReader reader)
268       throws IOException {
269     String line;
270     final HashMap<String, String> map = new HashMap<>();
271     while ((line = reader.readLine()) != null) {
272       int equalsPos = -1;
273       if ((equalsPos = line.indexOf('=')) != -1) {
274         final String key = line.substring(0, equalsPos);
275         final String val = unescape(line.substring(equalsPos + 1));
276         map.put(key, val);
277       }
278     }
279     return map;
280   }
281 
readExtraFile(final String filePath)282   private static JSONObject readExtraFile(final String filePath) throws IOException, JSONException {
283     final byte[] buffer = new byte[4096];
284     final FileInputStream inputStream = new FileInputStream(filePath);
285     final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
286     int bytesRead = 0;
287 
288     while ((bytesRead = inputStream.read(buffer)) != -1) {
289       outputStream.write(buffer, 0, bytesRead);
290     }
291 
292     final String contents = new String(outputStream.toByteArray(), "UTF-8");
293     return new JSONObject(contents);
294   }
295 
getCrashAnnotations( @onNull final Context context, @NonNull final File minidump, @NonNull final File extra, @NonNull final String appName)296   private static JSONObject getCrashAnnotations(
297       @NonNull final Context context,
298       @NonNull final File minidump,
299       @NonNull final File extra,
300       @NonNull final String appName)
301       throws IOException {
302     try {
303       final JSONObject annotations = readExtraFile(extra.getPath());
304 
305       // Compute the minidump hash and generate the stack traces
306       try {
307         final String hash = computeMinidumpHash(minidump);
308         annotations.put(MINIDUMP_SHA256_HASH_KEY, hash);
309       } catch (final Exception e) {
310         Log.e(LOGTAG, "exception while computing the minidump hash: ", e);
311       }
312 
313       annotations.put(PRODUCT_NAME_KEY, appName);
314       annotations.put(PRODUCT_ID_KEY, PRODUCT_ID);
315       annotations.put("Android_Manufacturer", Build.MANUFACTURER);
316       annotations.put("Android_Model", Build.MODEL);
317       annotations.put("Android_Board", Build.BOARD);
318       annotations.put("Android_Brand", Build.BRAND);
319       annotations.put("Android_Device", Build.DEVICE);
320       annotations.put("Android_Display", Build.DISPLAY);
321       annotations.put("Android_Fingerprint", Build.FINGERPRINT);
322       annotations.put("Android_CPU_ABI", Build.CPU_ABI);
323       annotations.put("Android_PackageName", context.getPackageName());
324       try {
325         annotations.put("Android_CPU_ABI2", Build.CPU_ABI2);
326         annotations.put("Android_Hardware", Build.HARDWARE);
327       } catch (final Exception ex) {
328         Log.e(LOGTAG, "Exception while sending SDK version 8 keys", ex);
329       }
330       annotations.put(
331           "Android_Version", Build.VERSION.SDK_INT + " (" + Build.VERSION.CODENAME + ")");
332 
333       return annotations;
334     } catch (final JSONException e) {
335       throw new IOException(e);
336     }
337   }
338 
generateBoundary()339   private static String generateBoundary() {
340     // Generate some random numbers to fill out the boundary
341     final int r0 = (int) (Integer.MAX_VALUE * Math.random());
342     final int r1 = (int) (Integer.MAX_VALUE * Math.random());
343     return String.format("---------------------------%08X%08X", r0, r1);
344   }
345 
sendAnnotations( final OutputStream os, final String boundary, final JSONObject extras)346   private static void sendAnnotations(
347       final OutputStream os, final String boundary, final JSONObject extras) throws IOException {
348     os.write(
349         ("--"
350                 + boundary
351                 + "\r\n"
352                 + "Content-Disposition: form-data; name=\"extra\"; "
353                 + "filename=\"extra.json\"\r\n"
354                 + "Content-Type: application/json\r\n"
355                 + "\r\n")
356             .getBytes());
357     os.write(extras.toString().getBytes("UTF-8"));
358     os.write('\n');
359   }
360 
sendFile( final OutputStream os, final String boundary, final String name, final File file)361   private static void sendFile(
362       final OutputStream os, final String boundary, final String name, final File file)
363       throws IOException {
364     os.write(
365         ("--"
366                 + boundary
367                 + "\r\n"
368                 + "Content-Disposition: form-data; name=\""
369                 + name
370                 + "\"; "
371                 + "filename=\""
372                 + file.getName()
373                 + "\"\r\n"
374                 + "Content-Type: application/octet-stream\r\n"
375                 + "\r\n")
376             .getBytes());
377     final FileChannel fc = new FileInputStream(file).getChannel();
378     fc.transferTo(0, fc.size(), Channels.newChannel(os));
379     fc.close();
380   }
381 
unescape(final String string)382   private static String unescape(final String string) {
383     return string.replaceAll("\\\\\\\\", "\\").replaceAll("\\\\n", "\n").replaceAll("\\\\t", "\t");
384   }
385 }
386