docs

Tools

Buoy.start turns on every tool. Some tools see your app right away. Others need a line of code to reach your app's data. Each part below says how to connect the tool, and what Android leaves out next to React Native.

Network

Add BuoyNetwork.interceptor to your OkHttp client:

kotlin
OkHttpClient.Builder().addInterceptor(BuoyNetwork.interceptor).build()
OkHttpClient.Builder().addInterceptor(BuoyNetwork.interceptor).build()

Retrofit, Coil and Glide run on OkHttp, so their calls show up too. HttpURLConnection and Ktor's CIO engine are not seen.

Fake answers can change how the app acts. Try them on a call you can throw away. Turn them off when you are done.

Slow-network modes live in Network's menu. They work while Network is shrunk or capture is paused.

Console

Console shows the app's own log lines from Log and println. It reads them with logcat for the app's own process, so it needs no permission. Android's own system lines are left out.

BuoyConsole.log(...) keeps the values you pass and where the call came from, like console.log in React Native.

Storage

Storage shows the app's default SharedPreferences file. Add other files by name:

kotlin
BuoyStorage.registerSharedPreferences("settings")
BuoyStorage.registerSharedPreferences("settings")

For MMKV, hand over a small adapter with BuoyStorage.registerMMKVInstance(...). For a secure store, use BuoyStorage.registerSecureStore(store, keys). BuoySecureStore.of(prefs) turns an EncryptedSharedPreferences into one. Buoy adds no MMKV or security library to your app.

BuoyStorage.configure(requiredKeys = ...) lists the keys your app expects.

Env

kotlin
BuoyEnv.configure(
    vars = BuoyEnv.buildConfigValues(BuildConfig::class.java),
    required = listOf(BuoyRequiredEnvVar.type("API_URL", "url")),
)
BuoyEnv.configure(
    vars = BuoyEnv.buildConfigValues(BuildConfig::class.java),
    required = listOf(BuoyRequiredEnvVar.type("API_URL", "url")),
)

buildConfigValues reads the fields in your BuildConfig. BUOY_LICENSE_KEY is always hidden.

Impersonate

BuoyImpersonate.configure(onSearchUsers = ...) lets Buoy search your users. Buoy adds a header to calls that go through its interceptor. Your server must check that the change is allowed.

Buoy can't clear your app's saved data for it. Pass onClearData and your app does that part.

Image Overlay

Lay a design image over the app. To line it up with a view, tag the view with Modifier.testTag("image-target:Profile photo"). Or drag the image anywhere.

Images

Use BuoyAsyncImage to test load, fail, retry, blank and swap states:

kotlin
BuoyAsyncImage(model = imageUrl, contentDescription = "Avatar", modifier = Modifier.size(64.dp))
BuoyAsyncImage(model = imageUrl, contentDescription = "Avatar", modifier = Modifier.size(64.dp))

BuoyAsyncImage uses Coil 3. Your app needs io.coil-kt.coil3:coil-compose and io.coil-kt.coil3:coil-network-okhttp to load web images.

To see plain Coil images too, add Buoy's hook to your ImageLoader:

kotlin
ImageLoader.Builder(context)
    .components { add(BuoyImagesCoil.interceptor) }
    .eventListenerFactory(BuoyImagesCoil.eventListenerFactory)
    .build()
ImageLoader.Builder(context)
    .components { add(BuoyImagesCoil.interceptor) }
    .eventListenerFactory(BuoyImagesCoil.eventListenerFactory)
    .build()

Per-image buttons, like reload or force an error, only work on BuoyAsyncImage. Coil doesn't report download progress.

Notifications

kotlin
BuoyNotifications.install(this)                       // Application.onCreate
BuoyNotifications.observeDeviceToken(token)           // FirebaseMessagingService.onNewToken
BuoyNotifications.observeRemoteMessage(id, title, body, data)  // onMessageReceived
BuoyNotifications.notify(context, id, notification)   // in place of NotificationManager.notify
BuoyNotifications.install(this)                       // Application.onCreate
BuoyNotifications.observeDeviceToken(token)           // FirebaseMessagingService.onNewToken
BuoyNotifications.observeRemoteMessage(id, title, body, data)  // onMessageReceived
BuoyNotifications.notify(context, id, notification)   // in place of NotificationManager.notify

Buoy can't see a direct call to NotificationManager.notify. A test alert set for later is lost if the app is killed first.

Routes

Give Buoy your routes and your navigation:

kotlin
BuoyRoutes.configure(
    routes = listOf("/", "/pokemon/[name]", "/settings"),
    navigate = { href, replace -> router.go(href, replace); true },
    goBack = { router.back() },
    stack = { router.entries.map { BuoyRoutes.stackItem(it.id, it.href) } },
)
BuoyRoutes.configure(
    routes = listOf("/", "/pokemon/[name]", "/settings"),
    navigate = { href, replace -> router.go(href, replace); true },
    goBack = { router.back() },
    stack = { router.entries.map { BuoyRoutes.stackItem(it.id, it.href) } },
)

