diff --git a/README.md b/README.md index 7e4a1dc..f728b5b 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,77 @@ When integrating BugSplat into your Android application, it's crucial to ensure These configurations ensure that the BugSplat native libraries are properly included in your app and can function correctly to capture and report native crashes. +## Handled Exceptions ๐Ÿชค + +Not every problem crashes the process. Use `BugSplat.postException` to report a caught `Throwable` as a non-fatal report. Non-fatals appear alongside crashes with the **"Android.Java"** type, so you can filter them separately on the dashboard. + +Reports carry the same metadata as a crash: the attributes and attachments passed to `init`, plus anything set with `BugSplat.setAttribute`. `BugSplat.init` must have been called first โ€” a post before init is logged and dropped. + +### Reporting a caught exception + +**Java** +```java +try { + riskyOperation(); +} catch (Exception e) { + // Uploads on a background thread and returns immediately + BugSplat.postException(e); +} +``` + +**Kotlin** +```kotlin +try { + riskyOperation() +} catch (e: Exception) { + BugSplat.postException(e) +} +``` + +### Per-report attributes + +Attributes passed to `postException` are layered over the attributes already registered with the SDK, so a per-report value wins over one with the same key from `init` or `setAttribute`: + +```java +Map attributes = new HashMap<>(); +attributes.put("screen", "checkout"); +attributes.put("retryCount", "2"); + +BugSplat.postException(e, attributes); +``` + +### Blocking submission + +Use `postExceptionBlocking` when you need the result โ€” for example in a background worker that should not exit until the report is uploaded. It returns `false` if the upload failed, the post was rate limited, or the SDK was not initialized. **Do not call it on the main thread.** + +```java +boolean reported = BugSplat.postExceptionBlocking(e); +``` + +### Rate limiting + +A caught exception inside a render or game loop can fire every frame, so the SDK drops posts made within **3000ms** of the previous accepted one. Adjust the window, or disable the guard entirely with a value of zero or less: + +```java +BugSplat.setExceptionPostIntervalMillis(10_000); // at most one report per 10s +BugSplat.setExceptionPostIntervalMillis(0); // report every exception +``` + +### Reporting uncaught exceptions + +Crashpad only catches native signals, so a Java exception that reaches the top of the stack is not reported automatically. To capture those, install a default handler that posts the exception before delegating to the previous one: + +```java +Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); +Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + // The process is about to die, so block until the report is uploaded + BugSplat.postExceptionBlocking(throwable); + if (previous != null) { + previous.uncaughtException(thread, throwable); + } +}); +``` + ## ANR Detection ๐ŸŒ The BugSplat Android SDK automatically detects and reports Application Not Responding (ANR) events on Android 11+ (API level 30+) using the [`ApplicationExitInfo`](https://developer.android.com/reference/android/app/ApplicationExitInfo) API. diff --git a/app/src/main/java/com/bugsplat/android/BugSplat.java b/app/src/main/java/com/bugsplat/android/BugSplat.java index 945cdb9..2f42cd6 100644 --- a/app/src/main/java/com/bugsplat/android/BugSplat.java +++ b/app/src/main/java/com/bugsplat/android/BugSplat.java @@ -2,6 +2,7 @@ import android.app.Activity; import android.content.Context; +import android.util.Log; import java.io.File; import java.util.List; import java.util.Map; @@ -109,6 +110,96 @@ public static void removeAttribute(String key) { BugSplatBridge.removeAttribute(key); } + /** + * Report a caught {@link Throwable} to BugSplat as a non-fatal error. + * This runs on a background thread and returns immediately. + * + *

