diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3b1805d..823d0f0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -46,6 +46,10 @@ jobs: # Build with Gradle using the Android SDK's CMake - name: Build with Gradle + env: + # The example module requires a bugsplat.database value to configure. + # CI doesn't upload symbols, so a placeholder is fine. + BUGSPLAT_DATABASE: fred run: ./gradlew assembleDebug --no-daemon - name: Perform CodeQL Analysis diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ffaf669 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + pull_request: + branches: [ "main", "master" ] + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Install Android SDK CMake + run: | + echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "cmake;3.22.1" + echo "$ANDROID_HOME/cmake/3.22.1/bin" >> $GITHUB_PATH + + - name: Run unit tests + env: + # The example module requires a bugsplat.database value to configure. + # CI doesn't upload symbols, so a placeholder is fine. + BUGSPLAT_DATABASE: fred + run: ./gradlew :app:testDebugUnitTest --no-daemon diff --git a/README.md b/README.md index 0db5928..72509df 100644 --- a/README.md +++ b/README.md @@ -102,15 +102,65 @@ After completing these steps, you can start using BugSplat in your Android appli ### Configuration -To configure BugSplat to handle native crashes, simply call `initBugSplat` with the desired arguments. Be sure that the value you provide for `database` matches the value in the BugSplat web app. +To configure BugSplat to handle native crashes, simply call `BugSplat.init` with the desired arguments. Be sure that the value you provide for `database` matches the value in the BugSplat web app. ```kotlin -BugSplatBridge.initBugSplat(this, database, application, version) +BugSplat.init(this, database, application, version) ``` -You can also add file attributes, and/or file attachments to your crash reports. +### Loading config from local.properties (recommended) -Kotlin +Keeping your database name, app name, and version in one place avoids drift between runtime (`BugSplat.init`) and symbol upload. The pattern most BugSplat users adopt: + +1. Add the database name to the gitignored `local.properties`: + + ```properties + bugsplat.database=your_database + ``` + +2. In your app module's `build.gradle`, load it and expose it (plus `applicationId` and `versionName`) as `BuildConfig` fields: + + ```gradle + def localProps = new Properties() + def localPropsFile = rootProject.file('local.properties') + if (localPropsFile.exists()) { + localPropsFile.withInputStream { localProps.load(it) } + } + + android { + defaultConfig { + applicationId "com.example.myapp" + versionName "1.0.0" + + buildConfigField "String", "BUGSPLAT_DATABASE", + "\"${localProps.getProperty('bugsplat.database')}\"" + buildConfigField "String", "BUGSPLAT_APP_NAME", + "\"${applicationId}\"" + buildConfigField "String", "BUGSPLAT_APP_VERSION", + "\"${versionName}\"" + } + buildFeatures { buildConfig true } + } + ``` + +3. Initialize BugSplat from the generated `BuildConfig`: + + ```java + BugSplat.init( + this, + BuildConfig.BUGSPLAT_DATABASE, + BuildConfig.BUGSPLAT_APP_NAME, + BuildConfig.BUGSPLAT_APP_VERSION + ); + ``` + +See [`example/build.gradle`](example/build.gradle) for the complete working setup. This same `bugsplat.database` value is also picked up by the symbol upload task, so there's a single source of truth across the whole build. + +### Attributes and attachments + +You can also add custom attributes and/or file attachments to your crash reports. + +**Kotlin** ```kotlin val attributes = mapOf( "key1" to "value1", @@ -118,27 +168,36 @@ val attributes = mapOf( "environment" to "development" ) -val attachmentFileName = "log.txt" -createAttachmentFile(attachmentFileName) -val attachmentPath = applicationContext.getFileStreamPath(attachmentFileName).absolutePath +val attachmentPath = applicationContext.getFileStreamPath("log.txt").absolutePath val attachments = arrayOf(attachmentPath) -BugSplatBridge.initBugSplat(this, "fred", "my-android-crasher", "2.0.0", attributes, attachments) +BugSplat.init( + this, + BuildConfig.BUGSPLAT_DATABASE, + BuildConfig.BUGSPLAT_APP_NAME, + BuildConfig.BUGSPLAT_APP_VERSION, + attributes, + attachments +) ``` -Java +**Java** ```java Map attributes = new HashMap<>(); attributes.put("key1", "value1"); -attributes.put("key2", "value2"); attributes.put("environment", "development"); -String attachmentFileName = "log.txt"; -createAttachmentFile(attachmentFileName); -String attachmentPath = getApplicationContext().getFileStreamPath(attachmentFileName).getAbsolutePath(); +String attachmentPath = getApplicationContext().getFileStreamPath("log.txt").getAbsolutePath(); String[] attachments = new String[]{attachmentPath}; -BugSplatBridge.initBugSplat(this, "fred", "my-android-crasher", "2.0.0", attributes, attachments); +BugSplat.init( + this, + BuildConfig.BUGSPLAT_DATABASE, + BuildConfig.BUGSPLAT_APP_NAME, + BuildConfig.BUGSPLAT_APP_VERSION, + attributes, + attachments +); ``` ### Symbol Upload @@ -173,68 +232,56 @@ This approach requires the `symbol-upload` executable to be included in your app #### 2. Using Gradle Build Tasks -You can also add a Gradle task to your build process to automatically upload symbols when you build your app. Here's an example of how to set this up: +You can wire symbol upload into your Gradle build so it runs automatically after `assembleDebug` / `assembleRelease`. The recommended pattern is to keep credentials out of `build.gradle` by loading them from the gitignored `local.properties`. + +**Step 1 — Add credentials to `local.properties` (do not commit):** + +```properties +bugsplat.database=your_database +bugsplat.clientId=your_client_id +bugsplat.clientSecret=your_client_secret +``` + +**Step 2 — Load them in `build.gradle` and register per-ABI upload tasks:** ```gradle -// BugSplat configuration +// Load BugSplat credentials from local.properties +def localProps = new Properties() +def localPropsFile = rootProject.file('local.properties') +if (localPropsFile.exists()) { + localPropsFile.withInputStream { localProps.load(it) } +} + ext { - bugsplatDatabase = "your_database_name" // Replace with your BugSplat database name - bugsplatAppName = "your_app_name" // Replace with your application name + bugsplatDatabase = localProps.getProperty('bugsplat.database') + bugsplatClientId = localProps.getProperty('bugsplat.clientId', '') + bugsplatClientSecret = localProps.getProperty('bugsplat.clientSecret', '') + // Use applicationId and versionName as the single source of truth + bugsplatAppName = android.defaultConfig.applicationId bugsplatAppVersion = android.defaultConfig.versionName - // Optional: Add your BugSplat API credentials for symbol upload - bugsplatClientId = "" // Replace with your BugSplat API client ID (optional) - bugsplatClientSecret = "" // Replace with your BugSplat API client secret (optional) } -// Task to upload debug symbols for native libraries -task uploadBugSplatSymbols { - doLast { - // Path to the merged native libraries - def nativeLibsDir = "${buildDir}/intermediates/merged_native_libs/debug/out/lib" - - // Check if the directory exists - def nativeLibsDirFile = file(nativeLibsDir) - if (!nativeLibsDirFile.exists()) { - logger.warn("Native libraries directory not found: ${nativeLibsDir}") - return - } - - // Path to the symbol-upload executable - def symbolUploadPath = "path/to/symbol-upload" // Adjust this path - - // Build the command with the directory and glob pattern - def command = [ - symbolUploadPath, - "-b", project.ext.bugsplatDatabase, - "-a", project.ext.bugsplatAppName, - "-v", project.ext.bugsplatAppVersion, - "-d", nativeLibsDirFile.absolutePath, - "-f", "**/*.so", - "-m" // Run dumpsyms - ] - - // Add client credentials if provided - if (project.ext.has('bugsplatClientId') && project.ext.bugsplatClientId) { - command.add("-i") - command.add(project.ext.bugsplatClientId) - command.add("-s") - command.add(project.ext.bugsplatClientSecret) - } - - // Execute the command - // ... (see example app for full implementation) - } -} +// See example/build.gradle for the full implementation including: +// - resolveSymbolUploadExecutable() - downloads the symbol-upload binary +// - uploadSymbolsForAbi(buildType, abi) - runs symbol-upload against +// build/intermediates/merged_native_libs//mergeNativeLibs/out/lib// +// - Per-ABI tasks (uploadBugSplatSymbolsDebugArm64-v8a, etc.) +// - AllAbis task that chains the per-ABI tasks serially via mustRunAfter +// (parallel uploads aren't safe — the symbol-upload binary uses a shared temp dir) -// Run the symbol upload task after the assembleDebug task +// Run symbol upload after assembleDebug tasks.whenTaskAdded { task -> if (task.name == 'assembleDebug') { - task.finalizedBy(uploadBugSplatSymbols) + task.finalizedBy(tasks.named('uploadBugSplatSymbolsDebugAllAbis')) } } ``` -See the [Example App README](example/README.md) for a complete implementation of this approach. +See [`example/build.gradle`](example/build.gradle) for the complete, working implementation. Key details: + +- **Intermediate path** — AGP 8.6+ places merged native libs at `merged_native_libs//mergeNativeLibs/out/lib//`. Older AGPs used a flat `merged_native_libs//out/lib//` layout. +- **Serial execution** — the `symbol-upload` binary uses a shared temp directory, so per-ABI uploads must be chained via `mustRunAfter` rather than running in parallel. +- **Missing ABIs** — when Android Studio runs on a single-ABI device (e.g. an arm64 emulator), only that ABI's libs get built. Per-ABI tasks for other ABIs will log a warning and skip cleanly. #### 3. Using the Command-Line Tool @@ -327,20 +374,54 @@ 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. +## 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. + +### How It Works + +When the system kills your app due to an ANR, the event is recorded by Android. On the next app launch, the SDK queries `ActivityManager.getHistoricalProcessExitReasons()` for new ANRs, reads the system-provided thread dump, and uploads it to BugSplat. ANR reports appear alongside crashes with the **"Android.ANR"** type. + +The thread dump includes: +- Full Java stack traces for all threads in the process +- Native stack frames with BuildIds (symbolicated against uploaded `.sym` files) +- Lock contention information (which threads are holding/waiting for locks) + +### Configuration + +ANR detection is enabled automatically when you call `BugSplat.init()` — no additional configuration needed. The SDK persists the timestamp of the last reported ANR in `SharedPreferences` to avoid duplicate uploads across launches. + +### Testing ANR Detection + +To test ANR detection, use `BugSplat.hang()` to block the main thread in a native infinite loop: + +```java +// Call this on the main thread to trigger an ANR +BugSplat.hang(); +``` + +After calling `BugSplat.hang()`, tap the screen to generate a pending input event — the system will show an ANR dialog after ~5 seconds. Choose "Close app" to kill the process. On the next app launch, the SDK will upload the ANR report to BugSplat. + +The resulting thread dump includes a native frame for `jniHang`, which demonstrates end-to-end symbolication when your `.sym` files have been uploaded. + +### Supported Versions + +ANR detection requires **Android 11+ (API 30+)**. On older Android versions, the `ApplicationExitInfo` API is unavailable and ANR detection is silently disabled. + ## User Feedback 💬 BugSplat supports collecting non-crashing user feedback such as bug reports and feature requests. Feedback reports appear in BugSplat alongside crash reports with the "User Feedback" type. ### Posting Feedback -Use `BugSplat.postFeedback` to submit feedback asynchronously, or `BugSplat.postFeedbackBlocking` for synchronous submission: +Use `BugSplat.postFeedback` to submit feedback asynchronously, or `BugSplat.postFeedbackBlocking` for synchronous submission. The `database`, `application`, and `version` values are typically loaded from `BuildConfig` (see [Loading config from local.properties](#loading-config-from-localproperties-recommended)): ```java // Async (returns immediately, runs on background thread) BugSplat.postFeedback( - "fred", // database - "my-android-crasher", // application - "1.0.0", // version + BuildConfig.BUGSPLAT_DATABASE, + BuildConfig.BUGSPLAT_APP_NAME, + BuildConfig.BUGSPLAT_APP_VERSION, "Login button broken", // title (required) "Nothing happens on tap", // description "Jane", // user @@ -350,7 +431,9 @@ BugSplat.postFeedback( // Blocking (returns true on success) boolean success = BugSplat.postFeedbackBlocking( - "fred", "my-android-crasher", "1.0.0", + BuildConfig.BUGSPLAT_DATABASE, + BuildConfig.BUGSPLAT_APP_NAME, + BuildConfig.BUGSPLAT_APP_VERSION, "Login button broken", "Nothing happens on tap", "Jane", "jane@example.com", null ); @@ -366,13 +449,35 @@ attachments.add(new File(getFilesDir(), "screenshot.png")); attachments.add(new File(getFilesDir(), "app.log")); BugSplat.postFeedback( - "fred", "my-android-crasher", "1.0.0", + BuildConfig.BUGSPLAT_DATABASE, + BuildConfig.BUGSPLAT_APP_NAME, + BuildConfig.BUGSPLAT_APP_VERSION, "Login button broken", "Nothing happens on tap", "Jane", "jane@example.com", null, attachments ); ``` +### Custom Attributes + +Attach arbitrary key/value metadata to feedback reports: + +```java +Map attributes = new HashMap<>(); +attributes.put("environment", "production"); +attributes.put("user_tier", "premium"); + +BugSplat.postFeedback( + BuildConfig.BUGSPLAT_DATABASE, + BuildConfig.BUGSPLAT_APP_NAME, + BuildConfig.BUGSPLAT_APP_VERSION, + "Login button broken", "Nothing happens on tap", + "Jane", "jane@example.com", null, + null, // attachments + attributes +); +``` + ### Example Feedback Dialog The example app includes a simple feedback dialog using Android's `AlertDialog`. See [`MainActivity.java`](example/src/main/java/com/bugsplat/example/MainActivity.java) for the implementation. The dialog collects a subject and optional description, then posts feedback using `BugSplat.postFeedbackBlocking` on a background thread. @@ -392,7 +497,9 @@ To run the example app: The example app demonstrates: - Automatically initializing the BugSplat SDK at app startup - Triggering a crash for testing purposes +- Triggering an ANR (via `BugSplat.hang()`) to test ANR detection and native frame symbolication - Submitting user feedback via a dialog +- Setting custom attributes via a dialog - Handling errors during initialization For more information, see the [Example App README](example/README.md). 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/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp index e018305..25f0a0d 100644 --- a/app/src/main/cpp/native-lib.cpp +++ b/app/src/main/cpp/native-lib.cpp @@ -56,6 +56,9 @@ Java_com_bugsplat_android_BugSplatBridge_jniInitBugSplat(JNIEnv *env, jclass cla extern "C" JNIEXPORT void JNICALL Java_com_bugsplat_android_BugSplatBridge_jniCrash(JNIEnv *env, jclass clazz); +extern "C" JNIEXPORT void JNICALL +Java_com_bugsplat_android_BugSplatBridge_jniHang(JNIEnv *env, jclass clazz); + extern "C" JNIEXPORT void JNICALL Java_com_bugsplat_android_BugSplatBridge_jniSetAttribute(JNIEnv *env, jclass clazz, jstring key, jstring value); @@ -146,6 +149,15 @@ Java_com_bugsplat_android_BugSplatBridge_jniCrash(JNIEnv *env, jclass clazz) *a = 1; } +extern "C" JNIEXPORT void JNICALL +Java_com_bugsplat_android_BugSplatBridge_jniHang(JNIEnv *env, jclass clazz) +{ + volatile int counter = 0; + while (true) { + counter++; + } +} + // Utility function implementations void createAttributes(JNIEnv *env, jobject attributes_map) { if (attributes_map == nullptr || g_annotations == nullptr) { 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..5728a71 --- /dev/null +++ b/app/src/main/java/com/bugsplat/android/AnrReporter.java @@ -0,0 +1,188 @@ +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.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +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); + } + }); + // This reporter is one-shot — there will never be more work submitted + // after the initial check. shutdown() lets the worker thread exit once + // the submitted task completes instead of lingering as a daemon. + executor.shutdown(); + } + + 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); + + // getHistoricalProcessExitReasons returns newest-first. Reverse to + // oldest-first so we can advance the watermark in chronological order + // and stop on the first upload failure (leaving newer ANRs for the + // next launch). This prevents a successful newer upload from masking + // a failed older one. + List orderedOldestFirst = new ArrayList<>(exitInfos); + Collections.reverse(orderedOldestFirst); + + ReportUploader uploader = new ReportUploader(database, application, version); + long advanceTo = lastReportedTimestamp; + + for (ApplicationExitInfo exitInfo : orderedOldestFirst) { + if (exitInfo.getReason() != ApplicationExitInfo.REASON_ANR) { + continue; + } + + long timestamp = exitInfo.getTimestamp(); + if (timestamp <= lastReportedTimestamp) { + continue; // already reported in a prior session + } + + String threadDump = readTraceStream(exitInfo); + if (threadDump == null || threadDump.isEmpty()) { + // Some ANR records have no trace (known Android quirk). + // Advance past them so we don't retry forever. + Log.w(TAG, "Empty trace stream for ANR at " + timestamp + ", skipping"); + advanceTo = Math.max(advanceTo, 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() + ")"); + + boolean uploaded = false; + try { + byte[] zipped = ReportUploader.zip( + "anr_trace.txt", + threadDump.getBytes(StandardCharsets.UTF_8)); + CommitOptions options = new CommitOptions() + .crashType(CRASH_TYPE) + .crashTypeId(CRASH_TYPE_ID); + uploaded = uploader.upload(zipped, options); + } catch (IOException e) { + Log.e(TAG, "Failed to upload ANR report", e); + } + + if (uploaded) { + advanceTo = Math.max(advanceTo, timestamp); + } else { + // Transient failure — stop so the remaining newer ANRs + // are retried next launch. + break; + } + } + + if (advanceTo > lastReportedTimestamp) { + prefs.edit() + .putLong(PREF_LAST_REPORTED_TIMESTAMP, advanceTo) + .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 new String(baos.toByteArray(), StandardCharsets.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/BugSplat.java b/app/src/main/java/com/bugsplat/android/BugSplat.java index 7dc6708..6ec47b6 100644 --- a/app/src/main/java/com/bugsplat/android/BugSplat.java +++ b/app/src/main/java/com/bugsplat/android/BugSplat.java @@ -71,6 +71,16 @@ public static void crash() { BugSplatBridge.crash(); } + /** + * Hang the calling thread indefinitely in a native infinite loop. + * Intended for testing ANR detection — call this from the main thread + * to trigger a system ANR with several symbolicated native frames in + * the resulting thread dump. + */ + public static void hang() { + BugSplatBridge.hang(); + } + /** * Set a custom attribute that will be included in crash reports. * This can be called at any time after init, and the value will be @@ -203,9 +213,31 @@ public static void postFeedback(String database, String application, String vers public static void postFeedback(String database, String application, String version, String title, String description, String user, String email, String appKey, List attachments) { + postFeedback(database, application, version, title, description, user, email, appKey, attachments, null); + } + + /** + * Post user feedback to BugSplat with file attachments and custom attributes. + * This runs on a background thread and returns immediately. + * + * @param database The BugSplat database name + * @param application The application name + * @param version The application version + * @param title The feedback title (becomes the stack key for grouping) + * @param description Additional feedback context + * @param user The user's name or id + * @param email The user's email + * @param appKey The application key for authentication + * @param attachments List of files to attach to the feedback report, or null for none + * @param attributes Custom key/value attributes to associate with the feedback, or null for none + */ + public static void postFeedback(String database, String application, String version, + String title, String description, String user, String email, + String appKey, List attachments, + Map attributes) { new Thread(() -> { FeedbackClient client = new FeedbackClient(database, application, version); - client.postFeedback(title, description, user, email, appKey, attachments); + client.postFeedback(title, description, user, email, appKey, attachments, attributes); }).start(); } @@ -247,7 +279,30 @@ public static boolean postFeedbackBlocking(String database, String application, public static boolean postFeedbackBlocking(String database, String application, String version, String title, String description, String user, String email, String appKey, List attachments) { + return postFeedbackBlocking(database, application, version, title, description, user, email, appKey, attachments, null); + } + + /** + * Post user feedback to BugSplat with file attachments and custom attributes. + * This blocks until the upload is complete. + * + * @param database The BugSplat database name + * @param application The application name + * @param version The application version + * @param title The feedback title (becomes the stack key for grouping) + * @param description Additional feedback context + * @param user The user's name or id + * @param email The user's email + * @param appKey The application key for authentication + * @param attachments List of files to attach to the feedback report, or null for none + * @param attributes Custom key/value attributes to associate with the feedback, or null for none + * @return true if feedback was posted successfully + */ + public static boolean postFeedbackBlocking(String database, String application, String version, + String title, String description, String user, String email, + String appKey, List attachments, + Map attributes) { FeedbackClient client = new FeedbackClient(database, application, version); - return client.postFeedback(title, description, user, email, appKey, attachments); + return client.postFeedback(title, description, user, email, appKey, attachments, attributes); } } \ No newline at end of file diff --git a/app/src/main/java/com/bugsplat/android/BugSplatBridge.java b/app/src/main/java/com/bugsplat/android/BugSplatBridge.java index 63dccf7..a0f96d7 100644 --- a/app/src/main/java/com/bugsplat/android/BugSplatBridge.java +++ b/app/src/main/java/com/bugsplat/android/BugSplatBridge.java @@ -35,12 +35,24 @@ 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() { jniCrash(); } + /** + * Hang the calling thread in a native infinite loop. Intended for testing + * ANR detection and symbolication of native frames. + */ + public static void hang() { + jniHang(); + } + public static void setAttribute(String key, String value) { validateAttributeKey(key); if (value == null) { @@ -66,6 +78,8 @@ static native boolean jniInitBugSplat(String dataDir, String libDir, String data static native void jniCrash(); + static native void jniHang(); + static native void jniSetAttribute(String key, String value); static native void jniRemoveAttribute(String key); diff --git a/app/src/main/java/com/bugsplat/android/CommitOptions.java b/app/src/main/java/com/bugsplat/android/CommitOptions.java new file mode 100644 index 0000000..5fe17f3 --- /dev/null +++ b/app/src/main/java/com/bugsplat/android/CommitOptions.java @@ -0,0 +1,59 @@ +package com.bugsplat.android; + +import org.json.JSONObject; + +import java.util.Map; + +/** + * Typed, optional fields for the {@code commitS3CrashUpload} request. + * + * Mirrors the documented BugSplat commit API 1-to-1: + * https://docs.bugsplat.com/introduction/development/web-services/crash#request-body-multipart-form-data + * + * All fields are optional. Use the fluent setters to populate only the fields + * you need. {@code appName}, {@code appVersion}, {@code s3Key}, {@code md5}, + * and {@code database} are set by {@link ReportUploader} from constructor + * state and the upload flow, so they are not present here. + */ +class CommitOptions { + String crashType; + Integer crashTypeId; + Integer fullDumpFlag; + String appKey; + String description; + String user; + String email; + String internalIP; + String notes; + String processor; + String crashTime; + Map attributes; + String crashHash; + + CommitOptions() {} + + CommitOptions crashType(String v) { this.crashType = v; return this; } + CommitOptions crashTypeId(int v) { this.crashTypeId = v; return this; } + CommitOptions fullDumpFlag(int v) { this.fullDumpFlag = v; return this; } + CommitOptions appKey(String v) { this.appKey = v; return this; } + CommitOptions description(String v) { this.description = v; return this; } + CommitOptions user(String v) { this.user = v; return this; } + CommitOptions email(String v) { this.email = v; return this; } + CommitOptions internalIP(String v) { this.internalIP = v; return this; } + CommitOptions notes(String v) { this.notes = v; return this; } + CommitOptions processor(String v) { this.processor = v; return this; } + CommitOptions crashTime(String v) { this.crashTime = v; return this; } + CommitOptions attributes(Map v) { this.attributes = v; return this; } + CommitOptions crashHash(String v) { this.crashHash = v; return this; } + + /** + * Return the attributes as a JSON string (the format the commit endpoint + * expects), or {@code null} if no attributes are set. + */ + String attributesJson() { + if (attributes == null || attributes.isEmpty()) { + return null; + } + return new JSONObject(attributes).toString(); + } +} diff --git a/app/src/main/java/com/bugsplat/android/FeedbackClient.java b/app/src/main/java/com/bugsplat/android/FeedbackClient.java index ed760bb..1b0dcca 100644 --- a/app/src/main/java/com/bugsplat/android/FeedbackClient.java +++ b/app/src/main/java/com/bugsplat/android/FeedbackClient.java @@ -2,130 +2,125 @@ import android.util.Log; +import org.json.JSONException; +import org.json.JSONObject; + 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; - +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. + * + * The feedback body is a JSON document ({@code feedback.json}) containing + * {@code title} and (optionally) {@code description}. Metadata like + * {@code user}, {@code email}, and {@code appKey} are attached on the + * {@code commitS3CrashUpload} request — not baked into the JSON body. + * + * See + * BugSplat User Feedback docs. + */ class FeedbackClient { private static final String TAG = "BugSplat"; + private static final String CRASH_TYPE = "User.Feedback"; + 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 = database; - this.application = application; - this.version = version; + this(database, application, version, new ReportUploader(database, application, version)); } - boolean postFeedback(String title, String description, String user, String email, String appKey) { - return postFeedback(title, description, user, email, appKey, null); + /** Package-private constructor for testing with a custom uploader. */ + FeedbackClient(String database, String application, String version, ReportUploader uploader) { + this.uploader = uploader; } - 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); - } - - // 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)"); - } - } - - 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(); - } + boolean postFeedback(String title, String description, String user, String email, String appKey) { + return postFeedback(title, description, user, email, appKey, null, null); + } - int status = conn.getResponseCode(); + boolean postFeedback(String title, String description, String user, String email, String appKey, + List attachments) { + return postFeedback(title, description, user, email, appKey, attachments, null); + } - 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 postFeedback(String title, String description, String user, String email, String appKey, + List attachments, Map attributes) { + try { + // feedback.json — per the User Feedback API, only title (required) + // and description (optional) live in the JSON; the rest are + // commit-request fields below. + JSONObject json = new JSONObject(); + json.put("title", title != null ? title : ""); + if (description != null && !description.isEmpty()) { + json.put("description", description); + } + byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8); + + byte[] zipped = buildZip(jsonBytes, attachments); + + CommitOptions options = new CommitOptions() + .crashType(CRASH_TYPE) + .crashTypeId(CRASH_TYPE_ID) + .user(user) + .email(email) + .description(description) + .appKey(appKey) + .attributes(attributes); + + boolean success = uploader.upload(zipped, options); + if (success) { + Log.i(TAG, "Feedback posted successfully"); + } else { + Log.e(TAG, "Failed to post feedback"); } + return success; - } catch (Exception e) { + } catch (JSONException | IOException 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); + /** + * 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(); + } } } - dos.write("\r\n".getBytes(StandardCharsets.UTF_8)); + 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 new file mode 100644 index 0000000..114f3d6 --- /dev/null +++ b/app/src/main/java/com/bugsplat/android/ReportUploader.java @@ -0,0 +1,281 @@ +package com.bugsplat.android; + +import android.util.Log; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +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.nio.charset.StandardCharsets; +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 (see {@link CommitOptions})
  6. + *
