Skip to content

Commit 8a1d008

Browse files
authored
feat: android ANR detection (#13)
1 parent 73b53c0 commit 8a1d008

18 files changed

Lines changed: 1617 additions & 336 deletions

File tree

.github/workflows/codeql.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ jobs:
4646

4747
# Build with Gradle using the Android SDK's CMake
4848
- name: Build with Gradle
49+
env:
50+
# The example module requires a bugsplat.database value to configure.
51+
# CI doesn't upload symbols, so a placeholder is fine.
52+
BUGSPLAT_DATABASE: fred
4953
run: ./gradlew assembleDebug --no-daemon
5054

5155
- name: Perform CodeQL Analysis

.github/workflows/tests.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Tests
2+
3+
on:
4+
pull_request:
5+
branches: [ "main", "master" ]
6+
7+
jobs:
8+
unit-tests:
9+
name: Unit Tests
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Checkout repository
14+
uses: actions/checkout@v4
15+
16+
- name: Set up JDK 17
17+
uses: actions/setup-java@v4
18+
with:
19+
java-version: '17'
20+
distribution: 'temurin'
21+
22+
- name: Install Android SDK CMake
23+
run: |
24+
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "cmake;3.22.1"
25+
echo "$ANDROID_HOME/cmake/3.22.1/bin" >> $GITHUB_PATH
26+
27+
- name: Run unit tests
28+
env:
29+
# The example module requires a bugsplat.database value to configure.
30+
# CI doesn't upload symbols, so a placeholder is fine.
31+
BUGSPLAT_DATABASE: fred
32+
run: ./gradlew :app:testDebugUnitTest --no-daemon

README.md

Lines changed: 176 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -102,43 +102,102 @@ After completing these steps, you can start using BugSplat in your Android appli
102102

103103
### Configuration
104104

105-
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.
105+
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.
106106

107107
```kotlin
108-
BugSplatBridge.initBugSplat(this, database, application, version)
108+
BugSplat.init(this, database, application, version)
109109
```
110110

111-
You can also add file attributes, and/or file attachments to your crash reports.
111+
### Loading config from local.properties (recommended)
112112

113-
Kotlin
113+
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:
114+
115+
1. Add the database name to the gitignored `local.properties`:
116+
117+
```properties
118+
bugsplat.database=your_database
119+
```
120+
121+
2. In your app module's `build.gradle`, load it and expose it (plus `applicationId` and `versionName`) as `BuildConfig` fields:
122+
123+
```gradle
124+
def localProps = new Properties()
125+
def localPropsFile = rootProject.file('local.properties')
126+
if (localPropsFile.exists()) {
127+
localPropsFile.withInputStream { localProps.load(it) }
128+
}
129+
130+
android {
131+
defaultConfig {
132+
applicationId "com.example.myapp"
133+
versionName "1.0.0"
134+
135+
buildConfigField "String", "BUGSPLAT_DATABASE",
136+
"\"${localProps.getProperty('bugsplat.database')}\""
137+
buildConfigField "String", "BUGSPLAT_APP_NAME",
138+
"\"${applicationId}\""
139+
buildConfigField "String", "BUGSPLAT_APP_VERSION",
140+
"\"${versionName}\""
141+
}
142+
buildFeatures { buildConfig true }
143+
}
144+
```
145+
146+
3. Initialize BugSplat from the generated `BuildConfig`:
147+
148+
```java
149+
BugSplat.init(
150+
this,
151+
BuildConfig.BUGSPLAT_DATABASE,
152+
BuildConfig.BUGSPLAT_APP_NAME,
153+
BuildConfig.BUGSPLAT_APP_VERSION
154+
);
155+
```
156+
157+
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.
158+
159+
### Attributes and attachments
160+
161+
You can also add custom attributes and/or file attachments to your crash reports.
162+
163+
**Kotlin**
114164
```kotlin
115165
val attributes = mapOf(
116166
"key1" to "value1",
117167
"key2" to "value2",
118168
"environment" to "development"
119169
)
120170

121-
val attachmentFileName = "log.txt"
122-
createAttachmentFile(attachmentFileName)
123-
val attachmentPath = applicationContext.getFileStreamPath(attachmentFileName).absolutePath
171+
val attachmentPath = applicationContext.getFileStreamPath("log.txt").absolutePath
124172
val attachments = arrayOf(attachmentPath)
125173

126-
BugSplatBridge.initBugSplat(this, "fred", "my-android-crasher", "2.0.0", attributes, attachments)
174+
BugSplat.init(
175+
this,
176+
BuildConfig.BUGSPLAT_DATABASE,
177+
BuildConfig.BUGSPLAT_APP_NAME,
178+
BuildConfig.BUGSPLAT_APP_VERSION,
179+
attributes,
180+
attachments
181+
)
127182
```
128183

129-
Java
184+
**Java**
130185
```java
131186
Map<String, String> attributes = new HashMap<>();
132187
attributes.put("key1", "value1");
133-
attributes.put("key2", "value2");
134188
attributes.put("environment", "development");
135189

136-
String attachmentFileName = "log.txt";
137-
createAttachmentFile(attachmentFileName);
138-
String attachmentPath = getApplicationContext().getFileStreamPath(attachmentFileName).getAbsolutePath();
190+
String attachmentPath = getApplicationContext().getFileStreamPath("log.txt").getAbsolutePath();
139191
String[] attachments = new String[]{attachmentPath};
140192

141-
BugSplatBridge.initBugSplat(this, "fred", "my-android-crasher", "2.0.0", attributes, attachments);
193+
BugSplat.init(
194+
this,
195+
BuildConfig.BUGSPLAT_DATABASE,
196+
BuildConfig.BUGSPLAT_APP_NAME,
197+
BuildConfig.BUGSPLAT_APP_VERSION,
198+
attributes,
199+
attachments
200+
);
142201
```
143202

144203
### Symbol Upload
@@ -173,68 +232,56 @@ This approach requires the `symbol-upload` executable to be included in your app
173232

174233
#### 2. Using Gradle Build Tasks
175234

176-
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:
235+
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`.
236+
237+
**Step 1 — Add credentials to `local.properties` (do not commit):**
238+
239+
```properties
240+
bugsplat.database=your_database
241+
bugsplat.clientId=your_client_id
242+
bugsplat.clientSecret=your_client_secret
243+
```
244+
245+
**Step 2 — Load them in `build.gradle` and register per-ABI upload tasks:**
177246

178247
```gradle
179-
// BugSplat configuration
248+
// Load BugSplat credentials from local.properties
249+
def localProps = new Properties()
250+
def localPropsFile = rootProject.file('local.properties')
251+
if (localPropsFile.exists()) {
252+
localPropsFile.withInputStream { localProps.load(it) }
253+
}
254+
180255
ext {
181-
bugsplatDatabase = "your_database_name" // Replace with your BugSplat database name
182-
bugsplatAppName = "your_app_name" // Replace with your application name
256+
bugsplatDatabase = localProps.getProperty('bugsplat.database')
257+
bugsplatClientId = localProps.getProperty('bugsplat.clientId', '')
258+
bugsplatClientSecret = localProps.getProperty('bugsplat.clientSecret', '')
259+
// Use applicationId and versionName as the single source of truth
260+
bugsplatAppName = android.defaultConfig.applicationId
183261
bugsplatAppVersion = android.defaultConfig.versionName
184-
// Optional: Add your BugSplat API credentials for symbol upload
185-
bugsplatClientId = "" // Replace with your BugSplat API client ID (optional)
186-
bugsplatClientSecret = "" // Replace with your BugSplat API client secret (optional)
187262
}
188263
189-
// Task to upload debug symbols for native libraries
190-
task uploadBugSplatSymbols {
191-
doLast {
192-
// Path to the merged native libraries
193-
def nativeLibsDir = "${buildDir}/intermediates/merged_native_libs/debug/out/lib"
194-
195-
// Check if the directory exists
196-
def nativeLibsDirFile = file(nativeLibsDir)
197-
if (!nativeLibsDirFile.exists()) {
198-
logger.warn("Native libraries directory not found: ${nativeLibsDir}")
199-
return
200-
}
201-
202-
// Path to the symbol-upload executable
203-
def symbolUploadPath = "path/to/symbol-upload" // Adjust this path
204-
205-
// Build the command with the directory and glob pattern
206-
def command = [
207-
symbolUploadPath,
208-
"-b", project.ext.bugsplatDatabase,
209-
"-a", project.ext.bugsplatAppName,
210-
"-v", project.ext.bugsplatAppVersion,
211-
"-d", nativeLibsDirFile.absolutePath,
212-
"-f", "**/*.so",
213-
"-m" // Run dumpsyms
214-
]
215-
216-
// Add client credentials if provided
217-
if (project.ext.has('bugsplatClientId') && project.ext.bugsplatClientId) {
218-
command.add("-i")
219-
command.add(project.ext.bugsplatClientId)
220-
command.add("-s")
221-
command.add(project.ext.bugsplatClientSecret)
222-
}
223-
224-
// Execute the command
225-
// ... (see example app for full implementation)
226-
}
227-
}
264+
// See example/build.gradle for the full implementation including:
265+
// - resolveSymbolUploadExecutable() - downloads the symbol-upload binary
266+
// - uploadSymbolsForAbi(buildType, abi) - runs symbol-upload against
267+
// build/intermediates/merged_native_libs/<buildType>/merge<BuildType>NativeLibs/out/lib/<abi>/
268+
// - Per-ABI tasks (uploadBugSplatSymbolsDebugArm64-v8a, etc.)
269+
// - AllAbis task that chains the per-ABI tasks serially via mustRunAfter
270+
// (parallel uploads aren't safe — the symbol-upload binary uses a shared temp dir)
228271
229-
// Run the symbol upload task after the assembleDebug task
272+
// Run symbol upload after assembleDebug
230273
tasks.whenTaskAdded { task ->
231274
if (task.name == 'assembleDebug') {
232-
task.finalizedBy(uploadBugSplatSymbols)
275+
task.finalizedBy(tasks.named('uploadBugSplatSymbolsDebugAllAbis'))
233276
}
234277
}
235278
```
236279

237-
See the [Example App README](example/README.md) for a complete implementation of this approach.
280+
See [`example/build.gradle`](example/build.gradle) for the complete, working implementation. Key details:
281+
282+
- **Intermediate path** — AGP 8.6+ places merged native libs at `merged_native_libs/<buildType>/merge<BuildType>NativeLibs/out/lib/<abi>/`. Older AGPs used a flat `merged_native_libs/<buildType>/out/lib/<abi>/` layout.
283+
- **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.
284+
- **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.
238285

239286
#### 3. Using the Command-Line Tool
240287

@@ -327,20 +374,54 @@ When integrating BugSplat into your Android application, it's crucial to ensure
327374

328375
These configurations ensure that the BugSplat native libraries are properly included in your app and can function correctly to capture and report native crashes.
329376

377+
## ANR Detection 🐌
378+
379+
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.
380+
381+
### How It Works
382+
383+
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.
384+
385+
The thread dump includes:
386+
- Full Java stack traces for all threads in the process
387+
- Native stack frames with BuildIds (symbolicated against uploaded `.sym` files)
388+
- Lock contention information (which threads are holding/waiting for locks)
389+
390+
### Configuration
391+
392+
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.
393+
394+
### Testing ANR Detection
395+
396+
To test ANR detection, use `BugSplat.hang()` to block the main thread in a native infinite loop:
397+
398+
```java
399+
// Call this on the main thread to trigger an ANR
400+
BugSplat.hang();
401+
```
402+
403+
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.
404+
405+
The resulting thread dump includes a native frame for `jniHang`, which demonstrates end-to-end symbolication when your `.sym` files have been uploaded.
406+
407+
### Supported Versions
408+
409+
ANR detection requires **Android 11+ (API 30+)**. On older Android versions, the `ApplicationExitInfo` API is unavailable and ANR detection is silently disabled.
410+
330411
## User Feedback 💬
331412

332413
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.
333414

334415
### Posting Feedback
335416

336-
Use `BugSplat.postFeedback` to submit feedback asynchronously, or `BugSplat.postFeedbackBlocking` for synchronous submission:
417+
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)):
337418

338419
```java
339420
// Async (returns immediately, runs on background thread)
340421
BugSplat.postFeedback(
341-
"fred", // database
342-
"my-android-crasher", // application
343-
"1.0.0", // version
422+
BuildConfig.BUGSPLAT_DATABASE,
423+
BuildConfig.BUGSPLAT_APP_NAME,
424+
BuildConfig.BUGSPLAT_APP_VERSION,
344425
"Login button broken", // title (required)
345426
"Nothing happens on tap", // description
346427
"Jane", // user
@@ -350,7 +431,9 @@ BugSplat.postFeedback(
350431

351432
// Blocking (returns true on success)
352433
boolean success = BugSplat.postFeedbackBlocking(
353-
"fred", "my-android-crasher", "1.0.0",
434+
BuildConfig.BUGSPLAT_DATABASE,
435+
BuildConfig.BUGSPLAT_APP_NAME,
436+
BuildConfig.BUGSPLAT_APP_VERSION,
354437
"Login button broken", "Nothing happens on tap",
355438
"Jane", "jane@example.com", null
356439
);
@@ -366,13 +449,35 @@ attachments.add(new File(getFilesDir(), "screenshot.png"));
366449
attachments.add(new File(getFilesDir(), "app.log"));
367450

368451
BugSplat.postFeedback(
369-
"fred", "my-android-crasher", "1.0.0",
452+
BuildConfig.BUGSPLAT_DATABASE,
453+
BuildConfig.BUGSPLAT_APP_NAME,
454+
BuildConfig.BUGSPLAT_APP_VERSION,
370455
"Login button broken", "Nothing happens on tap",
371456
"Jane", "jane@example.com", null,
372457
attachments
373458
);
374459
```
375460

461+
### Custom Attributes
462+
463+
Attach arbitrary key/value metadata to feedback reports:
464+
465+
```java
466+
Map<String, String> attributes = new HashMap<>();
467+
attributes.put("environment", "production");
468+
attributes.put("user_tier", "premium");
469+
470+
BugSplat.postFeedback(
471+
BuildConfig.BUGSPLAT_DATABASE,
472+
BuildConfig.BUGSPLAT_APP_NAME,
473+
BuildConfig.BUGSPLAT_APP_VERSION,
474+
"Login button broken", "Nothing happens on tap",
475+
"Jane", "jane@example.com", null,
476+
null, // attachments
477+
attributes
478+
);
479+
```
480+
376481
### Example Feedback Dialog
377482

378483
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:
392497
The example app demonstrates:
393498
- Automatically initializing the BugSplat SDK at app startup
394499
- Triggering a crash for testing purposes
500+
- Triggering an ANR (via `BugSplat.hang()`) to test ANR detection and native frame symbolication
395501
- Submitting user feedback via a dialog
502+
- Setting custom attributes via a dialog
396503
- Handling errors during initialization
397504

398505
For more information, see the [Example App README](example/README.md).

0 commit comments

Comments
 (0)