navigate returns true when it moved the app. Call BuoyRoutes.refreshStack() when your stack changes. With no stack to hand over, call BuoyRoutes.screenAppeared(path) on each screen.

Scenarios

Ship a QA menu of saved app setups:

kotlin
BuoyScenarios.configure(currentBuild = BuildConfig.VERSION_NAME)
BuoyScenarios.defineAll(assets.open("scenarios.json").reader().readText())
BuoyScenarios.configure(currentBuild = BuildConfig.VERSION_NAME)
BuoyScenarios.defineAll(assets.open("scenarios.json").reader().readText())

Recording a new scenario is not on Android yet.

Time Machine

Time Machine saves SharedPreferences, MMKV, secure keys and the current screen. Add your own state with a provider:

kotlin
BuoyTimeMachine.registerProvider(BuoySnapshotProvider(id = "cart", label = "Cart", capture = { ... }, restoreItem = { ... }))
BuoyTimeMachine.registerProvider(BuoySnapshotProvider(id = "cart", label = "Cart", capture = { ... }, restoreItem = { ... }))

A reload builds the screen again. The app process keeps running. Pass your own reload to BuoyTimeMachine.configure(onReload = ...) for a full restart.

Events

Events shows network, storage and route activity in one list. It has no Redux, Zustand, Jotai, React Query or render source.

Assets

Assets reads the installed APK. It shows sizes, copies and baselines.

The Images tool marks assets it sees load. Mark anything else with BuoyAssets.markLoaded(...). Load fonts with BuoyAssets.loadFont(context, path) so they show as loaded.

Clock

Android can't change the system clock for you. Read time through Buoy, and the Clock tool can move it:

kotlin
val expired = BuoyClock.nowMs() >= session.expiresAt
BuoyClock.after(60.0) { showReminder() }
val expired = BuoyClock.nowMs() >= session.expiresAt
BuoyClock.after(60.0) { showReminder() }

System time stays real, and so do animations.

Lifecycle

Hand over the hooks where real and fake events both land:

kotlin
BuoyLifecycle.configure(
    appState = { state -> session.onAppState(state) },
    deepLink = { url -> router.open(url) },
    colorScheme = { scheme -> theme.override = scheme },
)
BuoyLifecycle.configure(
    appState = { state -> session.onAppState(state) },
    deepLink = { url -> router.open(url) },
    colorScheme = { scheme -> theme.override = scheme },
)

Leaving the app sends "inactive" and then "background", like iOS. A reload builds the screen again. The app process keeps running.

Location

Read location through BuoyLocationProvider:

kotlin
val location = BuoyLocationProvider(context)
location.onLocations = { fixes -> map.show(fixes.last()) }
location.startUpdatingLocation()
val location = BuoyLocationProvider(context)
location.onLocations = { fixes -> map.show(fixes.last()) }
location.startUpdatingLocation()

With the tool off, every call goes to the device. Real fixes still need your app's own location permission. Heading comes from the fix's bearing, not a compass.

Permissions

Check and ask through Buoy:

kotlin
when (BuoyPermissions.check("camera")) {
    "granted", "limited" -> openCamera()
    "undetermined" -> if (BuoyPermissions.request("camera") == "granted") openCamera()
    else -> showSettingsButton()
}
when (BuoyPermissions.check("camera")) {
    "granted", "limited" -> openCamera()
    "undetermined" -> if (BuoyPermissions.request("camera") == "granted") openCamera()
    else -> showSettingsButton()
}

Android can't tell "never asked" from "blocked". Buoy remembers the asks that go through it.

Bench

Bench shows a speed HUD and saves test runs you can compare. Batch runs move through the app with BuoyRoutes.configure.

Android has no Reanimated, so that row is gone. Compose gives no render times.

Highlight

Mark the views you want counted:

kotlin
Column(Modifier.buoyTrackRenders("CartTotal", props = mapOf("total" to total)))
Column(Modifier.buoyTrackRenders("CartTotal", props = mapOf("total" to total)))

Only marked views are counted. React Native sees every view. Render times and hook details are not there.

Debug Borders

Press BORDERS in the floating row. It cycles through outlines, labels and off. Borders show on views that have a test tag or a label.

Ask Buoy

Point Ask Buoy at your model endpoint:

kotlin
BuoyAskBuoy.configure(AskBuoyConfig(endpoint = "https://ai.example.com/v1/messages", model = "your-model"))
BuoyAskBuoy.configure(AskBuoyConfig(endpoint = "https://ai.example.com/v1/messages", model = "your-model"))

Ask Buoy can't take real files yet, on Android or React Native. Its "Choose a file" button sends a stand-in name.