+ * + * Callers are responsible for producing the zip. Use {@link #zip(String, byte[])} + * for the common single-entry case, or build a {@link ZipOutputStream} inline + * (e.g. to stream file attachments without reading them fully into memory). + * + * https://docs.bugsplat.com/introduction/development/web-services/crash + */ +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 pre-built zip using the 3-part S3 upload flow. + * + * @param zipped The zipped payload bytes to upload + * @param options Commit-request fields (crashType, user, email, attributes, etc.) + * @return true if all three steps succeeded + */ + boolean upload(byte[] zipped, CommitOptions options) throws IOException { + if (zipped == null || zipped.length == 0) { + throw new IllegalArgumentException("zipped payload must not be empty"); + } + 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; + } + + // 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 " + zipped.length + " bytes to S3"); + + // Step 3: Commit + if (!commitUpload(presignedUrl, md5, options)) { + Log.e(TAG, "Failed to commit upload"); + return false; + } + Log.i(TAG, "Upload committed" + + (options != null && options.crashType != null ? " (" + options.crashType + ")" : "")); + return true; + } + + private String getCrashUploadUrl(int size) throws IOException { + String urlStr = getBaseUrl() + "/api/getCrashUploadUrl" + + "?database=" + urlEncode(database) + + "&appName=" + urlEncode(application) + + "&appVersion=" + urlEncode(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 || status >= 300) { + 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 || status >= 300) { + Log.e(TAG, "S3 PUT failed (HTTP " + status + ")"); + return false; + } + return true; + } finally { + conn.disconnect(); + } + } + + /** + * Build the multipart/form-data body for commitS3CrashUpload. Field names + * mirror the documented BugSplat API 1-to-1: + * https://docs.bugsplat.com/introduction/development/web-services/crash#request-body-multipart-form-data + */ + private boolean commitUpload(String s3Key, String md5, CommitOptions options) 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(); + // Fields always set by the uploader itself + writeField(baos, boundary, "database", database); + writeField(baos, boundary, "appName", application); + writeField(baos, boundary, "appVersion", version); + writeField(baos, boundary, "s3Key", s3Key); + writeField(baos, boundary, "md5", md5); + + // Optional fields from CommitOptions — mirrors the documented + // commitS3CrashUpload multipart body 1-to-1. + if (options != null) { + writeOptionalField(baos, boundary, "crashType", options.crashType); + writeOptionalField(baos, boundary, "crashTypeId", + options.crashTypeId != null ? options.crashTypeId.toString() : null); + writeOptionalField(baos, boundary, "fullDumpFlag", + options.fullDumpFlag != null ? options.fullDumpFlag.toString() : null); + writeOptionalField(baos, boundary, "appKey", options.appKey); + writeOptionalField(baos, boundary, "description", options.description); + writeOptionalField(baos, boundary, "user", options.user); + writeOptionalField(baos, boundary, "email", options.email); + writeOptionalField(baos, boundary, "internalIP", options.internalIP); + writeOptionalField(baos, boundary, "notes", options.notes); + writeOptionalField(baos, boundary, "processor", options.processor); + writeOptionalField(baos, boundary, "crashTime", options.crashTime); + writeOptionalField(baos, boundary, "attributes", options.attributesJson()); + writeOptionalField(baos, boundary, "crashHash", options.crashHash); + } + baos.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.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 -- + + /** Convenience: build a single-entry zip around {@code data}. */ + static byte[] zip(String entryName, byte[] data) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + zos.putNextEntry(new ZipEntry(entryName)); + zos.write(data); + 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, StandardCharsets.UTF_8))) { + 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(StandardCharsets.UTF_8)); + baos.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + baos.write(value.getBytes(StandardCharsets.UTF_8)); + baos.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + private static void writeOptionalField(ByteArrayOutputStream baos, String boundary, + String name, String value) throws IOException { + if (value != null && !value.isEmpty()) { + writeField(baos, boundary, name, value); + } + } + + private static String urlEncode(String value) { + try { + return java.net.URLEncoder.encode(value, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new AssertionError(e); // UTF-8 always supported + } + } +} 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..75febbd --- /dev/null +++ b/app/src/test/java/com/bugsplat/android/FeedbackClientTest.java @@ -0,0 +1,238 @@ +package com.bugsplat.android; + +import org.json.JSONObject; +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.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() { + String url = server.url("").toString(); + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + }; + 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 feedbackBodyIsJson_withTitleAndDescription() throws Exception { + enqueueSuccessfulUpload(); + + FeedbackClient client = createClient(); + boolean result = client.postFeedback("Bug Report", "App crashed on login", null, null, null); + + assertTrue(result); + + server.takeRequest(); // getCrashUploadUrl + RecordedRequest putRequest = server.takeRequest(); + + String json = extractZipContent(putRequest.getBody().readByteArray(), "feedback.json"); + JSONObject parsed = new JSONObject(json); + assertEquals("Bug Report", parsed.getString("title")); + assertEquals("App crashed on login", parsed.getString("description")); + } + + @Test + public void feedbackBody_omitsDescriptionWhenNullOrEmpty() throws Exception { + enqueueSuccessfulUpload(); + + FeedbackClient client = createClient(); + client.postFeedback("Bug Report", null, null, null, null); + + server.takeRequest(); + RecordedRequest putRequest = server.takeRequest(); + + String json = extractZipContent(putRequest.getBody().readByteArray(), "feedback.json"); + JSONObject parsed = new JSONObject(json); + assertEquals("Bug Report", parsed.getString("title")); + assertFalse("should not include description when null", parsed.has("description")); + } + + @Test + public void feedbackBody_handlesNullTitleAsEmptyString() throws Exception { + enqueueSuccessfulUpload(); + + FeedbackClient client = createClient(); + client.postFeedback(null, "desc", null, null, null); + + server.takeRequest(); + RecordedRequest putRequest = server.takeRequest(); + + String json = extractZipContent(putRequest.getBody().readByteArray(), "feedback.json"); + JSONObject parsed = new JSONObject(json); + assertEquals("", parsed.getString("title")); + } + + @Test + public void commitRequest_usesUserDotFeedbackCrashType() 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 User.Feedback crash type", body.contains("User.Feedback")); + assertTrue("should use crash type id 36", body.contains("36")); + } + + @Test + public void commitRequest_includesOptionalUserEmailAppKey() throws Exception { + enqueueSuccessfulUpload(); + + FeedbackClient client = createClient(); + client.postFeedback("Title", "some desc", "alice", "alice@test.com", "key123"); + + server.takeRequest(); + server.takeRequest(); + RecordedRequest commitRequest = server.takeRequest(); + + String body = commitRequest.getBody().readUtf8(); + assertTrue("commit should include user", body.contains("alice")); + assertTrue("commit should include email", body.contains("alice@test.com")); + assertTrue("commit should include appKey", body.contains("key123")); + // description is mirrored on the commit (per User Feedback API docs) + assertTrue("commit should include description", body.contains("some desc")); + } + + @Test + public void commitRequest_includesAttributesAsJsonString() throws Exception { + enqueueSuccessfulUpload(); + + java.util.Map attributes = new java.util.LinkedHashMap<>(); + attributes.put("env", "prod"); + attributes.put("tier", "premium"); + + FeedbackClient client = createClient(); + client.postFeedback("Title", null, null, null, null, null, attributes); + + server.takeRequest(); + server.takeRequest(); + RecordedRequest commitRequest = server.takeRequest(); + + String body = commitRequest.getBody().readUtf8(); + // The attributes field is a JSON string (per the commit API docs). + assertTrue("commit should include attributes field", body.contains("name=\"attributes\"")); + assertTrue("commit attributes should be JSON-encoded", body.contains("\"env\":\"prod\"")); + assertTrue("commit attributes should include tier", body.contains("\"tier\":\"premium\"")); + } + + @Test + public void commitRequest_omitsOptionalFieldsWhenNullOrEmpty() throws Exception { + enqueueSuccessfulUpload(); + + FeedbackClient client = createClient(); + client.postFeedback("Title", null, null, null, null); + + server.takeRequest(); + server.takeRequest(); + RecordedRequest commitRequest = server.takeRequest(); + + String body = commitRequest.getBody().readUtf8(); + assertFalse("should not include user field", body.contains("name=\"user\"")); + assertFalse("should not include email field", body.contains("name=\"email\"")); + assertFalse("should not include appKey field", body.contains("name=\"appKey\"")); + assertFalse("should not include attributes field", body.contains("name=\"attributes\"")); + } + + @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_uploadsAttachmentAsZipEntry() 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(); + + byte[] zipData = putRequest.getBody().readByteArray(); + String attachmentContent = extractZipContent(zipData, tempFile.getName()); + assertEquals("attachment content", attachmentContent); + + // feedback.json should still be valid JSON with just title/description + String json = extractZipContent(zipData, "feedback.json"); + JSONObject parsed = new JSONObject(json); + assertEquals("Bug", parsed.getString("title")); + + 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..3a67e95 --- /dev/null +++ b/app/src/test/java/com/bugsplat/android/ReportUploaderTest.java @@ -0,0 +1,281 @@ +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.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 ReportUploaderTest { + + // ---- zip tests ---- + + @Test + public void zip_producesValidSingleEntryZip() throws IOException { + String content = "hello world"; + byte[] zipped = ReportUploader.zip("test.txt", content.getBytes(StandardCharsets.UTF_8)); + + assertNotNull(zipped); + assertTrue(zipped.length > 0); + + ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipped)); + ZipEntry entry = zis.getNextEntry(); + assertNotNull(entry); + assertEquals("test.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(content, new String(baos.toByteArray(), StandardCharsets.UTF_8)); + + assertNull(zis.getNextEntry()); + zis.close(); + } + + @Test + public void zip_handlesEmptyInput() throws IOException { + byte[] zipped = ReportUploader.zip("empty.txt", new byte[0]); + + ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipped)); + ZipEntry entry = zis.getNextEntry(); + assertNotNull(entry); + assertEquals("empty.txt", entry.getName()); + zis.close(); + } + + // ---- md5Hex tests ---- + + @Test + public void md5Hex_returnsCorrectHashForKnownInput() { + String hash = ReportUploader.md5Hex("hello world".getBytes(StandardCharsets.UTF_8)); + assertEquals("5eb63bbbe01eeed093cb22bb8f5acdc3", hash); + } + + @Test + public void md5Hex_returnsCorrectHashForEmptyInput() { + assertEquals("d41d8cd98f00b204e9800998ecf8427e", ReportUploader.md5Hex(new byte[0])); + } + + @Test + public void md5Hex_returns32CharLowercaseHex() { + String hash = ReportUploader.md5Hex("test data".getBytes(StandardCharsets.UTF_8)); + assertEquals(32, hash.length()); + assertTrue(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 { + assertEquals("", ReportUploader.readBody(new ByteArrayInputStream(new byte[0]))); + } + + // ---- 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(); + } + + private CommitOptions anrOptions() { + return new CommitOptions().crashType("Android.ANR").crashTypeId(37); + } + + private byte[] sampleZip() throws IOException { + return ReportUploader.zip("test.txt", "test content".getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void upload_performsThreeStepFlow() 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(200)); + + ReportUploader uploader = new TestableReportUploader("testdb", "testapp", "1.0.0", server); + boolean result = uploader.upload(sampleZip(), anrOptions()); + + assertTrue("upload should succeed", result); + assertEquals(3, server.getRequestCount()); + + // Step 1 + 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")); + + // Step 2 + RecordedRequest req2 = server.takeRequest(); + assertEquals("PUT", req2.getMethod()); + assertEquals("application/octet-stream", req2.getHeader("Content-Type")); + + // Step 3 + RecordedRequest req3 = server.takeRequest(); + assertEquals("POST", req3.getMethod()); + assertTrue(req3.getPath().contains("commitS3CrashUpload")); + String commitBody = req3.getBody().readUtf8(); + assertTrue(commitBody.contains("testdb")); + assertTrue(commitBody.contains("testapp")); + assertTrue(commitBody.contains("1.0.0")); + assertTrue(commitBody.contains("Android.ANR")); + assertTrue(commitBody.contains("37")); + assertTrue("should use s3Key (capital K)", commitBody.contains("name=\"s3Key\"")); + assertTrue(commitBody.contains("name=\"md5\"")); + } + + @Test + public void upload_returnsFalseWhenGetUrlFails() throws Exception { + server.enqueue(new MockResponse().setResponseCode(500)); + ReportUploader uploader = new TestableReportUploader("testdb", "testapp", "1.0.0", server); + assertFalse(uploader.upload(sampleZip(), anrOptions())); + 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); + assertFalse(uploader.upload(sampleZip(), anrOptions())); + } + + @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); + assertFalse(uploader.upload(sampleZip(), anrOptions())); + 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); + assertFalse(uploader.upload(sampleZip(), anrOptions())); + assertEquals(3, server.getRequestCount()); + } + + @Test + public void upload_includesAllCommitOptionsFields() 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(200)); + + Map attrs = new LinkedHashMap<>(); + attrs.put("env", "prod"); + + CommitOptions options = new CommitOptions() + .crashType("User.Feedback") + .crashTypeId(36) + .user("alice") + .email("alice@test.com") + .appKey("key123") + .description("bug desc") + .notes("build 42") + .attributes(attrs); + + ReportUploader uploader = new TestableReportUploader("testdb", "testapp", "1.0.0", server); + assertTrue(uploader.upload(sampleZip(), options)); + + server.takeRequest(); + server.takeRequest(); + RecordedRequest commitRequest = server.takeRequest(); + String body = commitRequest.getBody().readUtf8(); + + assertTrue(body.contains("name=\"user\"")); + assertTrue(body.contains("alice")); + assertTrue(body.contains("name=\"email\"")); + assertTrue(body.contains("alice@test.com")); + assertTrue(body.contains("name=\"appKey\"")); + assertTrue(body.contains("key123")); + assertTrue(body.contains("name=\"description\"")); + assertTrue(body.contains("bug desc")); + assertTrue(body.contains("name=\"notes\"")); + assertTrue(body.contains("build 42")); + assertTrue(body.contains("name=\"attributes\"")); + assertTrue("attributes should be JSON-encoded", body.contains("\"env\":\"prod\"")); + } + + @Test + public void upload_rejectsEmptyPayload() throws Exception { + ReportUploader uploader = new TestableReportUploader("testdb", "testapp", "1.0.0", server); + try { + uploader.upload(new byte[0], anrOptions()); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + // expected + } + } + + /** + * Test subclass that routes all HTTP calls to the MockWebServer. + */ + static class TestableReportUploader extends ReportUploader { + private final String baseUrl; + + TestableReportUploader(String database, String application, String version, MockWebServer server) { + super(database, application, version); + // MockWebServer's server.url("").toString() ends with a "/"; strip + // it so URL joining in ReportUploader produces clean paths. + String url = server.url("").toString(); + this.baseUrl = url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + @Override + String getBaseUrl() { + return baseUrl; + } + } +} diff --git a/example/build.gradle b/example/build.gradle index 131bc80..0dfc7f7 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -2,14 +2,37 @@ plugins { id 'com.android.application' } -// BugSplat configuration - Define at the top level so it can be used throughout the build file +// Load BugSplat credentials from local.properties (gitignored). +// Required for symbol upload - add the following to local.properties: +// bugsplat.database=yourdb +// bugsplat.clientId=yourClientId +// bugsplat.clientSecret=yourClientSecret +def localProps = new Properties() +def localPropsFile = rootProject.file('local.properties') +if (localPropsFile.exists()) { + localPropsFile.withInputStream { localProps.load(it) } +} + +// Resolution order: local.properties → -Pbugsplat.database=... → BUGSPLAT_DATABASE env var +def bugsplatDb = localProps.getProperty('bugsplat.database') ?: + project.findProperty('bugsplat.database') ?: + System.getenv('BUGSPLAT_DATABASE') +if (!bugsplatDb) { + throw new GradleException( + "bugsplat.database is not configured. Set it in one of:\n" + + " - local.properties: bugsplat.database=yourdb\n" + + " - gradle property: -Pbugsplat.database=yourdb\n" + + " - env var: BUGSPLAT_DATABASE=yourdb" + ) +} + +// BugSplat configuration - appName and appVersion are derived from android.defaultConfig below. ext { - bugsplatDatabase = "fred" // Replace with your BugSplat database name - bugsplatAppName = "my-android-crasher" // Replace with your application name - bugsplatAppVersion = "1.0.0" // Can be overridden by versionName below - // Optional: Add your BugSplat API credentials for symbol upload - bugsplatClientId = "" // Replace with your BugSplat API client ID (optional) - bugsplatClientSecret = "" // Replace with your BugSplat API client secret (optional) + bugsplatDatabase = bugsplatDb + bugsplatAppName = null // populated from android.defaultConfig.applicationId + bugsplatAppVersion = null // populated from android.defaultConfig.versionName + bugsplatClientId = localProps.getProperty('bugsplat.clientId', '') + bugsplatClientSecret = localProps.getProperty('bugsplat.clientSecret', '') } android { @@ -29,17 +52,17 @@ android { versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - + // Match the ABI filters from the library ndk { abiFilters 'arm64-v8a', 'x86_64', 'armeabi-v7a' } - - // Update BugSplat version to match app version if needed - if (project.ext.bugsplatAppVersion != versionName) { - project.ext.bugsplatAppVersion = versionName - } - + + // Use applicationId and versionName as the single source of truth for + // BugSplat's appName/appVersion so they stay in sync with the build. + project.ext.bugsplatAppName = applicationId + project.ext.bugsplatAppVersion = versionName + // Add BugSplat configuration to BuildConfig buildConfigField "String", "BUGSPLAT_DATABASE", "\"${project.ext.bugsplatDatabase}\"" buildConfigField "String", "BUGSPLAT_APP_NAME", "\"${project.ext.bugsplatAppName}\"" @@ -146,172 +169,125 @@ task downloadSymbolUpload { } } -// Task to upload debug symbols for native libraries -task uploadBugSplatSymbols { - doLast { - // Check if client credentials are provided - if (!project.ext.has('bugsplatClientId') || !project.ext.bugsplatClientId || - !project.ext.has('bugsplatClientSecret') || !project.ext.bugsplatClientSecret) { - logger.warn("BugSplat API client credentials (clientId and clientSecret) are not provided.") - logger.warn("Symbol upload requires authentication. Please add the following to your build.gradle:") - logger.warn("ext {") - logger.warn(" bugsplatClientId = \"your_client_id\"") - logger.warn(" bugsplatClientSecret = \"your_client_secret\"") - logger.warn("}") - logger.warn("You can obtain these credentials from your BugSplat account.") - logger.warn("Skipping symbol upload.") - return - } - - // Get the current build variant and ABI - def buildType = System.getProperty("buildType") ?: "debug" // Default to debug if not specified - def currentAbi = System.getProperty("abi") ?: "arm64-v8a" // Default to arm64-v8a if not specified - - logger.lifecycle("Uploading symbols for build type: ${buildType}, ABI: ${currentAbi}") - - // Path to the merged native libraries for the current build type - def nativeLibsDir = "${buildDir}/intermediates/merged_native_libs/${buildType}/out/lib/${currentAbi}" - - // Check if the directory exists - def nativeLibsDirFile = file(nativeLibsDir) - if (!nativeLibsDirFile.exists()) { - logger.warn("Native libraries directory not found: ${nativeLibsDir}") - logger.warn("Skipping symbol upload") - return - } - - // Download the symbol-upload executable if needed - def isWindows = System.getProperty("os.name").toLowerCase().contains("windows") - def isMac = System.getProperty("os.name").toLowerCase().contains("mac") - def isLinux = System.getProperty("os.name").toLowerCase().contains("linux") - - def downloadUrl - def executableName - def executableFile - - if (isWindows) { - downloadUrl = "https://app.bugsplat.com/download/symbol-upload-windows.exe" - executableName = "symbol-upload-windows.exe" - executableFile = file("${rootProject.projectDir}/${executableName}") - } else if (isMac) { - downloadUrl = "https://app.bugsplat.com/download/symbol-upload-macos" - executableName = "symbol-upload-macos" - executableFile = file("${rootProject.projectDir}/${executableName}") - } else if (isLinux) { - downloadUrl = "https://app.bugsplat.com/download/symbol-upload-linux" - executableName = "symbol-upload-linux" - executableFile = file("${rootProject.projectDir}/${executableName}") - } else { - logger.error("Unsupported operating system for symbol-upload") - logger.error("Skipping symbol upload") - return - } - - // Check if the executable already exists - if (!executableFile.exists()) { - // Download the executable - logger.lifecycle("Downloading symbol upload executable from: ${downloadUrl}") - - try { - // Create a new URL and connection - def url = new URL(downloadUrl) - def connection = url.openConnection() - connection.setRequestProperty("User-Agent", "BugSplat-Android-Gradle-Plugin") - - // Download the file - executableFile.withOutputStream { outputStream -> - connection.inputStream.with { inputStream -> - outputStream << inputStream - } - } - - // Make the file executable (not needed for Windows) - if (!isWindows) { - executableFile.setExecutable(true) - logger.lifecycle("Made ${executableFile.absolutePath} executable") - } - - logger.lifecycle("Downloaded symbol upload executable to: ${executableFile.absolutePath}") - } catch (Exception e) { - logger.error("Failed to download symbol-upload executable: ${e.message}") - logger.error("Skipping symbol upload") - return +// Resolve the symbol-upload executable for the current OS, downloading it if needed. +// Returns the File, or null if download fails / OS unsupported. +def resolveSymbolUploadExecutable = { + def osName = System.getProperty("os.name").toLowerCase() + def executableName + def downloadUrl + if (osName.contains("windows")) { + executableName = "symbol-upload-windows.exe" + downloadUrl = "https://app.bugsplat.com/download/symbol-upload-windows.exe" + } else if (osName.contains("mac")) { + executableName = "symbol-upload-macos" + downloadUrl = "https://app.bugsplat.com/download/symbol-upload-macos" + } else if (osName.contains("linux")) { + executableName = "symbol-upload-linux" + downloadUrl = "https://app.bugsplat.com/download/symbol-upload-linux" + } else { + logger.error("Unsupported operating system for symbol-upload: ${osName}") + return null + } + + def executableFile = file("${rootProject.projectDir}/${executableName}") + if (!executableFile.exists()) { + logger.lifecycle("Downloading symbol-upload executable from: ${downloadUrl}") + try { + def connection = new URL(downloadUrl).openConnection() + connection.setRequestProperty("User-Agent", "BugSplat-Android-Gradle-Plugin") + executableFile.withOutputStream { outStream -> + connection.inputStream.with { inStream -> outStream << inStream } } - } else { - logger.lifecycle("Symbol upload executable already exists at: ${executableFile.absolutePath}") - } - - // Path to the symbol-upload executable - def symbolUploadPath = executableFile.absolutePath - - logger.lifecycle("Uploading symbols from directory: ${nativeLibsDir}") - - // Build the command with the directory and glob pattern - def command = [ - symbolUploadPath, - "-b", project.ext.bugsplatDatabase, - "-a", project.ext.bugsplatAppName, - "-v", project.ext.bugsplatAppVersion, - "-d", nativeLibsDirFile.absolutePath, - "-f", "*.so", // Only match .so files directly in the ABI directory - "-m", // Enable multi-threading - "-i", project.ext.bugsplatClientId, - "-s", project.ext.bugsplatClientSecret - ] - - // Execute the command - logger.lifecycle("Executing command: ${command.join(' ')}") - def process = command.execute() - def output = new StringBuilder() - def error = new StringBuilder() - - // Capture and log output in real-time - process.consumeProcessOutput( - { line -> - output.append(line).append('\n') - logger.lifecycle("symbol-upload: ${line}") - } as Appendable, - { line -> - error.append(line).append('\n') - logger.error("symbol-upload error: ${line}") - } as Appendable - ) - - process.waitFor() - - if (process.exitValue() == 0) { - logger.lifecycle("Successfully uploaded symbols for ${buildType}/${currentAbi}") - } else { - logger.error("Failed to upload symbols, exit code: ${process.exitValue()}") - if (error.length() > 0) { - logger.error("Error output: ${error}") + if (!osName.contains("windows")) { + executableFile.setExecutable(true) } + logger.lifecycle("Downloaded symbol-upload executable to: ${executableFile.absolutePath}") + } catch (Exception e) { + logger.error("Failed to download symbol-upload executable: ${e.message}") + return null } } + return executableFile +} + +// Upload symbols for a single buildType + abi. Runs synchronously. +def uploadSymbolsForAbi = { String buildType, String abi -> + // Check if client credentials are provided + if (!project.ext.bugsplatClientId || !project.ext.bugsplatClientSecret) { + logger.warn("BugSplat API client credentials are not configured.") + logger.warn("Add bugsplat.clientId and bugsplat.clientSecret to local.properties to enable symbol upload.") + logger.warn("Skipping symbol upload.") + return + } + + // AGP 8.6+ includes the producing task name in the intermediate path. + def mergeTaskName = "merge${buildType.capitalize()}NativeLibs" + def nativeLibsDir = file("${buildDir}/intermediates/merged_native_libs/${buildType}/${mergeTaskName}/out/lib/${abi}") + if (!nativeLibsDir.exists()) { + logger.warn("Native libs not found for ${buildType}/${abi}: ${nativeLibsDir}") + logger.warn("(ABI likely wasn't built for the current target device). Skipping symbol upload.") + return + } + + def executable = resolveSymbolUploadExecutable() + if (executable == null) { + return + } + + logger.lifecycle("Uploading symbols for ${buildType}/${abi} from ${nativeLibsDir}") + def command = [ + executable.absolutePath, + "-b", project.ext.bugsplatDatabase, + "-a", project.ext.bugsplatAppName, + "-v", project.ext.bugsplatAppVersion, + "-d", nativeLibsDir.absolutePath, + "-f", "*.so", + "-m", + "-i", project.ext.bugsplatClientId, + "-s", project.ext.bugsplatClientSecret + ] + + def process = command.execute() + process.consumeProcessOutput( + { line -> logger.lifecycle("symbol-upload: ${line}") } as Appendable, + { line -> logger.error("symbol-upload error: ${line}") } as Appendable + ) + process.waitFor() + + if (process.exitValue() == 0) { + logger.lifecycle("Successfully uploaded symbols for ${buildType}/${abi}") + } else { + logger.error("Failed to upload symbols for ${buildType}/${abi}, exit code: ${process.exitValue()}") + } } // Define build types and ABIs def buildTypes = ['debug', 'release'] def abiFilters = android.defaultConfig.ndk.abiFilters -// Create tasks for each build type and ABI combination +// Register per-ABI upload tasks. Each task has its own body (no shared task) +// so AllAbis can serialize them via mustRunAfter — the symbol-upload binary +// uses a shared temp dir, so parallel invocations are not safe. buildTypes.each { buildType -> - abiFilters.each { abi -> + def perAbiTasks = abiFilters.collect { abi -> tasks.register("uploadBugSplatSymbols${buildType.capitalize()}${abi.capitalize()}") { - dependsOn uploadBugSplatSymbols - doFirst { - System.setProperty("buildType", buildType) - System.setProperty("abi", abi) + dependsOn "merge${buildType.capitalize()}NativeLibs" + doLast { + uploadSymbolsForAbi(buildType, abi) } } } - - // Create a task to upload symbols for all ABIs for this build type - tasks.register("uploadBugSplatSymbols${buildType.capitalize()}AllAbis") { - dependsOn abiFilters.collect { abi -> - tasks.named("uploadBugSplatSymbols${buildType.capitalize()}${abi.capitalize()}") + + // Chain the per-ABI tasks so they run serially when AllAbis is invoked. + perAbiTasks.eachWithIndex { taskProvider, i -> + if (i > 0) { + taskProvider.configure { mustRunAfter perAbiTasks[i - 1] } } } + + tasks.register("uploadBugSplatSymbols${buildType.capitalize()}AllAbis") { + dependsOn perAbiTasks + } } // Run the symbol upload task after the assemble tasks diff --git a/example/src/main/java/com/bugsplat/example/MainActivity.java b/example/src/main/java/com/bugsplat/example/MainActivity.java index a9b2116..780f9e1 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 via native hang..."); + Toast.makeText(MainActivity.this, + "Tap the screen to trigger the ANR dialog", Toast.LENGTH_SHORT).show(); + // BugSplat.hang() blocks the main thread in a native infinite loop, + // so the resulting ANR thread dump includes a symbolicated C++ frame. + // Tap the screen to trigger the ANR dialog, then choose "Close app" + // to kill the process. The ANR will be captured via ApplicationExitInfo + // and uploaded on next launch. + BugSplat.hang(); + } + }); + // 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..9ad2e6c 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" /> +