The report carries the attributes and attachments supplied to + * {@code init}, plus any set with {@link #setAttribute(String, String)}, and + * appears on the dashboard with the {@code Android.Java} crash type so + * non-fatals can be filtered separately from crashes.

+ * + *

Posts closer together than the exception post interval are dropped โ€” + * see {@link #setExceptionPostIntervalMillis(long)}. Requires {@code init} + * to have been called; a post before init is logged and ignored.

+ * + * @param throwable The caught exception to report + */ + public static void postException(Throwable throwable) { + postException(throwable, null); + } + + /** + * Report a caught {@link Throwable} to BugSplat with additional attributes. + * This runs on a background thread and returns immediately. + * + * @param throwable The caught exception to report + * @param attributes Extra key/value attributes for this report, layered over + * the attributes already registered with the SDK, or null + */ + public static void postException(Throwable throwable, Map attributes) { + new Thread(() -> postExceptionInternal(throwable, attributes)).start(); + } + + /** + * Report a caught {@link Throwable} to BugSplat. + * This blocks until the upload is complete. + * + * @param throwable The caught exception to report + * @return true if the report was uploaded; false if it failed, was rate + * limited, or the SDK was not initialized + */ + public static boolean postExceptionBlocking(Throwable throwable) { + return postExceptionBlocking(throwable, null); + } + + /** + * Report a caught {@link Throwable} to BugSplat with additional attributes. + * This blocks until the upload is complete. + * + * @param throwable The caught exception to report + * @param attributes Extra key/value attributes for this report, layered over + * the attributes already registered with the SDK, or null + * @return true if the report was uploaded; false if it failed, was rate + * limited, or the SDK was not initialized + */ + public static boolean postExceptionBlocking(Throwable throwable, Map attributes) { + return postExceptionInternal(throwable, attributes); + } + + /** + * Set the minimum time between two accepted {@code postException} calls. + * + *

A caught exception inside a render or game loop can fire every frame, + * so posts made within this window of the previous one are dropped. Defaults + * to 3000ms. Pass zero or less to disable the guard and post every + * exception.

+ * + * @param millis The minimum interval in milliseconds + */ + public static void setExceptionPostIntervalMillis(long millis) { + ExceptionReporter.setMinPostIntervalMillis(millis); + } + + private static boolean postExceptionInternal(Throwable throwable, Map attributes) { + if (throwable == null) { + Log.e("BugSplat", "postException called with a null throwable"); + return false; + } + if (!BugSplatConfig.isInitialized()) { + Log.e("BugSplat", "postException called before init; report dropped"); + return false; + } + if (!ExceptionReporter.shouldPost(System.currentTimeMillis())) { + Log.w("BugSplat", "postException rate limited; report dropped"); + return false; + } + + ExceptionReporter reporter = new ExceptionReporter( + BugSplatConfig.database(), BugSplatConfig.application(), BugSplatConfig.version()); + return reporter.post(throwable, attributes, BugSplatConfig.attachmentFiles()); + } + /** * Upload debug symbols for native libraries (.so files) in the specified directory. * This method runs asynchronously and returns immediately. diff --git a/app/src/main/java/com/bugsplat/android/BugSplatBridge.java b/app/src/main/java/com/bugsplat/android/BugSplatBridge.java index a0f96d7..bb7a8bb 100644 --- a/app/src/main/java/com/bugsplat/android/BugSplatBridge.java +++ b/app/src/main/java/com/bugsplat/android/BugSplatBridge.java @@ -31,6 +31,10 @@ public static void initBugSplat(Activity activity, String database, String appli public static void initBugSplat(Activity activity, String database, String application, String version, Map attributes, String[] attachments) { + // Mirror the init parameters on the Java side โ€” Crashpad owns this state + // natively, and postException needs to read it back to build a report. + BugSplatConfig.init(database, application, version, attributes, attachments); + ApplicationInfo applicationInfo = activity.getApplicationInfo(); Log.d("BugSplat", "init result: " + jniInitBugSplat(applicationInfo.dataDir, applicationInfo.nativeLibraryDir, database, application, @@ -56,14 +60,16 @@ public static void hang() { public static void setAttribute(String key, String value) { validateAttributeKey(key); if (value == null) { - jniRemoveAttribute(key); + removeAttribute(key); return; } + BugSplatConfig.setAttribute(key, value); jniSetAttribute(key, value); } public static void removeAttribute(String key) { validateAttributeKey(key); + BugSplatConfig.removeAttribute(key); jniRemoveAttribute(key); } diff --git a/app/src/main/java/com/bugsplat/android/BugSplatConfig.java b/app/src/main/java/com/bugsplat/android/BugSplatConfig.java new file mode 100644 index 0000000..4fda22a --- /dev/null +++ b/app/src/main/java/com/bugsplat/android/BugSplatConfig.java @@ -0,0 +1,102 @@ +package com.bugsplat.android; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Java-side mirror of the values handed to {@code init}. + * + *

Crashpad owns this state natively, but native memory is not readable from + * Java, so APIs that build a report in-process โ€” {@link ExceptionReporter} โ€” + * need their own copy of the database/application/version triple plus the + * attributes and attachments that a crash report would carry.

+ * + *

All accessors are safe to call from any thread.

+ */ +final class BugSplatConfig { + private static volatile String database; + private static volatile String application; + private static volatile String version; + private static final Map attributes = new ConcurrentHashMap<>(); + private static volatile List attachments = Collections.emptyList(); + + private BugSplatConfig() { + } + + /** Record the init parameters. Replaces any state from a prior init. */ + static void init(String database, String application, String version, + Map attributes, String[] attachments) { + BugSplatConfig.database = database; + BugSplatConfig.application = application; + BugSplatConfig.version = version; + + BugSplatConfig.attributes.clear(); + if (attributes != null) { + for (Map.Entry entry : attributes.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + BugSplatConfig.attributes.put(entry.getKey(), entry.getValue()); + } + } + } + + BugSplatConfig.attachments = attachments == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(java.util.Arrays.asList(attachments))); + } + + /** True once {@code init} has supplied the database/application/version triple. */ + static boolean isInitialized() { + return database != null && application != null && version != null; + } + + static String database() { + return database; + } + + static String application() { + return application; + } + + static String version() { + return version; + } + + static void setAttribute(String key, String value) { + attributes.put(key, value); + } + + static void removeAttribute(String key) { + attributes.remove(key); + } + + /** A point-in-time copy of the attributes, in no particular order. */ + static Map attributesSnapshot() { + return new LinkedHashMap<>(attributes); + } + + /** The init attachments as {@link File}s; missing paths are left for the zip builder to skip. */ + static List attachmentFiles() { + List paths = attachments; + List files = new ArrayList<>(paths.size()); + for (String path : paths) { + if (path != null && !path.isEmpty()) { + files.add(new File(path)); + } + } + return files; + } + + /** Test hook โ€” clears all recorded state. */ + static void reset() { + database = null; + application = null; + version = null; + attributes.clear(); + attachments = Collections.emptyList(); + } +} diff --git a/app/src/main/java/com/bugsplat/android/ExceptionReporter.java b/app/src/main/java/com/bugsplat/android/ExceptionReporter.java new file mode 100644 index 0000000..a306a2c --- /dev/null +++ b/app/src/main/java/com/bugsplat/android/ExceptionReporter.java @@ -0,0 +1,267 @@ +package com.bugsplat.android; + +import android.util.Log; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Uploads caught {@link Throwable}s to BugSplat as non-fatal reports. + * + *

