Integrate an SDK · Android
Android SDK
Native Kotlin SDK with a Java-friendly API, an instrumentation replay runner, and a demo shop app. Capsules carry sdk.name: bugcapsule-android and runtime.name: android.
- Coordinates
dev.bugcapsule:<artifact>:0.3.0- Requirements
- minSdk 23 · Java 17 bytecode · libraries compiled against SDK 36 (README states compileSdk 34+)
- Toolchain
- AGP 8.13, Kotlin 2.2.20
- Distribution
- Local Maven repo from
./gradlew publishAll(build/repo). Maven Central is not wired up yet.
Artifacts
| Artifact | Package | What it does |
|---|---|---|
bugcapsule-android | dev.bugcapsule.android | Core: capsule codec and validator, recorder, redaction, request keys, remote config, outbox, capture (lifecycle, pointers, Back key, failures, crashes), replay engine, Views helpers, SharedPreferences and DataStore checkpoints, state serializers (ViewModels too) |
bugcapsule-okhttp | dev.bugcapsule.okhttp | OkHttp 4 capture and deterministic replay (Retrofit works through it) |
bugcapsule-navigation | dev.bugcapsule.navigation | Jetpack Navigation route events, checkpoint and restore |
bugcapsule-compose | dev.bugcapsule.compose | Compose helpers: semantic actions, tracked text, navigation effect |
bugcapsule-android-replay | dev.bugcapsule.android.replay | androidTest only. BugCapsuleReplayRunner (an AndroidJUnitRunner) and BugCapsuleReplayTest |
1. Gradle setup
Until the artifacts are on Maven Central, publish them locally from the SDK checkout and point your build at the folder:
cd bug_capsule_android_sdk
./gradlew clean publishAll # writes build/repo/dev/bugcapsule/<artifact>/0.3.0/
dependencyResolutionManagement {
repositories {
google(); mavenCentral()
maven { url = uri("/path/to/bug_capsule_android_sdk/build/repo") }
}
}
android {
defaultConfig {
// The replay runner must be the instrumentation runner of the app's androidTest APK.
testInstrumentationRunner = "dev.bugcapsule.android.replay.BugCapsuleReplayRunner"
// The CLI passes -PbugcapsuleGitSha=<sha> so capsules and replays carry the build's commit.
buildConfigField("String", "BUGCAPSULE_GIT_SHA",
"\"${providers.gradleProperty("bugcapsuleGitSha").getOrElse("")}\"")
buildConfigField("String", "BUGCAPSULE_INGEST_KEY",
"\"${providers.environmentVariable("BUGCAPSULE_INGEST_KEY").getOrElse("")}\"")
}
buildFeatures.buildConfig = true
}
dependencies {
implementation("dev.bugcapsule:bugcapsule-android:0.3.0")
implementation("dev.bugcapsule:bugcapsule-okhttp:0.3.0")
implementation("dev.bugcapsule:bugcapsule-navigation:0.3.0")
implementation("dev.bugcapsule:bugcapsule-compose:0.3.0") // if you use Compose
androidTestImplementation("dev.bugcapsule:bugcapsule-android-replay:0.3.0")
// Only if you checkpoint Preferences DataStore:
implementation("androidx.datastore:datastore-preferences:1.1.1")
}
The library modules ship no manifest. Your app must declare android.permission.INTERNET (uploads and the replay socket).
2. Initialize in Application.onCreate
import dev.bugcapsule.android.BugCapsule
import dev.bugcapsule.android.BugCapsuleConfig
import dev.bugcapsule.android.core.CaptureMode
import dev.bugcapsule.android.core.EndpointRule
import dev.bugcapsule.android.storage.BugCapsuleDataStore
import dev.bugcapsule.android.storage.BugCapsuleSharedPreferences
import dev.bugcapsule.navigation.BugCapsuleNavigation
import dev.bugcapsule.okhttp.BugCapsuleOkHttp
class ShopApp : Application() {
override fun onCreate() {
super.onCreate()
// First thing in Application.onCreate — not a ContentProvider or App Startup initializer:
// in replay the runner restores storage right before Application.onCreate.
BugCapsule.init(
this,
BugCapsuleConfig.builder()
.mode(CaptureMode.QA) // default: DEBUG if debuggable, else PRODUCTION
.backend("https://api.bugcapsule.com", BuildConfig.BUGCAPSULE_INGEST_KEY) // bci_… only
.gitSha(BuildConfig.BUGCAPSULE_GIT_SHA)
.redactKeys("loyaltyId")
.build(),
listOf(
okHttp,
navigation,
BugCapsuleSharedPreferences.builder().allowFile("shop_prefs").build(),
BugCapsuleDataStore.builder().allow("settings").build(),
),
)
}
companion object {
val okHttp = BugCapsuleOkHttp(rules = listOf(
// Fields that change on every run must not affect request matching.
EndpointRule("POST", "/api/checkout", ignoreBodyFields = setOf("timestamp", "nonce"))))
val navigation = BugCapsuleNavigation()
}
}
A second init call is ignored with a warning. Setup errors are caught and the app runs without capture. Status is readable through BugCapsule.isInitialized, isRecording, isReplaying, mode and session.
Java
BugCapsuleConfig config = BugCapsuleConfig.builder()
.mode(CaptureMode.QA)
.backend(url, ingestKey)
.build();
BugCapsule.init(app, config, Arrays.asList(okHttp, navigation));
BugCapsuleViews.onClick(button, "cart.checkout", v -> viewModel.checkout());
BugCapsule.check("cart_total_non_negative", total >= 0, "total " + total);
3. Wire OkHttp
Add the SDK interceptor as the innermost application interceptor, after your own. Pass the same BugCapsuleOkHttp instance to init.
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor()) // your interceptors first: they still run in replay
.addInterceptor(ShopApp.okHttp.interceptor) // BugCapsule last
.build()
// or: ShopApp.okHttp.install(builder)
| Constructor parameter | Default | Notes |
|---|---|---|
rules | empty | EndpointRule(method, path, ignoreQuery, ignoreBodyFields, matchBodyFields). path supports :name segments and a trailing *. |
ignoreQuery | _ ts timestamp nonce cachebust | Query params dropped from every request key |
ignoreBodyFields | empty | JSON fields dropped before hashing, for every endpoint |
maxBinaryBytes | 64 KiB | Binary body prefix kept |
maxKeyBodyBytes | 64 KiB | Largest body hashed into the key |
maxBodyWaitMs | 30 000 | After this a still-streaming body is recorded truncated |
exclude | null | RequestFilter { request -> Boolean }: excluded calls record method, URL, status and timing only |
In replay the interceptor answers from the capsule by request key + occurrence after the recorded latency, without calling chain.proceed. An unrecorded request throws ConnectException and reports network.unrecorded. Exchanges recorded as pending (in flight at a crash) are held open until the call is cancelled.
Because replay answers inside the interceptor, interceptors added after it, network interceptors, caching and cookies are not exercised in replay. One-shot and duplex request bodies are never read; they are keyed without a body hash.
4. Navigation
// After the graph is set, on the main thread:
ShopApp.navigation.attach(navHostFragment.navController)
val navController = rememberNavController()
BugCapsuleNavigationEffect(navController, ShopApp.navigation)
BugCapsuleNavigation(sanitizeLocation = LocationSanitizer { it.replace(Regex("/orders/\\d+"), "/orders/:id") }) rewrites locations that contain secrets or ids. Route events carry source: "navigation"; {route, backStack} is snapshotted in periodic checkpoints and restored by navigating. Only String, Number and Boolean arguments are kept (Parcelables are not restored).
5. Actions and tracked text
Semantic replay invokes registered actions by id and restores tracked text by id. It works even when the developer's screen differs from QA's.
import dev.bugcapsule.android.views.BugCapsuleViews
BugCapsuleViews.onClick(checkoutButton, "cart.checkout") { viewModel.checkout() }
BugCapsuleViews.trackText(couponField, "cart.coupon")
BugCapsuleViews.trackText(passwordField, "login.password") // password input types are forced sensitive
import dev.bugcapsule.compose.*
val coupon = rememberTrackedText("cart.coupon")
TextField(value = coupon.value, onValueChange = coupon::onValueChange)
// Compose does NOT detect password fields: pass sensitive = true yourself.
val password = rememberTrackedText("login.password", sensitive = true)
Button(onClick = rememberBugCapsuleAction("cart.checkout") { viewModel.checkout() }) {
Text("Checkout")
}
Lower-level API for custom widgets: BugCapsule.action(id, Runnable), registerAction(id, ActionHandler) / unregisterAction, recordAction(id, payload) and trackText(id, TextSetter, sensitive) which returns a TextTracker (onChanged(value), untrack()).
6. Assertions, errors and logs
BugCapsule.check("cart_total_non_negative", total >= 0, "total $total")
BugCapsule.fail("checkout_stuck", "spinner for 30 s")
try { … } catch (e: IllegalStateException) { BugCapsule.reportError(e) }
BugCapsule.log("legacy cache migrated", level = "warn")
A failure writes a capsule automatically: 300 ms after the failure (crashes immediately), at most maxCapsulesPerSession (3) per session, one per failure signature. Manual capture() calls count toward the same quota.
What is captured automatically
- Uncaught JVM exceptions on any thread through a chained default
UncaughtExceptionHandler. The previous handler is always called afterwards, so the process still dies. The capsule is written synchronously tonoBackupFilesDir/bugcapsuleand uploaded on the next launch; it carriescrash.persisted(partial).ActivityThread's "Unable to start activity" wrapper is unwrapped. - Not captured: native (NDK) crashes, ANRs,
System.exit/killProcess, OOM kills, exceptions the app catches withoutreportError. - Lifecycle (process level), pointers via each Activity's
Window.Callback(dp, no accessibility service or screen recording), the system Back key (keyevent).
7. State and storage
// ViewModel state in periodic checkpoints, restored in replay (auto-unregistered when the VM clears)
BugCapsule.registerViewModel(this, "cart", { state.toJson() }) { value -> state = CartState.fromJson(value) }
// Any other app state
BugCapsule.registerState("session", { sessionStore.snapshot() }) { sessionStore.restore(it) }
BugCapsule.reportState("cart") // diagnostic `state` event (not replayed)
Storage is allowlist only; nothing is read unless you name it:
BugCapsuleSharedPreferences.builder()
.allowFile("shop_prefs") // every key of a file
.allow("settings_prefs", "feature.") // keys with a prefix
.deny("settings_prefs", "feature.secret")
.build()
BugCapsuleDataStore.builder() // reads files/datastore/<name>.preferences_pb
.allow("settings")
.build()
Both are restored by the replay runner before Application.onCreate. Periodic checkpoints (every 30 s) are used only when the session outgrows the 5-minute / 20,000-event window; otherwise replay starts from the launch checkpoint and re-executes the whole session.
8. Deterministic hooks and manual capture
val nonce = BugCapsule.random().nextInt() // seeded per session; same seed in replay
val now = BugCapsule.currentTimeMillis() // recorded wall clock in replay
BugCapsule.capture(note = "QA: total looks wrong") // Future<CaptureResult?>
BugCapsule.flushUploads()
BugCapsule.refreshRemoteConfig()
BugCapsule.savedCaptures() // List<File>, newest 20
A manual capture with no failure gets a blocking failure.assertion gap and is ineligible: stored and inspectable, never counted in the reproduction rate.
Configuration reference
BugCapsuleConfig.builder()…build() (also toBuilder()).
| Builder method | Default | Notes |
|---|---|---|
appVersion, appBuild, appId, appName, flavor | package info | versionName, versionCode, packageName, label |
mode | DEBUG if debuggable, else PRODUCTION | Only sampling depends on mode: debug and QA always record |
enabled | true | Local master switch, read at init |
backend(url, ingestKey) | none | Without it nothing is uploaded and capture settings aren't fetched. bci_ keys only |
gitSha, gitDirty | null | Fallback: manifest meta-data dev.bugcapsule.GIT_SHA / dev.bugcapsule.GIT_DIRTY |
stateSchemaVersion | 1 | A mismatch with a periodic checkpoint makes replay INCOMPATIBLE |
checkpointIntervalMs | 30 000 | At least 1000 |
windowMs / maxEvents | 300 000 / 20 000 | Flight-recorder window |
productionSampleRate | 0.1 | Remote sampleRate can only lower it |
autoCaptureOnFailure | true | |
maxCapsulesPerSession | 3 | Shared with manual captures |
captureNetworkBodies / maxBodyBytes | true / 256 KiB | ANDed with the remote setting |
redactKeys, redactHeaders, redactPattern(regex), redactPatterns | empty | Added to the built-in rules |
beforeRecord | null | BeforeRecord { type, data -> data or null }, null drops the event |
beforeCheckpoint | null | BeforeCheckpoint { integration, data -> … } |
outboxLimits | OutboxLimits(10, 10 MiB, 7 days) | Oldest evicted first |
maxStaleConfigMs | 7 days | Uploads need a config at most this old |
ignoreError | null | ErrorFilter { e -> Boolean } |
label(key, value) | empty | Free-form tags, scrubbed like text |
maxSavedCaptures | 20 |
Replay
{
"runtime": "android",
"android": {
"gradleDir": ".",
"module": "app",
"variant": "debug",
"applicationId": "com.example.shop",
"testApplicationId": "com.example.shop.test",
"avd": "Pixel_6_2"
},
"timeouts": { "buildSeconds": 1200, "attemptSeconds": 120 }
}
bugcapsule run <id> -n 3 # expects REPRODUCED
bugcapsule verify <id> -n 3 # after the fix: FIXED only if every attempt is valid and clean
bugcapsule regress add <id> && bugcapsule regress run
All android.* keys are listed on the CLI page. What one attempt does:
- The CLI builds
:<module>:assemble<Variant>andassemble<Variant>AndroidTestonce, with-PbugcapsuleGitSha=<sha> -PbugcapsuleGitDirty=<bool>(or uses--apk/--test-apk). - Per attempt:
pm clear,adb reverse tcp:<port>, thenam instrument -r -w -e bugcapsuleReplayUrl <url> -e class dev.bugcapsule.android.replay.BugCapsuleReplayTest <testAppId>/<runner>. BugCapsuleReplayRunner.onCreate(beforeApplication.onCreate) fetches settings and capsule, refuses unless the app is debuggable, and restores SharedPreferences/DataStore.BugCapsule.initstarts in replay mode. The test launches the launcher activity withActivityScenarioand runs the in-app engine: pointers viasendPointerSync(dp → px, scaled if the screen differs), Back viasendKeySync, actions and text on the main thread, lifecycle by moving the scenario (resumed→RESUMED,inactive→STARTED,paused→CREATED).- The result goes to
POST <base>/resultand to instrumentation statusbugcapsule_result=BUGCAPSULE_RESULT::{json}. A replayed crash reports synchronously from the crash handler before the process dies.
Strategy AUTO uses pointer replay when the screen matches or no actions exist, otherwise semantic. Idle gaps over 1.5 s shrink to 0.3 s; recorded network latency is never shortened.
Production protection. Replay can only be armed from an Instrumentation (the runner ships in the androidTest APK only) for a debuggable app. There is no intent, deep link, URL, push or file path into replay.
The emulator must be unlocked once after boot. AVDs with a screen-lock PIN cannot be unlocked by the CLI. --device picks a running device (serial, AVD name or model) or an AVD to boot; an emulator the CLI booted is shut down afterwards.
Uploads and capture settings
- The SDK uses its own HTTP connection, so its traffic is never captured by your OkHttp client.
- Uploads wait until the project's capture settings have been fetched once (or a cached copy is younger than 7 days). Failed uploads are retried with backoff from 30 s up to 1 h at the next trigger (launch, foreground, a new capsule,
flushUploads()); a capsule BugCapsule rejects is dropped. - Capture settings are cached per API URL and ingest key and refreshed every few minutes and on foreground.
- Kill switch: recording stops, queued and saved captures are deleted, and it persists across launches. A rejected ingest key stops capture for the session but keeps the queue (other SDKs delete it).
Demo app
examples/demo: a Views + Compose shop (Retrofit on OkHttp, an in-process FakeShopServer interceptor below the SDK, Jetpack Navigation, allowlisted SharedPreferences and DataStore, CartViewModel registered as state).
| Bug | Trigger | Why replay needs BugCapsule |
|---|---|---|
Data-dependent: item with price: null | 1 in 4 carts; Checkout | The recorded server response |
| Negative total | Coupon SAVE100 on a cart under 100 | check("cart_total_non_negative") + tracked text |
| Race: recommendations before profile | "Refresh home" (~1 in 4) | Recorded response latencies |
| Legacy cart cache | "Simulate upgrade from v0.9", restart, open cart | The launch checkpoint restores shop_prefs |
export BUGCAPSULE_INGEST_KEY=bci_… # enables uploads
./gradlew :demo:assembleDebug -PbugcapsuleBackendUrl=https://api.bugcapsule.com
# Isolated fix toggle, no tracked source change:
BUGCAPSULE_ANDROID_GRADLE_ARGS=-PbugcapsuleDemoFix=race bugcapsule verify <id> -p examples/demo
Only race keeps the recorded request sequence. price and legacy change which requests the app makes, so verifying those is (correctly) INCONCLUSIVE with network.unrecorded.
Limitations
- Replay runs only through the instrumentation runner with a debuggable build. Pointer replay assumes the capture screen size; otherwise coordinates are scaled (
screen.size, expected). - Only the Back key among hardware keys; IME text only through tracked fields. Multi-window and system dialogs are not driven.
- Clock and randomness are deterministic only through
currentTimeMillis()andrandom(). WorkManager, alarms, Bluetooth, location and other network stacks (HttpURLConnection, Ktor, Cronet) are not captured. - The consumer ProGuard/R8 rules are empty and the demo ships with minify off. R8 builds are untested; verify a minified QA build before relying on it.
- There is no consent, user-identify or breadcrumb API. Use
enabledat init,label(),log()andrecordAction().