From 30dd8328e3b76fe76295d0afb5e672a5c66e14ce Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Tue, 14 Apr 2026 21:16:51 -0400 Subject: [PATCH 01/15] feat: android ANR --- app/build.gradle | 6 + .../com/bugsplat/android/AnrReporter.java | 162 +++++++++ .../com/bugsplat/android/BugSplatBridge.java | 4 + .../com/bugsplat/android/FeedbackClient.java | 135 +++---- .../com/bugsplat/android/ReportUploader.java | 259 ++++++++++++++ .../bugsplat/android/FeedbackClientTest.java | 200 +++++++++++ .../bugsplat/android/ReportUploaderTest.java | 331 ++++++++++++++++++ .../com/bugsplat/example/MainActivity.java | 18 + example/src/main/res/layout/activity_main.xml | 16 +- gradle/libs.versions.toml | 2 + 10 files changed, 1040 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/com/bugsplat/android/AnrReporter.java create mode 100644 app/src/main/java/com/bugsplat/android/ReportUploader.java create mode 100644 app/src/test/java/com/bugsplat/android/FeedbackClientTest.java create mode 100644 app/src/test/java/com/bugsplat/android/ReportUploaderTest.java diff --git a/app/build.gradle b/app/build.gradle index 26a11b3..9ccfce5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,6 +47,10 @@ android { } } + testOptions { + unitTests.returnDefaultValues = true + } + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -113,6 +117,8 @@ dependencies { implementation libs.material implementation libs.androidx.constraintlayout testImplementation libs.junit + testImplementation libs.mockwebserver + testImplementation 'org.json:json:20231013' androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.espresso.core } diff --git a/app/src/main/java/com/bugsplat/android/AnrReporter.java b/app/src/main/java/com/bugsplat/android/AnrReporter.java new file mode 100644 index 0000000..5df69b2 --- /dev/null +++ b/app/src/main/java/com/bugsplat/android/AnrReporter.java @@ -0,0 +1,162 @@ +package com.bugsplat.android; + +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Detects and reports ANR (Application Not Responding) events using the + * ApplicationExitInfo API available on Android 11+ (API 30+). + * + * On each SDK initialization, this class checks for historical ANR exit reasons + * from previous process instances. For each new (unreported) ANR, it reads the + * system-provided thread dump and uploads it to BugSplat via the 3-part S3 upload. + * + * The thread dump is plain text in the same format as /data/anr/traces.txt and + * includes all threads with full Java + native stack traces, lock info, and + * BuildIds for native frames. Native frame addresses are relative to the library + * load address and can be symbolicated against .sym files by matching FUNC/line + * records. + */ +class AnrReporter { + private static final String TAG = "BugSplat-ANR"; + private static final String PREFS_NAME = "bugsplat_anr"; + private static final String PREF_LAST_REPORTED_TIMESTAMP = "last_reported_anr_timestamp"; + private static final String CRASH_TYPE = "Android ANR"; + private static final int CRASH_TYPE_ID = 37; + + private final Context context; + private final String database; + private final String application; + private final String version; + private final ExecutorService executor; + + AnrReporter(Context context, String database, String application, String version) { + this.context = context.getApplicationContext(); + this.database = database; + this.application = application; + this.version = version; + this.executor = Executors.newSingleThreadExecutor(); + } + + /** + * Check for unreported ANRs and upload them in the background. + * Safe to call on any API level — silently no-ops below Android 11. + */ + void checkAndReport() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Log.d(TAG, "ANR reporting requires Android 11+ (API 30+), skipping"); + return; + } + + executor.execute(() -> { + try { + checkAndReportInternal(); + } catch (Exception e) { + Log.e(TAG, "Failed to check/report ANRs", e); + } + }); + } + + private void checkAndReportInternal() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return; + } + + ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (am == null) { + Log.w(TAG, "ActivityManager unavailable"); + return; + } + + List exitInfos = am.getHistoricalProcessExitReasons( + context.getPackageName(), 0, 0); + + if (exitInfos == null || exitInfos.isEmpty()) { + Log.d(TAG, "No historical exit reasons found"); + return; + } + + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + long lastReportedTimestamp = prefs.getLong(PREF_LAST_REPORTED_TIMESTAMP, 0); + long newestAnrTimestamp = lastReportedTimestamp; + + ReportUploader uploader = new ReportUploader(database, application, version); + + for (ApplicationExitInfo exitInfo : exitInfos) { + if (exitInfo.getReason() != ApplicationExitInfo.REASON_ANR) { + continue; + } + + long timestamp = exitInfo.getTimestamp(); + if (timestamp <= lastReportedTimestamp) { + break; + } + + newestAnrTimestamp = Math.max(newestAnrTimestamp, timestamp); + + String threadDump = readTraceStream(exitInfo); + if (threadDump == null || threadDump.isEmpty()) { + Log.w(TAG, "Empty trace stream for ANR at " + timestamp); + continue; + } + + boolean foreground = exitInfo.getImportance() + == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; + + Log.i(TAG, "Reporting ANR at " + timestamp + + " (pid=" + exitInfo.getPid() + + ", foreground=" + foreground + + ", description=" + exitInfo.getDescription() + ")"); + + try { + uploader.upload( + threadDump.getBytes("UTF-8"), + "anr_trace.txt", + CRASH_TYPE, + CRASH_TYPE_ID + ); + } catch (IOException e) { + Log.e(TAG, "Failed to upload ANR report", e); + } + } + + if (newestAnrTimestamp > lastReportedTimestamp) { + prefs.edit() + .putLong(PREF_LAST_REPORTED_TIMESTAMP, newestAnrTimestamp) + .apply(); + } + } + + private String readTraceStream(ApplicationExitInfo exitInfo) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return null; + } + + try (InputStream is = exitInfo.getTraceInputStream()) { + if (is == null) { + return null; + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + baos.write(buffer, 0, bytesRead); + } + return baos.toString("UTF-8"); + } catch (IOException e) { + Log.e(TAG, "Failed to read ANR trace stream", e); + return null; + } + } +} diff --git a/app/src/main/java/com/bugsplat/android/BugSplatBridge.java b/app/src/main/java/com/bugsplat/android/BugSplatBridge.java index 63dccf7..523fabb 100644 --- a/app/src/main/java/com/bugsplat/android/BugSplatBridge.java +++ b/app/src/main/java/com/bugsplat/android/BugSplatBridge.java @@ -35,6 +35,10 @@ public static void initBugSplat(Activity activity, String database, String appli Log.d("BugSplat", "init result: " + jniInitBugSplat(applicationInfo.dataDir, applicationInfo.nativeLibraryDir, database, application, version, attributes, attachments)); + + // Check for ANR reports from previous sessions (Android 11+) + AnrReporter anrReporter = new AnrReporter(activity, database, application, version); + anrReporter.checkAndReport(); } public static void crash() { diff --git a/app/src/main/java/com/bugsplat/android/FeedbackClient.java b/app/src/main/java/com/bugsplat/android/FeedbackClient.java index ed760bb..e397fc2 100644 --- a/app/src/main/java/com/bugsplat/android/FeedbackClient.java +++ b/app/src/main/java/com/bugsplat/android/FeedbackClient.java @@ -3,27 +3,31 @@ import android.util.Log; import java.io.ByteArrayOutputStream; -import java.io.DataOutputStream; import java.io.File; -import java.io.FileInputStream; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.UUID; class FeedbackClient { private static final String TAG = "BugSplat"; + private static final String CRASH_TYPE = "UserFeedback"; + private static final int CRASH_TYPE_ID = 36; private final String database; private final String application; private final String version; + private final ReportUploader uploader; FeedbackClient(String database, String application, String version) { + this(database, application, version, new ReportUploader(database, application, version)); + } + + /** Package-private constructor for testing with a custom uploader. */ + FeedbackClient(String database, String application, String version, ReportUploader uploader) { this.database = database; this.application = application; this.version = version; + this.uploader = uploader; } boolean postFeedback(String title, String description, String user, String email, String appKey) { @@ -32,100 +36,49 @@ boolean postFeedback(String title, String description, String user, String email boolean postFeedback(String title, String description, String user, String email, String appKey, List attachments) { try { - String url = "https://" + database + ".bugsplat.com/post/feedback/"; - String boundary = UUID.randomUUID().toString(); - - HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); - try { - conn.setRequestMethod("POST"); - conn.setDoOutput(true); - conn.setConnectTimeout(30000); - conn.setReadTimeout(30000); - conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); - - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - DataOutputStream dos = new DataOutputStream(baos); - - // Required fields - writeFormField(dos, boundary, "database", database); - writeFormField(dos, boundary, "appName", application); - writeFormField(dos, boundary, "appVersion", version); - writeFormField(dos, boundary, "title", title); - - // Optional fields - if (description != null && !description.isEmpty()) { - writeFormField(dos, boundary, "description", description); - } - if (user != null && !user.isEmpty()) { - writeFormField(dos, boundary, "user", user); - } - if (email != null && !email.isEmpty()) { - writeFormField(dos, boundary, "email", email); - } - if (appKey != null && !appKey.isEmpty()) { - writeFormField(dos, boundary, "appKey", appKey); - } + // Build a simple text report with the feedback fields + StringBuilder report = new StringBuilder(); + report.append("Title: ").append(title != null ? title : "").append("\n"); + if (description != null && !description.isEmpty()) { + report.append("Description: ").append(description).append("\n"); + } + if (user != null && !user.isEmpty()) { + report.append("User: ").append(user).append("\n"); + } + if (email != null && !email.isEmpty()) { + report.append("Email: ").append(email).append("\n"); + } + if (appKey != null && !appKey.isEmpty()) { + report.append("AppKey: ").append(appKey).append("\n"); + } - // File attachments - if (attachments != null) { - for (File file : attachments) { - if (file == null || !file.exists() || !file.isFile()) { - Log.w(TAG, "Skipping invalid attachment: " + file); - continue; - } - writeFileField(dos, boundary, file.getName(), file); - Log.d(TAG, "Attached file " + file.getName() + " (" + file.length() + " bytes)"); + // Add attachment info + if (attachments != null) { + for (File file : attachments) { + if (file != null && file.exists() && file.isFile()) { + report.append("Attachment: ").append(file.getName()) + .append(" (").append(file.length()).append(" bytes)\n"); } } + } - dos.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); - dos.flush(); - - byte[] payload = baos.toByteArray(); - conn.setRequestProperty("Content-Length", String.valueOf(payload.length)); - try (OutputStream out = conn.getOutputStream()) { - out.write(payload); - out.flush(); - } - - int status = conn.getResponseCode(); - - if (status >= 200 && status < 300) { - Log.i(TAG, "Feedback posted successfully (HTTP " + status + ")"); - return true; - } else { - Log.e(TAG, "Failed to post feedback (HTTP " + status + ")"); - return false; - } - } finally { - conn.disconnect(); + boolean success = uploader.upload( + report.toString().getBytes(StandardCharsets.UTF_8), + "feedback.txt", + CRASH_TYPE, + CRASH_TYPE_ID + ); + + if (success) { + Log.i(TAG, "Feedback posted successfully"); + } else { + Log.e(TAG, "Failed to post feedback"); } + return success; } catch (Exception e) { Log.e(TAG, "Failed to post feedback", e); return false; } } - - private void writeFormField(DataOutputStream dos, String boundary, String name, String value) throws Exception { - dos.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); - dos.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n").getBytes(StandardCharsets.UTF_8)); - dos.write(value.getBytes(StandardCharsets.UTF_8)); - dos.write("\r\n".getBytes(StandardCharsets.UTF_8)); - } - - private void writeFileField(DataOutputStream dos, String boundary, String fieldName, File file) throws Exception { - dos.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); - dos.write(("Content-Disposition: form-data; name=\"" + fieldName + "\"; filename=\"" + file.getName() + "\"\r\n").getBytes(StandardCharsets.UTF_8)); - dos.write("Content-Type: application/octet-stream\r\n\r\n".getBytes(StandardCharsets.UTF_8)); - - byte[] buffer = new byte[4096]; - try (FileInputStream fis = new FileInputStream(file)) { - int bytesRead; - while ((bytesRead = fis.read(buffer)) != -1) { - dos.write(buffer, 0, bytesRead); - } - } - dos.write("\r\n".getBytes(StandardCharsets.UTF_8)); - } } diff --git a/app/src/main/java/com/bugsplat/android/ReportUploader.java b/app/src/main/java/com/bugsplat/android/ReportUploader.java new file mode 100644 index 0000000..1107622 --- /dev/null +++ b/app/src/main/java/com/bugsplat/android/ReportUploader.java @@ -0,0 +1,259 @@ +package com.bugsplat.android; + +import android.util.Log; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.MessageDigest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Implements the BugSplat 3-part upload flow: + *
    + *
  1. GET /api/getCrashUploadUrl — obtain a presigned S3 URL
  2. + *
  3. PUT the zipped payload to the presigned URL
  4. + *
  5. POST /api/commitS3CrashUpload — commit with metadata
  6. + *
+ */ +class ReportUploader { + private static final String TAG = "BugSplat-Upload"; + private static final int CONNECT_TIMEOUT_MS = 15_000; + private static final int READ_TIMEOUT_MS = 30_000; + + private final String database; + private final String application; + private final String version; + + ReportUploader(String database, String application, String version) { + this.database = database; + this.application = application; + this.version = version; + } + + /** Returns the base URL for API calls. Overridable for testing. */ + String getBaseUrl() { + return "https://" + database + ".bugsplat.com"; + } + + /** + * Upload a file using the 3-part S3 upload flow. + * + * @param file The file to upload + * @param crashType The crash type string (e.g. "Android ANR", "Feedback") + * @param crashTypeId The crash type ID + * @return true if the upload succeeded + */ + boolean upload(File file, String crashType, int crashTypeId) throws IOException { + byte[] zipped = createZip(file.getName(), new FileInputStream(file)); + return uploadZipped(zipped, file.getName(), crashType, crashTypeId); + } + + /** + * Upload raw bytes using the 3-part S3 upload flow. + * + * @param data The data to upload + * @param fileName The filename for the zip entry + * @param crashType The crash type string (e.g. "Android ANR", "Feedback") + * @param crashTypeId The crash type ID + * @return true if the upload succeeded + */ + boolean upload(byte[] data, String fileName, String crashType, int crashTypeId) throws IOException { + byte[] zipped = createZip(fileName, new ByteArrayInputStream(data)); + return uploadZipped(zipped, fileName, crashType, crashTypeId); + } + + private boolean uploadZipped(byte[] zipped, String fileName, String crashType, int crashTypeId) throws IOException { + String md5 = md5Hex(zipped); + + // Step 1: Get presigned upload URL + String presignedUrl = getCrashUploadUrl(zipped.length); + if (presignedUrl == null) { + Log.e(TAG, "Failed to get crash upload URL"); + return false; + } + Log.d(TAG, "Got presigned URL for " + fileName); + + // Step 2: PUT the zip to S3 + if (!uploadToPresignedUrl(presignedUrl, zipped)) { + Log.e(TAG, "Failed to upload to presigned URL"); + return false; + } + Log.d(TAG, "Uploaded " + fileName + " to S3 (" + zipped.length + " bytes)"); + + // Step 3: Commit + if (!commitUpload(presignedUrl, crashType, crashTypeId, md5)) { + Log.e(TAG, "Failed to commit upload"); + return false; + } + Log.i(TAG, "Upload committed: " + fileName + " (" + crashType + ")"); + + return true; + } + + private String getCrashUploadUrl(int size) throws IOException { + String urlStr = getBaseUrl() + "/api/getCrashUploadUrl" + + "?database=" + database + + "&appName=" + application + + "&appVersion=" + version + + "&crashPostSize=" + size; + + HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); + try { + conn.setRequestMethod("GET"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + + int status = conn.getResponseCode(); + if (status == 429) { + Log.w(TAG, "Rate limited by server"); + return null; + } + if (status != 200) { + Log.e(TAG, "getCrashUploadUrl failed (HTTP " + status + ")"); + return null; + } + + String body = readBody(conn.getInputStream()); + JSONObject json = new JSONObject(body); + return json.getString("url"); + } catch (Exception e) { + Log.e(TAG, "getCrashUploadUrl error", e); + return null; + } finally { + conn.disconnect(); + } + } + + private boolean uploadToPresignedUrl(String presignedUrl, byte[] data) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(presignedUrl).openConnection(); + try { + conn.setRequestMethod("PUT"); + conn.setDoOutput(true); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestProperty("Content-Type", "application/octet-stream"); + conn.setRequestProperty("Content-Length", String.valueOf(data.length)); + conn.setFixedLengthStreamingMode(data.length); + + try (OutputStream out = conn.getOutputStream()) { + out.write(data); + out.flush(); + } + + int status = conn.getResponseCode(); + if (status != 200) { + Log.e(TAG, "S3 PUT failed (HTTP " + status + ")"); + return false; + } + return true; + } finally { + conn.disconnect(); + } + } + + private boolean commitUpload(String s3Key, String crashType, int crashTypeId, String md5) throws IOException { + String urlStr = getBaseUrl() + "/api/commitS3CrashUpload"; + String boundary = java.util.UUID.randomUUID().toString(); + + HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); + try { + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + writeField(baos, boundary, "database", database); + writeField(baos, boundary, "appName", application); + writeField(baos, boundary, "appVersion", version); + writeField(baos, boundary, "crashType", crashType); + writeField(baos, boundary, "crashTypeId", String.valueOf(crashTypeId)); + writeField(baos, boundary, "s3key", s3Key); + writeField(baos, boundary, "md5", md5); + baos.write(("--" + boundary + "--\r\n").getBytes("UTF-8")); + + byte[] payload = baos.toByteArray(); + conn.setRequestProperty("Content-Length", String.valueOf(payload.length)); + conn.setFixedLengthStreamingMode(payload.length); + + try (OutputStream out = conn.getOutputStream()) { + out.write(payload); + out.flush(); + } + + int status = conn.getResponseCode(); + if (status >= 200 && status < 300) { + return true; + } else { + String body = readBody(status >= 400 ? conn.getErrorStream() : conn.getInputStream()); + Log.e(TAG, "commitS3CrashUpload failed (HTTP " + status + "): " + body); + return false; + } + } finally { + conn.disconnect(); + } + } + + // -- Utilities -- + + static byte[] createZip(String entryName, InputStream data) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + zos.putNextEntry(new ZipEntry(entryName)); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = data.read(buffer)) != -1) { + zos.write(buffer, 0, bytesRead); + } + zos.closeEntry(); + } + return baos.toByteArray(); + } + + static String md5Hex(byte[] data) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(data); + StringBuilder sb = new StringBuilder(32); + for (byte b : digest) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException("MD5 not available", e); + } + } + + static String readBody(InputStream stream) throws IOException { + if (stream == null) return ""; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } + } + + private static void writeField(ByteArrayOutputStream baos, String boundary, + String name, String value) throws IOException { + baos.write(("--" + boundary + "\r\n").getBytes("UTF-8")); + baos.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n").getBytes("UTF-8")); + baos.write(value.getBytes("UTF-8")); + baos.write("\r\n".getBytes("UTF-8")); + } +} diff --git a/app/src/test/java/com/bugsplat/android/FeedbackClientTest.java b/app/src/test/java/com/bugsplat/android/FeedbackClientTest.java new file mode 100644 index 0000000..5c26afa --- /dev/null +++ b/app/src/test/java/com/bugsplat/android/FeedbackClientTest.java @@ -0,0 +1,200 @@ +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.Arrays; +import java.util.Collections; +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 FeedbackClientTest { + + private MockWebServer server; + + @Before + public void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @After + public void tearDown() throws IOException { + server.shutdown(); + } + + private FeedbackClient createClient() { + ReportUploader uploader = new ReportUploader("testdb", "testapp", "1.0.0") { + @Override + String getBaseUrl() { + return server.url("").toString(); + } + }; + return new FeedbackClient("testdb", "testapp", "1.0.0", 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)); + } + + @Test + public void postFeedback_includesAllFieldsInReport() throws Exception { + enqueueSuccessfulUpload(); + + FeedbackClient client = createClient(); + boolean result = client.postFeedback("Bug Report", "App crashed on login", "alice", "alice@test.com", "key123"); + + assertTrue(result); + + // Skip step 1 (getCrashUploadUrl), inspect step 2 (S3 PUT) for the content + server.takeRequest(); // getCrashUploadUrl + RecordedRequest putRequest = server.takeRequest(); + + String content = extractZipContent(putRequest.getBody().readByteArray(), "feedback.txt"); + assertTrue("should contain title", content.contains("Title: Bug Report")); + assertTrue("should contain description", content.contains("Description: App crashed on login")); + assertTrue("should contain user", content.contains("User: alice")); + assertTrue("should contain email", content.contains("Email: alice@test.com")); + assertTrue("should contain appKey", content.contains("AppKey: key123")); + } + + @Test + public void postFeedback_omitsNullOptionalFields() throws Exception { + enqueueSuccessfulUpload(); + + FeedbackClient client = createClient(); + boolean result = client.postFeedback("Bug Report", null, null, null, null); + + assertTrue(result); + + server.takeRequest(); + RecordedRequest putRequest = server.takeRequest(); + + String content = extractZipContent(putRequest.getBody().readByteArray(), "feedback.txt"); + assertTrue("should contain title", content.contains("Title: Bug Report")); + assertFalse("should not contain Description:", content.contains("Description:")); + assertFalse("should not contain User:", content.contains("User:")); + assertFalse("should not contain Email:", content.contains("Email:")); + assertFalse("should not contain AppKey:", content.contains("AppKey:")); + } + + @Test + public void postFeedback_omitsEmptyOptionalFields() throws Exception { + enqueueSuccessfulUpload(); + + FeedbackClient client = createClient(); + boolean result = client.postFeedback("Bug Report", "", "", "", ""); + + assertTrue(result); + + server.takeRequest(); + RecordedRequest putRequest = server.takeRequest(); + + String content = extractZipContent(putRequest.getBody().readByteArray(), "feedback.txt"); + assertTrue("should contain title", content.contains("Title: Bug Report")); + assertFalse("should not contain Description:", content.contains("Description:")); + assertFalse("should not contain User:", content.contains("User:")); + assertFalse("should not contain Email:", content.contains("Email:")); + assertFalse("should not contain AppKey:", content.contains("AppKey:")); + } + + @Test + public void postFeedback_handlesNullTitle() throws Exception { + enqueueSuccessfulUpload(); + + FeedbackClient client = createClient(); + boolean result = client.postFeedback(null, "some description", null, null, null); + + assertTrue(result); + + server.takeRequest(); + RecordedRequest putRequest = server.takeRequest(); + + String content = extractZipContent(putRequest.getBody().readByteArray(), "feedback.txt"); + assertTrue("should contain empty title", content.contains("Title: \n")); + } + + @Test + public void postFeedback_commitUsesCorrectCrashType() throws Exception { + enqueueSuccessfulUpload(); + + FeedbackClient client = createClient(); + client.postFeedback("Test", null, null, null, null); + + server.takeRequest(); // getCrashUploadUrl + server.takeRequest(); // S3 PUT + RecordedRequest commitRequest = server.takeRequest(); + + String body = commitRequest.getBody().readUtf8(); + assertTrue("should use UserFeedback crash type", body.contains("UserFeedback")); + assertTrue("should use crash type id 36", body.contains("36")); + } + + @Test + public void postFeedback_returnsFalseOnUploadFailure() throws Exception { + server.enqueue(new MockResponse().setResponseCode(500)); + + FeedbackClient client = createClient(); + boolean result = client.postFeedback("Test", null, null, null, null); + + assertFalse(result); + } + + @Test + public void postFeedback_withAttachments_includesAttachmentInfo() throws Exception { + enqueueSuccessfulUpload(); + + File tempFile = File.createTempFile("test_attachment", ".txt"); + try (FileWriter writer = new FileWriter(tempFile)) { + writer.write("attachment content"); + } + + FeedbackClient client = createClient(); + boolean result = client.postFeedback("Bug", "desc", null, null, null, Collections.singletonList(tempFile)); + + assertTrue(result); + + server.takeRequest(); + RecordedRequest putRequest = server.takeRequest(); + + String content = extractZipContent(putRequest.getBody().readByteArray(), "feedback.txt"); + assertTrue("should contain attachment info", content.contains("Attachment: " + tempFile.getName())); + + tempFile.delete(); + } + + 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/app/src/test/java/com/bugsplat/android/ReportUploaderTest.java b/app/src/test/java/com/bugsplat/android/ReportUploaderTest.java new file mode 100644 index 0000000..216f5f8 --- /dev/null +++ b/app/src/test/java/com/bugsplat/android/ReportUploaderTest.java @@ -0,0 +1,331 @@ +package com.bugsplat.android; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +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 ReportUploaderTest { + + // ---- createZip tests ---- + + @Test + public void createZip_producesValidZipWithCorrectEntry() throws IOException { + String content = "hello world"; + byte[] zipped = ReportUploader.createZip( + "test.txt", + new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)) + ); + + assertNotNull(zipped); + assertTrue(zipped.length > 0); + + // Read back the zip and verify + ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipped)); + ZipEntry entry = zis.getNextEntry(); + assertNotNull("zip should contain an entry", entry); + assertEquals("test.txt", entry.getName()); + + // Verify content + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int len; + while ((len = zis.read(buffer)) != -1) { + baos.write(buffer, 0, len); + } + assertEquals(content, baos.toString("UTF-8")); + + // Should be only one entry + assertNull("zip should contain exactly one entry", zis.getNextEntry()); + zis.close(); + } + + @Test + public void createZip_handlesEmptyInput() throws IOException { + byte[] zipped = ReportUploader.createZip( + "empty.txt", + new ByteArrayInputStream(new byte[0]) + ); + + assertNotNull(zipped); + + ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipped)); + ZipEntry entry = zis.getNextEntry(); + assertNotNull(entry); + assertEquals("empty.txt", entry.getName()); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int len; + while ((len = zis.read(buffer)) != -1) { + baos.write(buffer, 0, len); + } + assertEquals(0, baos.size()); + zis.close(); + } + + @Test + public void createZip_handlesLargeInput() throws IOException { + byte[] largeContent = new byte[100_000]; + for (int i = 0; i < largeContent.length; i++) { + largeContent[i] = (byte) (i % 256); + } + + byte[] zipped = ReportUploader.createZip( + "large.bin", + new ByteArrayInputStream(largeContent) + ); + + // Zip should be smaller due to compression of repeating pattern + assertNotNull(zipped); + + ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipped)); + ZipEntry entry = zis.getNextEntry(); + assertNotNull(entry); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int len; + while ((len = zis.read(buffer)) != -1) { + baos.write(buffer, 0, len); + } + assertArrayEquals(largeContent, baos.toByteArray()); + zis.close(); + } + + // ---- md5Hex tests ---- + + @Test + public void md5Hex_returnsCorrectHashForKnownInput() { + // MD5 of "hello world" is 5eb63bbbe01eeed093cb22bb8f5acdc3 + String hash = ReportUploader.md5Hex("hello world".getBytes(StandardCharsets.UTF_8)); + assertEquals("5eb63bbbe01eeed093cb22bb8f5acdc3", hash); + } + + @Test + public void md5Hex_returnsCorrectHashForEmptyInput() { + // MD5 of empty string is d41d8cd98f00b204e9800998ecf8427e + String hash = ReportUploader.md5Hex(new byte[0]); + assertEquals("d41d8cd98f00b204e9800998ecf8427e", hash); + } + + @Test + public void md5Hex_returns32CharLowercaseHex() { + String hash = ReportUploader.md5Hex("test data".getBytes(StandardCharsets.UTF_8)); + assertEquals(32, hash.length()); + assertTrue("hash should be lowercase hex", hash.matches("[0-9a-f]+")); + } + + // ---- readBody tests ---- + + @Test + public void readBody_readsInputStream() throws IOException { + String content = "response body content"; + InputStream stream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + assertEquals(content, ReportUploader.readBody(stream)); + } + + @Test + public void readBody_returnsEmptyForNullStream() throws IOException { + assertEquals("", ReportUploader.readBody(null)); + } + + @Test + public void readBody_handlesEmptyStream() throws IOException { + InputStream stream = new ByteArrayInputStream(new byte[0]); + assertEquals("", ReportUploader.readBody(stream)); + } + + // ---- 3-step upload integration tests ---- + + private MockWebServer server; + + @Before + public void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @After + public void tearDown() throws IOException { + server.shutdown(); + } + + @Test + public void upload_performsThreeStepFlow() throws Exception { + String presignedUrl = server.url("/s3-upload").toString(); + + // Step 1: getCrashUploadUrl response + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"url\": \"" + presignedUrl + "\"}")); + + // Step 2: S3 PUT response + server.enqueue(new MockResponse().setResponseCode(200)); + + // Step 3: commitS3CrashUpload response + server.enqueue(new MockResponse().setResponseCode(200)); + + // Use a ReportUploader that points at our mock server + String baseUrl = server.url("").toString(); + // We need to extract host:port to construct the uploader + // The uploader constructs URLs like https://{database}.bugsplat.com/... + // So we'll use a TestableReportUploader subclass approach + // Actually, let's test the utility methods above and the flow separately + + // For the full flow test, we verify the 3 requests arrive in order + ReportUploader uploader = new TestableReportUploader("testdb", "testapp", "1.0.0", server); + boolean result = uploader.upload( + "test content".getBytes(StandardCharsets.UTF_8), + "test.txt", + "Android ANR", + 37 + ); + + assertTrue("upload should succeed", result); + assertEquals("should make 3 requests", 3, server.getRequestCount()); + + // Verify Step 1: getCrashUploadUrl + RecordedRequest req1 = server.takeRequest(); + assertEquals("GET", req1.getMethod()); + assertTrue(req1.getPath().contains("getCrashUploadUrl")); + assertTrue(req1.getPath().contains("database=testdb")); + assertTrue(req1.getPath().contains("appName=testapp")); + assertTrue(req1.getPath().contains("appVersion=1.0.0")); + + // Verify Step 2: S3 PUT + RecordedRequest req2 = server.takeRequest(); + assertEquals("PUT", req2.getMethod()); + assertEquals("application/octet-stream", req2.getHeader("Content-Type")); + + // Verify the body is a valid zip + byte[] putBody = req2.getBody().readByteArray(); + ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(putBody)); + ZipEntry entry = zis.getNextEntry(); + assertNotNull(entry); + assertEquals("test.txt", entry.getName()); + zis.close(); + + // Verify Step 3: commitS3CrashUpload + RecordedRequest req3 = server.takeRequest(); + assertEquals("POST", req3.getMethod()); + assertTrue(req3.getPath().contains("commitS3CrashUpload")); + String commitBody = req3.getBody().readUtf8(); + assertTrue("should contain database", commitBody.contains("testdb")); + assertTrue("should contain appName", commitBody.contains("testapp")); + assertTrue("should contain appVersion", commitBody.contains("1.0.0")); + assertTrue("should contain crashType", commitBody.contains("Android ANR")); + assertTrue("should contain crashTypeId", commitBody.contains("37")); + assertTrue("should contain md5", commitBody.contains("md5")); + } + + @Test + public void upload_returnsFalseWhenGetUrlFails() throws Exception { + server.enqueue(new MockResponse().setResponseCode(500)); + + ReportUploader uploader = new TestableReportUploader("testdb", "testapp", "1.0.0", server); + boolean result = uploader.upload( + "test".getBytes(StandardCharsets.UTF_8), + "test.txt", + "Android ANR", + 37 + ); + + assertFalse("upload should fail when getCrashUploadUrl fails", result); + assertEquals(1, server.getRequestCount()); + } + + @Test + public void upload_returnsFalseWhenRateLimited() throws Exception { + server.enqueue(new MockResponse().setResponseCode(429)); + + ReportUploader uploader = new TestableReportUploader("testdb", "testapp", "1.0.0", server); + boolean result = uploader.upload( + "test".getBytes(StandardCharsets.UTF_8), + "test.txt", + "Android ANR", + 37 + ); + + assertFalse("upload should fail when rate limited", result); + } + + @Test + public void upload_returnsFalseWhenS3PutFails() throws Exception { + String presignedUrl = server.url("/s3-upload").toString(); + + server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"url\": \"" + presignedUrl + "\"}")); + server.enqueue(new MockResponse().setResponseCode(403)); + + ReportUploader uploader = new TestableReportUploader("testdb", "testapp", "1.0.0", server); + boolean result = uploader.upload( + "test".getBytes(StandardCharsets.UTF_8), + "test.txt", + "Android ANR", + 37 + ); + + assertFalse("upload should fail when S3 PUT fails", result); + assertEquals(2, server.getRequestCount()); + } + + @Test + public void upload_returnsFalseWhenCommitFails() throws Exception { + 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(500)); + + ReportUploader uploader = new TestableReportUploader("testdb", "testapp", "1.0.0", server); + boolean result = uploader.upload( + "test".getBytes(StandardCharsets.UTF_8), + "test.txt", + "Android ANR", + 37 + ); + + assertFalse("upload should fail when commit fails", result); + assertEquals(3, server.getRequestCount()); + } + + /** + * Test subclass that routes all HTTP calls to the MockWebServer + * instead of the real BugSplat API. + */ + static class TestableReportUploader extends ReportUploader { + private final String baseUrl; + + TestableReportUploader(String database, String application, String version, MockWebServer server) { + super(database, application, version); + this.baseUrl = server.url("").toString(); + // Remove trailing slash + if (this.baseUrl.endsWith("/")) { + // baseUrl is fine as-is, we'll override the URL construction + } + } + + @Override + String getBaseUrl() { + return baseUrl; + } + } +} diff --git a/example/src/main/java/com/bugsplat/example/MainActivity.java b/example/src/main/java/com/bugsplat/example/MainActivity.java index a9b2116..2b81fc2 100644 --- a/example/src/main/java/com/bugsplat/example/MainActivity.java +++ b/example/src/main/java/com/bugsplat/example/MainActivity.java @@ -28,6 +28,7 @@ public class MainActivity extends AppCompatActivity { private static final String TAG = "BugSplatExample"; private TextView statusTextView; private Button crashButton; + private Button anrButton; private Button feedbackButton; private Button setAttributeButton; @@ -39,6 +40,7 @@ protected void onCreate(Bundle savedInstanceState) { // Initialize views statusTextView = findViewById(R.id.statusTextView); crashButton = findViewById(R.id.crashButton); + anrButton = findViewById(R.id.anrButton); feedbackButton = findViewById(R.id.feedbackButton); setAttributeButton = findViewById(R.id.setAttributeButton); @@ -68,6 +70,22 @@ public void onClick(View v) { } }); + // Set up click listener for ANR button + anrButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Log.d(TAG, "Triggering ANR by blocking main thread for ~10 seconds..."); + statusTextView.setText("Status: Blocking main thread (ANR will trigger after ~5s)..."); + // Block the main thread long enough to trigger a system ANR dialog + try { + Thread.sleep(10_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + statusTextView.setText("Status: Main thread unblocked"); + } + }); + // Set up click listener for feedback button feedbackButton.setOnClickListener(v -> showFeedbackDialog()); diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index 9e67b1c..813f9ae 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -40,11 +40,23 @@ android:layout_marginTop="32dp" android:text="@string/crash_button" style="@style/Widget.BugSplat.Button.Crash" - app:layout_constraintBottom_toTopOf="@+id/feedbackButton" + app:layout_constraintBottom_toTopOf="@+id/anrButton" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/titleTextView" /> +