The body is the same XML report the BugSplat Java SDK posts under crash + * type id 4 โ€” a {@code } document whose frames carry symbol, file, and + * line for each {@link StackTraceElement}. It is zipped as {@code stack.jdmp}, + * matching the entry name the server expects for that crash type. Attributes, + * attachments, and the database/application/version triple come from + * {@link BugSplatConfig} so a non-fatal carries the same metadata as a crash.

+ * + *

Reports are gated by {@link #shouldPost(long)}: a caught exception inside a + * render or game loop can fire every frame, so posts closer together than + * {@link #DEFAULT_MIN_POST_INTERVAL_MS} are dropped. This mirrors the + * client-side guard in the BugSplat Unity SDK.

+ */ +class ExceptionReporter { + private static final String TAG = "BugSplat"; + static final String CRASH_TYPE = "Android.Java"; + static final int CRASH_TYPE_ID = 4; + static final String ENTRY_NAME = "stack.jdmp"; + + /** Upper bound on how far a cause chain is walked. */ + private static final int MAX_CAUSE_DEPTH = 16; + + /** Default minimum gap between two accepted posts. */ + static final long DEFAULT_MIN_POST_INTERVAL_MS = 3_000L; + + private static final AtomicLong minPostIntervalMs = new AtomicLong(DEFAULT_MIN_POST_INTERVAL_MS); + /** Timestamp of the last accepted post; 0 means none yet. */ + private static final AtomicLong lastPostMs = new AtomicLong(0L); + + private final ReportUploader uploader; + + ExceptionReporter(String database, String application, String version) { + this(new ReportUploader(database, application, version)); + } + + /** Package-private constructor for testing with a custom uploader. */ + ExceptionReporter(ReportUploader uploader) { + this.uploader = uploader; + } + + // -- Rate limiting -- + + /** + * Set the minimum interval between accepted posts. A value of zero or less + * disables the guard. + */ + static void setMinPostIntervalMillis(long millis) { + minPostIntervalMs.set(millis); + } + + static long getMinPostIntervalMillis() { + return minPostIntervalMs.get(); + } + + /** + * Claim a post slot for {@code nowMs}. Returns true โ€” and records the + * timestamp โ€” when the caller may post; false when the previous post was + * too recent. + */ + static boolean shouldPost(long nowMs) { + long interval = minPostIntervalMs.get(); + if (interval <= 0) { + lastPostMs.set(nowMs); + return true; + } + while (true) { + long last = lastPostMs.get(); + if (last != 0L && nowMs - last < interval) { + return false; + } + if (lastPostMs.compareAndSet(last, nowMs)) { + return true; + } + } + } + + /** Test hook โ€” forgets the last post so the next one is always allowed. */ + static void resetRateLimit() { + lastPostMs.set(0L); + minPostIntervalMs.set(DEFAULT_MIN_POST_INTERVAL_MS); + } + + // -- Upload -- + + /** + * Build and upload a non-fatal report for {@code throwable}. + * + * @param throwable the caught exception (must not be null) + * @param attributes per-call attributes, merged over the init attributes + * @param attachments files to include alongside the report, or null + * @return true if the report was uploaded + */ + boolean post(Throwable throwable, Map attributes, List attachments) { + if (throwable == null) { + Log.e(TAG, "postException called with a null throwable"); + return false; + } + + try { + byte[] report = buildXmlReport(throwable).getBytes(StandardCharsets.UTF_8); + byte[] zipped = ReportUploader.zip(ENTRY_NAME, report, attachments); + + CommitOptions options = new CommitOptions() + .crashType(CRASH_TYPE) + .crashTypeId(CRASH_TYPE_ID) + .description(throwable.toString()) + .attributes(mergeAttributes(attributes)); + + if (uploader.upload(zipped, options)) { + Log.i(TAG, "Exception reported: " + throwable); + return true; + } + Log.e(TAG, "Failed to report exception: " + throwable); + return false; + } catch (IOException e) { + Log.e(TAG, "Failed to report exception", e); + return false; + } + } + + /** Init attributes with the per-call attributes layered on top. */ + private static Map mergeAttributes(Map callAttributes) { + Map merged = new LinkedHashMap<>(BugSplatConfig.attributesSnapshot()); + if (callAttributes != null) { + for (Map.Entry entry : callAttributes.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + merged.put(entry.getKey(), entry.getValue()); + } + } + } + return merged; + } + + // -- Report body -- + + /** + * Render {@code throwable} as a BugSplat XML report. + * + *

Frames come from the root cause โ€” the innermost {@code getCause()} โ€” so + * reports group by where the failure actually originated, matching the Java + * SDK. The full chain, wrappers included, is preserved in + * {@code }.

+ */ + static String buildXmlReport(Throwable throwable) { + Throwable root = rootCause(throwable); + StackTraceElement[] stack = root.getStackTrace(); + + StringBuilder sb = new StringBuilder(1024); + sb.append("\n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" ").append(cdata(stackKey(root, stack))).append("\n"); + sb.append(" ").append(cdata(root.getMessage())).append("\n"); + sb.append(" ").append(cdata(describeChain(throwable))).append("\n"); + sb.append(" ").append(escape(fileNameOf(stack))).append("\n"); + sb.append(" ").append(lineNumberOf(stack)).append("\n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + + for (StackTraceElement frame : stack) { + sb.append(" \n"); + sb.append(" ").append(cdata(symbolOf(frame))).append("\n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" ").append(escape(frame.getFileName())).append("\n"); + sb.append(" ").append(frame.getLineNumber()).append("\n"); + sb.append(" \n"); + } + + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append("\n"); + return sb.toString(); + } + + /** Walk to the innermost cause, bounded so a self-referential chain can't spin. */ + static Throwable rootCause(Throwable throwable) { + Throwable root = throwable; + for (int depth = 0; depth < MAX_CAUSE_DEPTH; depth++) { + Throwable cause = root.getCause(); + if (cause == null || cause == root) { + break; + } + root = cause; + } + return root; + } + + /** + * The value BugSplat groups on. Uses the top frame's class and method; a + * throwable with no stack trace (possible when the JVM elides it) falls back + * to the exception's own class name so the report still groups sensibly. + */ + private static String stackKey(Throwable root, StackTraceElement[] stack) { + return stack.length > 0 ? symbolOf(stack[0]) : root.getClass().getName(); + } + + private static String symbolOf(StackTraceElement frame) { + return frame.getClassName() + "." + frame.getMethodName(); + } + + private static String fileNameOf(StackTraceElement[] stack) { + return stack.length > 0 ? stack[0].getFileName() : null; + } + + private static int lineNumberOf(StackTraceElement[] stack) { + return stack.length > 0 ? stack[0].getLineNumber() : 0; + } + + /** {@code toString()} of the throwable and each of its causes, outermost first. */ + private static String describeChain(Throwable throwable) { + StringBuilder sb = new StringBuilder(); + Throwable current = throwable; + int depth = 0; + while (current != null && depth < MAX_CAUSE_DEPTH) { + if (depth > 0) { + sb.append("\nCaused by: "); + } + sb.append(current); + Throwable cause = current.getCause(); + if (cause == current) { + break; + } + current = cause; + depth++; + } + return sb.toString(); + } + + /** + * Wrap {@code value} in a CDATA section. A literal {@code ]]>} in the value + * would close the section early, so it is split across two sections. + */ + private static String cdata(String value) { + String safe = value == null ? "" : value.replace("]]>", "]]]]>"); + return ""; + } + + /** Escape the few characters that are illegal in element text. */ + private static String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } +} diff --git a/app/src/main/java/com/bugsplat/android/FeedbackClient.java b/app/src/main/java/com/bugsplat/android/FeedbackClient.java index 4adcf29..6103e00 100644 --- a/app/src/main/java/com/bugsplat/android/FeedbackClient.java +++ b/app/src/main/java/com/bugsplat/android/FeedbackClient.java @@ -5,15 +5,11 @@ import org.json.JSONException; import org.json.JSONObject; -import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; /** * Posts User Feedback reports to BugSplat via the 3-part presigned-URL flow. @@ -77,7 +73,7 @@ FeedbackResult postFeedbackWithResult(String title, String description, String u } byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8); - byte[] zipped = buildZip(jsonBytes, attachments); + byte[] zipped = ReportUploader.zip("feedback.json", jsonBytes, attachments); CommitOptions options = new CommitOptions() .crashType(CRASH_TYPE) @@ -102,39 +98,4 @@ FeedbackResult postFeedbackWithResult(String title, String description, String u return FeedbackResult.failure(); } } - - /** - * Build the feedback zip: feedback.json first, then each attachment - * streamed directly from disk (not buffered fully in memory). - * Attachment filenames are used as-is for zip entry names โ€” callers are - * responsible for ensuring they don't collide with each other or with - * {@code feedback.json}. - */ - private static byte[] buildZip(byte[] feedbackJson, List attachments) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - zos.putNextEntry(new ZipEntry("feedback.json")); - zos.write(feedbackJson); - zos.closeEntry(); - - if (attachments != null) { - byte[] buffer = new byte[8192]; - for (File file : attachments) { - if (file == null || !file.exists() || !file.isFile()) { - Log.w(TAG, "Skipping invalid attachment: " + file); - continue; - } - zos.putNextEntry(new ZipEntry(file.getName())); - try (FileInputStream fis = new FileInputStream(file)) { - int n; - while ((n = fis.read(buffer)) != -1) { - zos.write(buffer, 0, n); - } - } - zos.closeEntry(); - } - } - } - return baos.toByteArray(); - } } diff --git a/app/src/main/java/com/bugsplat/android/ReportUploader.java b/app/src/main/java/com/bugsplat/android/ReportUploader.java index d40aa18..048f760 100644 --- a/app/src/main/java/com/bugsplat/android/ReportUploader.java +++ b/app/src/main/java/com/bugsplat/android/ReportUploader.java @@ -6,6 +6,8 @@ import java.io.BufferedReader; import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -14,6 +16,7 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -265,11 +268,43 @@ private static UploadResult parseCommitResponse(String body) { /** Convenience: build a single-entry zip around {@code data}. */ static byte[] zip(String entryName, byte[] data) throws IOException { + return zip(entryName, data, null); + } + + /** + * Build a zip holding {@code data} as {@code entryName}, followed by each + * attachment streamed directly from disk (not buffered fully in memory). + * Attachments that are null, missing, or not regular files are skipped. + * + *

