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

ArtifactPackageWhat it does
bugcapsule-androiddev.bugcapsule.androidCore: 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-okhttpdev.bugcapsule.okhttpOkHttp 4 capture and deterministic replay (Retrofit works through it)
bugcapsule-navigationdev.bugcapsule.navigationJetpack Navigation route events, checkpoint and restore
bugcapsule-composedev.bugcapsule.composeCompose helpers: semantic actions, tracked text, navigation effect
bugcapsule-android-replaydev.bugcapsule.android.replayandroidTest 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 parameterDefaultNotes
rulesemptyEndpointRule(method, path, ignoreQuery, ignoreBodyFields, matchBodyFields). path supports :name segments and a trailing *.
ignoreQuery_ ts timestamp nonce cachebustQuery params dropped from every request key
ignoreBodyFieldsemptyJSON fields dropped before hashing, for every endpoint
maxBinaryBytes64 KiBBinary body prefix kept
maxKeyBodyBytes64 KiBLargest body hashed into the key
maxBodyWaitMs30 000After this a still-streaming body is recorded truncated
excludenullRequestFilter { 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.

i

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

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 methodDefaultNotes
appVersion, appBuild, appId, appName, flavorpackage infoversionName, versionCode, packageName, label
modeDEBUG if debuggable, else PRODUCTIONOnly sampling depends on mode: debug and QA always record
enabledtrueLocal master switch, read at init
backend(url, ingestKey)noneWithout it nothing is uploaded and capture settings aren't fetched. bci_ keys only
gitSha, gitDirtynullFallback: manifest meta-data dev.bugcapsule.GIT_SHA / dev.bugcapsule.GIT_DIRTY
stateSchemaVersion1A mismatch with a periodic checkpoint makes replay INCOMPATIBLE
checkpointIntervalMs30 000At least 1000
windowMs / maxEvents300 000 / 20 000Flight-recorder window
productionSampleRate0.1Remote sampleRate can only lower it
autoCaptureOnFailuretrue
maxCapsulesPerSession3Shared with manual captures
captureNetworkBodies / maxBodyBytestrue / 256 KiBANDed with the remote setting
redactKeys, redactHeaders, redactPattern(regex), redactPatternsemptyAdded to the built-in rules
beforeRecordnullBeforeRecord { type, data -> data or null }, null drops the event
beforeCheckpointnullBeforeCheckpoint { integration, data -> … }
outboxLimitsOutboxLimits(10, 10 MiB, 7 days)Oldest evicted first
maxStaleConfigMs7 daysUploads need a config at most this old
ignoreErrornullErrorFilter { e -> Boolean }
label(key, value)emptyFree-form tags, scrubbed like text
maxSavedCaptures20

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:

  1. The CLI builds :<module>:assemble<Variant> and assemble<Variant>AndroidTest once, with -PbugcapsuleGitSha=<sha> -PbugcapsuleGitDirty=<bool> (or uses --apk/--test-apk).
  2. Per attempt: pm clear, adb reverse tcp:<port>, then am instrument -r -w -e bugcapsuleReplayUrl <url> -e class dev.bugcapsule.android.replay.BugCapsuleReplayTest <testAppId>/<runner>.
  3. BugCapsuleReplayRunner.onCreate (before Application.onCreate) fetches settings and capsule, refuses unless the app is debuggable, and restores SharedPreferences/DataStore.
  4. BugCapsule.init starts in replay mode. The test launches the launcher activity with ActivityScenario and runs the in-app engine: pointers via sendPointerSync (dp → px, scaled if the screen differs), Back via sendKeySync, actions and text on the main thread, lifecycle by moving the scenario (resumed→RESUMED, inactive→STARTED, paused→CREATED).
  5. The result goes to POST <base>/result and to instrumentation status bugcapsule_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

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).

BugTriggerWhy replay needs BugCapsule
Data-dependent: item with price: null1 in 4 carts; CheckoutThe recorded server response
Negative totalCoupon SAVE100 on a cart under 100check("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 cartThe 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