Attachment filenames are used as-is for zip entry names โ€” callers are + * responsible for ensuring they don't collide with each other or with + * {@code entryName}.

+ */ + static byte[] zip(String entryName, byte[] data, List attachments) + throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (ZipOutputStream zos = new ZipOutputStream(baos)) { zos.putNextEntry(new ZipEntry(entryName)); zos.write(data); zos.closeEntry(); + + if (attachments != null) { + byte[] buffer = new byte[8192]; + for (File file : attachments) { + if (file == null || !file.exists() || !file.isFile()) { + Log.w(TAG, "Skipping invalid attachment: " + file); + continue; + } + zos.putNextEntry(new ZipEntry(file.getName())); + try (FileInputStream fis = new FileInputStream(file)) { + int n; + while ((n = fis.read(buffer)) != -1) { + zos.write(buffer, 0, n); + } + } + zos.closeEntry(); + } + } } return baos.toByteArray(); } diff --git a/app/src/test/java/com/bugsplat/android/BugSplatConfigTest.java b/app/src/test/java/com/bugsplat/android/BugSplatConfigTest.java new file mode 100644 index 0000000..ba19d30 --- /dev/null +++ b/app/src/test/java/com/bugsplat/android/BugSplatConfigTest.java @@ -0,0 +1,93 @@ +package com.bugsplat.android; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +public class BugSplatConfigTest { + + @Before + public void setUp() { + BugSplatConfig.reset(); + } + + @After + public void tearDown() { + BugSplatConfig.reset(); + } + + @Test + public void isInitialized_falseUntilInit() { + assertFalse(BugSplatConfig.isInitialized()); + + BugSplatConfig.init("db", "app", "1.0.0", null, null); + + assertTrue(BugSplatConfig.isInitialized()); + assertEquals("db", BugSplatConfig.database()); + assertEquals("app", BugSplatConfig.application()); + assertEquals("1.0.0", BugSplatConfig.version()); + } + + @Test + public void init_copiesAttributesAndSkipsNullEntries() { + Map attributes = new LinkedHashMap<>(); + attributes.put("env", "prod"); + attributes.put("missing", null); + + BugSplatConfig.init("db", "app", "1.0.0", attributes, null); + + Map snapshot = BugSplatConfig.attributesSnapshot(); + assertEquals("prod", snapshot.get("env")); + assertFalse(snapshot.containsKey("missing")); + + // the snapshot is a copy โ€” mutating it must not affect the config + snapshot.put("env", "dev"); + assertEquals("prod", BugSplatConfig.attributesSnapshot().get("env")); + } + + @Test + public void init_replacesAttributesFromAPriorInit() { + BugSplatConfig.init("db", "app", "1.0.0", + java.util.Collections.singletonMap("stale", "yes"), null); + BugSplatConfig.init("db", "app", "2.0.0", null, null); + + assertTrue(BugSplatConfig.attributesSnapshot().isEmpty()); + assertEquals("2.0.0", BugSplatConfig.version()); + } + + @Test + public void setAndRemoveAttribute_updateTheSnapshot() { + BugSplatConfig.init("db", "app", "1.0.0", null, null); + + BugSplatConfig.setAttribute("level", "3"); + assertEquals("3", BugSplatConfig.attributesSnapshot().get("level")); + + BugSplatConfig.removeAttribute("level"); + assertFalse(BugSplatConfig.attributesSnapshot().containsKey("level")); + } + + @Test + public void attachmentFiles_mapsPathsAndSkipsBlanks() { + BugSplatConfig.init("db", "app", "1.0.0", null, + new String[]{"/tmp/one.log", null, "", "/tmp/two.log"}); + + List files = BugSplatConfig.attachmentFiles(); + + assertEquals(Arrays.asList(new File("/tmp/one.log"), new File("/tmp/two.log")), files); + } + + @Test + public void attachmentFiles_emptyWhenNoAttachments() { + BugSplatConfig.init("db", "app", "1.0.0", null, null); + + assertTrue(BugSplatConfig.attachmentFiles().isEmpty()); + } +} diff --git a/app/src/test/java/com/bugsplat/android/ExceptionReporterTest.java b/app/src/test/java/com/bugsplat/android/ExceptionReporterTest.java new file mode 100644 index 0000000..a5a6b98 --- /dev/null +++ b/app/src/test/java/com/bugsplat/android/ExceptionReporterTest.java @@ -0,0 +1,361 @@ +package com.bugsplat.android; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +import static org.junit.Assert.*; + +public class ExceptionReporterTest { + + private MockWebServer server; + + @Before + public void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + ExceptionReporter.resetRateLimit(); + BugSplatConfig.reset(); + } + + @After + public void tearDown() throws IOException { + server.shutdown(); + ExceptionReporter.resetRateLimit(); + BugSplatConfig.reset(); + } + + private ExceptionReporter createReporter() { + ReportUploader uploader = new ReportUploader("testdb", "testapp", "1.0.0") { + @Override + String getBaseUrl() { + String url = server.url("").toString(); + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + }; + return new ExceptionReporter(uploader); + } + + private void enqueueSuccessfulUpload() { + String presignedUrl = server.url("/s3-upload").toString(); + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"url\": \"" + presignedUrl + "\"}")); + server.enqueue(new MockResponse().setResponseCode(200)); + server.enqueue(new MockResponse().setResponseCode(200)); + } + + /** A throwable with a deterministic stack, so assertions don't depend on the test runner. */ + private static Throwable throwableWithStack(String message, StackTraceElement... frames) { + Throwable t = new IllegalStateException(message); + t.setStackTrace(frames); + return t; + } + + private static StackTraceElement frame(String cls, String method, String file, int line) { + return new StackTraceElement(cls, method, file, line); + } + + // ---- XML report body ---- + + @Test + public void xmlReport_containsExceptionHeaderFromTopFrame() { + Throwable t = throwableWithStack("boom", + frame("com.foo.Bar", "baz", "Bar.java", 42), + frame("com.foo.App", "run", "App.java", 10)); + + String xml = ExceptionReporter.buildXmlReport(t); + + assertTrue(xml.startsWith("")); + assertTrue("stack key is class.method of the top frame", + xml.contains("")); + assertTrue(xml.contains("")); + assertTrue(xml.contains("Bar.java")); + assertTrue(xml.contains("42")); + } + + @Test + public void xmlReport_emitsOneFramePerStackElement() { + Throwable t = throwableWithStack("boom", + frame("com.foo.Bar", "baz", "Bar.java", 42), + frame("com.foo.App", "run", "App.java", 10)); + + String xml = ExceptionReporter.buildXmlReport(t); + + assertTrue(xml.contains("framecount=\"2\"")); + assertEquals(2, countOccurrences(xml, "")); + assertTrue(xml.contains("")); + assertTrue(xml.contains("10")); + } + + @Test + public void xmlReport_usesRootCauseFramesAndKeepsFullChainInExplanation() { + Throwable cause = throwableWithStack("inner", frame("com.foo.Inner", "fail", "Inner.java", 7)); + Throwable outer = new RuntimeException("outer", cause); + outer.setStackTrace(new StackTraceElement[]{frame("com.foo.Outer", "wrap", "Outer.java", 99)}); + + String xml = ExceptionReporter.buildXmlReport(outer); + + assertTrue("frames come from the root cause", + xml.contains("")); + assertFalse("wrapper frames are not used for grouping", + xml.contains("")); + assertTrue("explanation retains the wrapper", xml.contains("java.lang.RuntimeException: outer")); + assertTrue("explanation retains the cause", xml.contains("Caused by: java.lang.IllegalStateException: inner")); + } + + @Test + public void xmlReport_fallsBackToClassNameWhenStackIsEmpty() { + Throwable t = throwableWithStack("no frames"); + + String xml = ExceptionReporter.buildXmlReport(t); + + assertTrue(xml.contains("")); + assertTrue(xml.contains("framecount=\"0\"")); + assertTrue(xml.contains("0")); + } + + @Test + public void xmlReport_handlesNullMessageAndNullFileName() { + Throwable t = new IllegalStateException(); + t.setStackTrace(new StackTraceElement[]{frame("com.foo.Bar", "baz", null, -2)}); + + String xml = ExceptionReporter.buildXmlReport(t); + + assertTrue(xml.contains("")); + assertTrue(xml.contains("")); + } + + @Test + public void xmlReport_splitsCdataTerminatorInsideMessage() { + Throwable t = throwableWithStack("payload ]]> injected", + frame("com.foo.Bar", "baz", "Bar.java", 1)); + + String xml = ExceptionReporter.buildXmlReport(t); + + assertTrue("a literal ]]> must not close the section early", + xml.contains(" injected]]>")); + } + + @Test + public void xmlReport_escapesMarkupInFileNames() { + Throwable t = new IllegalStateException("boom"); + t.setStackTrace(new StackTraceElement[]{frame("com.foo.Bar", "baz", "&.java", 1)}); + + String xml = ExceptionReporter.buildXmlReport(t); + + assertTrue(xml.contains("<Bar>&.java")); + } + + @Test + public void rootCause_stopsOnSelfReferentialChain() { + Throwable t = new RuntimeException("loop") { + @Override + public synchronized Throwable getCause() { + return this; + } + }; + + assertSame(t, ExceptionReporter.rootCause(t)); + } + + // ---- upload ---- + + @Test + public void post_uploadsReportAsStackJdmpEntry() throws Exception { + enqueueSuccessfulUpload(); + + Throwable t = throwableWithStack("boom", frame("com.foo.Bar", "baz", "Bar.java", 42)); + assertTrue(createReporter().post(t, null, null)); + + server.takeRequest(); // getCrashUploadUrl + RecordedRequest putRequest = server.takeRequest(); + + String xml = extractZipContent(putRequest.getBody().readByteArray(), "stack.jdmp"); + assertTrue(xml.contains("")); + } + + @Test + public void commitRequest_usesAndroidJavaCrashType() throws Exception { + enqueueSuccessfulUpload(); + + createReporter().post(new IllegalStateException("boom"), null, null); + + server.takeRequest(); + server.takeRequest(); + RecordedRequest commitRequest = server.takeRequest(); + + String body = commitRequest.getBody().readUtf8(); + assertTrue("should use Android.Java crash type", body.contains("Android.Java")); + assertTrue("should use crash type id 4", + body.contains("name=\"crashTypeId\"\r\n\r\n4\r\n")); + assertTrue("description mirrors the throwable", + body.contains("java.lang.IllegalStateException: boom")); + } + + @Test + public void commitRequest_mergesInitAttributesWithCallAttributes() throws Exception { + enqueueSuccessfulUpload(); + + Map initAttributes = new LinkedHashMap<>(); + initAttributes.put("env", "prod"); + initAttributes.put("tier", "free"); + BugSplatConfig.init("testdb", "testapp", "1.0.0", initAttributes, null); + BugSplatConfig.setAttribute("level", "3"); + + Map callAttributes = new LinkedHashMap<>(); + callAttributes.put("tier", "premium"); // per-call wins + callAttributes.put("screen", "checkout"); + + createReporter().post(new IllegalStateException("boom"), callAttributes, null); + + server.takeRequest(); + server.takeRequest(); + String body = server.takeRequest().getBody().readUtf8(); + + assertTrue(body.contains("name=\"attributes\"")); + assertTrue("init attribute is carried", body.contains("\"env\":\"prod\"")); + assertTrue("setAttribute value is carried", body.contains("\"level\":\"3\"")); + assertTrue("per-call attribute overrides init", body.contains("\"tier\":\"premium\"")); + assertFalse(body.contains("\"tier\":\"free\"")); + assertTrue(body.contains("\"screen\":\"checkout\"")); + } + + @Test + public void commitRequest_omitsAttributesWhenNoneSet() throws Exception { + enqueueSuccessfulUpload(); + + createReporter().post(new IllegalStateException("boom"), null, null); + + server.takeRequest(); + server.takeRequest(); + String body = server.takeRequest().getBody().readUtf8(); + + assertFalse(body.contains("name=\"attributes\"")); + } + + @Test + public void post_includesAttachmentsAlongsideTheReport() throws Exception { + enqueueSuccessfulUpload(); + + File tempFile = File.createTempFile("exception_attachment", ".txt"); + try (FileWriter writer = new FileWriter(tempFile)) { + writer.write("log line"); + } + + assertTrue(createReporter().post(new IllegalStateException("boom"), null, + Collections.singletonList(tempFile))); + + server.takeRequest(); + byte[] zipData = server.takeRequest().getBody().readByteArray(); + + assertEquals("log line", extractZipContent(zipData, tempFile.getName())); + assertNotNull(extractZipContent(zipData, "stack.jdmp")); + + tempFile.delete(); + } + + @Test + public void post_returnsFalseOnUploadFailure() throws Exception { + server.enqueue(new MockResponse().setResponseCode(500)); + + assertFalse(createReporter().post(new IllegalStateException("boom"), null, null)); + } + + @Test + public void post_returnsFalseForNullThrowable() { + assertFalse(createReporter().post(null, null, null)); + assertEquals(0, server.getRequestCount()); + } + + // ---- rate limiting ---- + + @Test + public void shouldPost_allowsFirstPost() { + assertTrue(ExceptionReporter.shouldPost(1_000L)); + } + + @Test + public void shouldPost_dropsPostsInsideTheInterval() { + assertTrue(ExceptionReporter.shouldPost(1_000L)); + assertFalse(ExceptionReporter.shouldPost(1_500L)); + assertFalse(ExceptionReporter.shouldPost( + 1_000L + ExceptionReporter.DEFAULT_MIN_POST_INTERVAL_MS - 1)); + } + + @Test + public void shouldPost_allowsPostAfterTheInterval() { + assertTrue(ExceptionReporter.shouldPost(1_000L)); + assertTrue(ExceptionReporter.shouldPost( + 1_000L + ExceptionReporter.DEFAULT_MIN_POST_INTERVAL_MS)); + } + + @Test + public void shouldPost_droppedPostDoesNotExtendTheWindow() { + assertTrue(ExceptionReporter.shouldPost(1_000L)); + assertFalse(ExceptionReporter.shouldPost(3_000L)); + assertTrue("window is measured from the last accepted post, not the last attempt", + ExceptionReporter.shouldPost(4_000L)); + } + + @Test + public void shouldPost_intervalOfZeroDisablesTheGuard() { + ExceptionReporter.setMinPostIntervalMillis(0); + assertTrue(ExceptionReporter.shouldPost(1_000L)); + assertTrue(ExceptionReporter.shouldPost(1_000L)); + } + + @Test + public void setMinPostIntervalMillis_isHonored() { + ExceptionReporter.setMinPostIntervalMillis(10_000L); + assertEquals(10_000L, ExceptionReporter.getMinPostIntervalMillis()); + assertTrue(ExceptionReporter.shouldPost(1_000L)); + assertFalse(ExceptionReporter.shouldPost(9_000L)); + assertTrue(ExceptionReporter.shouldPost(11_000L)); + } + + // ---- helpers ---- + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + int index = haystack.indexOf(needle); + while (index != -1) { + count++; + index = haystack.indexOf(needle, index + needle.length()); + } + return count; + } + + private String extractZipContent(byte[] zipData, String entryName) throws IOException { + ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipData)); + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().equals(entryName)) { + byte[] buffer = new byte[4096]; + StringBuilder sb = new StringBuilder(); + int len; + while ((len = zis.read(buffer)) != -1) { + sb.append(new String(buffer, 0, len, "UTF-8")); + } + zis.close(); + return sb.toString(); + } + } + zis.close(); + fail("zip entry '" + entryName + "' not found"); + return null; + } +} diff --git a/example/src/main/java/com/bugsplat/example/MainActivity.java b/example/src/main/java/com/bugsplat/example/MainActivity.java index c1a51d6..f398086 100644 --- a/example/src/main/java/com/bugsplat/example/MainActivity.java +++ b/example/src/main/java/com/bugsplat/example/MainActivity.java @@ -20,7 +20,9 @@ import com.bugsplat.android.BugSplat; import java.io.File; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class MainActivity extends AppCompatActivity implements FeedbackSheetFragment.FeedbackListener { @@ -140,10 +142,17 @@ private void triggerNonCrashError() { value.length(); } catch (Exception e) { Log.e(TAG, "Caught non-crash exception", e); - statusTextView.setText("Caught: " + e.getClass().getSimpleName() + " โ€” app still running"); - Toast.makeText(this, "Exception caught", Toast.LENGTH_SHORT).show(); + + // Report the caught exception to BugSplat as a non-fatal. The upload + // happens on a background thread, so the app keeps running. + Map attributes = new HashMap<>(); + attributes.put("source", "error card"); + BugSplat.postException(e, attributes); + + statusTextView.setText("Reported: " + e.getClass().getSimpleName() + " โ€” app still running"); + Toast.makeText(this, "Exception reported to BugSplat", Toast.LENGTH_SHORT).show(); ActivityLog.record(this, ActivityLog.TYPE_ERROR, - e.getClass().getSimpleName() + " caught"); + e.getClass().getSimpleName() + " reported"); renderRecentActivity(); } } diff --git a/example/src/main/res/values/strings.xml b/example/src/main/res/values/strings.xml index d4c9270..36ac5a0 100644 --- a/example/src/main/res/values/strings.xml +++ b/example/src/main/res/values/strings.xml @@ -16,7 +16,7 @@ Crash Native crash ยท stack + threads + memory Non-Crash Error - Exception caught ยท app keeps running + Caught exception reported ยท app keeps running User Feedback Open the feedback sheet Hang