An in-app debug panel for Android. Wrap your root composable once, open the sheet with a three-finger tap, and inspect logs, HTTP traffic, auth tokens, push tokens, permissions and performance without leaving the app. Release builds ship a no-op variant, so none of it ends up in production.
Made by Apps with love.
- Debug sheet opens and closes with a three-finger tap
- Logs: Timber and HTTP entries, filter by query, export to file
- HTTP inspector via an OkHttp interceptor
- Deeplinks: fire ad-hoc or preconfigured deeplinks from the sheet
- Session: inspect, copy, edit or delete auth tokens per provider
- FCM push token: display, refresh, revoke (you supply the source, no Firebase dependency)
- Permissions, performance and accessibility modules
- Test crashes
- App info from your build config plus custom key/value pairs
- Custom modules: append your own composables to the sheet
- No-op artifact for release builds
dependencies {
debugImplementation("com.appswithlove.debug:debug:0.6.1")
releaseImplementation("com.appswithlove.debug:debug-no-op:0.6.1")
}Two lines, one per build type: the debug build gets the real panel, the release build gets a stub with the same API and empty bodies. Your app code stays identical for both. Details in How the artifacts fit together.
All artifacts are on Maven Central. New Android projects already have it configured:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}No token, no extra repository.
debugImplementation and releaseImplementation only cover the two default build types. If your app declares more (staging, qa, ...), each one needs its own line, otherwise that build type has neither artifact and fails to compile:
stagingImplementation("com.appswithlove.debug:debug:0.6.1") // internal build: real panel
qaImplementation("com.appswithlove.debug:debug-no-op:0.6.1") // goes to external testers: stubRule of thumb: debug for builds that stay inside the team, debug-no-op for anything that leaves the building.
awl-debug ships as a real + no-op pair with a shared API, the same pattern LeakCanary and Chucker use. Your app calls DevSheet { ... }, Timber.plant(DevLogTree()) and DevHttpLogInterceptor() unconditionally. Which implementation ends up in the APK is decided by Gradle per build type, not by if (BuildConfig.DEBUG) checks in your code.
| Artifact | Contents | Weight |
|---|---|---|
debug-api |
Contract types only: DevConfig, DevModulesConfig, DeeplinkItem, SessionProvider, FcmTokenProvider. Pulled in transitively by both AARs, never referenced directly. |
a few KB |
debug |
The real panel: Compose sheet, three-finger gesture, log collector, HTTP interceptor, all modules, charts. | ~430 KB plus its Compose, Material 3 and Vico dependencies |
debug-no-op |
The same public functions and classes with empty bodies. DevSheet renders your content and nothing else; DevLogTree and DevHttpLogInterceptor discard everything. Depends only on debug-api. |
~14 KB |
Both AARs expose identical signatures under identical package names, so the same call sites compile against either. The shared types live in debug-api exactly once, so the two AARs can never collide on the classpath.
What this buys you:
- Release builds contain no debug UI, gesture detection or log buffering. Not disabled: absent from the binary.
- The Session module can read and overwrite auth tokens. That code does not exist in production builds.
- None of the panel's UI dependencies (Vico charts, extra Material 3, DataStore) are added to your release APK.
- No conditionals in app code. Plant the tree, add the interceptor, wrap your root composable, done.
The contract types live under com.appswithlove.debug.api.*, for example com.appswithlove.debug.api.DevConfig and com.appswithlove.debug.api.session.SessionProvider.
Wrap DevSheet around your top-level composable. The sheet opens with a three-finger tap while the dev tool is activated (see Activate and deactivate).
DevSheet(
devConfig = DevConfig(
versionName = BuildConfig.VERSION_NAME,
versionCode = BuildConfig.VERSION_CODE,
flavor = BuildConfig.FLAVOR,
buildType = BuildConfig.BUILD_TYPE,
),
) {
MyApp()
}| Parameter | Default | Description |
|---|---|---|
devConfig |
null |
Build info shown in the App Info module: version name and code, flavor, build type, plus optional customParams: Map<String, String> |
modulesConfig |
DevModulesConfig() |
Which built-in modules are shown and their inputs, see Modules |
customModules |
empty | Composable lambda appended at the bottom of the sheet, see Custom modules |
isAlwaysActive |
false |
When true, the three-finger tap works even if the dev tool is deactivated |
swipeToDismiss |
false |
When true, the sheet can be dismissed by swiping down |
inheritHostTheme |
false |
When true, the sheet uses the host app's MaterialTheme instead of its own |
logHistory |
LogCollector.logHistory |
Log source to display; override to use a custom collector instance |
modifier |
Modifier |
Applied to the wrapping container |
DevModulesConfig controls which modules appear. Everything is enabled by default; modules that need input (deeplinks, session, FCM) hide themselves when nothing is provided.
DevSheet(
modulesConfig = DevModulesConfig(
showPerformanceModule = false,
showAccessibilityModule = false,
)
) {
MyApp()
}| Field | Default | Module |
|---|---|---|
showLogsModule |
true |
Logs (Timber + HTTP) |
showPerformanceModule |
true |
Performance |
showPermissionsModule |
true |
Permissions |
showAccessibilityModule |
true |
Accessibility |
showDeeplinksModule / deeplinks |
true / empty |
Deeplinks |
showSessionModule / sessionProviders |
true / empty |
Session, hidden when the list is empty |
showFcmPushTokenModule / fcmTokenProvider |
true / null |
FCM push token, hidden when null |
showTestCrashesModule |
true |
Test crashes |
Fire deeplinks straight from the sheet to test navigation. Enter a URL and tap Shoot, or preconfigure a list for one-tap access:
DevSheet(
modulesConfig = DevModulesConfig(
deeplinks = listOf(
DeeplinkItem(label = "Home", url = "myapp://home"),
DeeplinkItem(label = "Profile", url = "myapp://profile/123"),
)
)
) {
MyApp()
}Inspect and manipulate the current auth state per token source:
- Shows logged in / logged out status per provider
- Copies the current token to the clipboard
- Edits the token (for example paste an invalid one to trigger a refresh), only when
onSetTokenis provided - Deletes the token to break the session, only when
onSetTokenis provided
Implement SessionProvider for each token source and pass the list:
DevSheet(
modulesConfig = DevModulesConfig(
sessionProviders = listOf(
object : SessionProvider {
override val label = "Auth Token"
override val token: Flow<String?> = authStorage.tokenFlow
// optional: omit to hide the Edit and Delete buttons
override val onSetToken: suspend (String?) -> Unit = { authStorage.setToken(it) }
},
object : SessionProvider {
override val label = "Refresh Token"
override val token: Flow<String?> = authStorage.refreshTokenFlow
override val onSetToken: suspend (String?) -> Unit = { authStorage.setRefreshToken(it) }
},
)
)
) {
MyApp()
}token must emit whenever the value changes (login, logout, refresh); that is how the module stays in sync without manual refreshes. The module is hidden when sessionProviders is empty.
Displays the current push token and, optionally, lets you refresh or revoke it. The library has no Firebase dependency; you supply the token and the actions:
DevSheet(
modulesConfig = DevModulesConfig(
fcmTokenProvider = object : FcmTokenProvider {
override val token: Flow<String?> = fcmStorage.tokenFlow
// optional: omit to hide the button
override val onRefresh: suspend () -> Unit = {
FirebaseMessaging.getInstance().deleteToken().await()
fcmStorage.set(FirebaseMessaging.getInstance().token.await())
}
// optional: omit to hide the button
override val onRevoke: suspend () -> Unit = {
FirebaseMessaging.getInstance().deleteToken().await()
fcmStorage.set(null)
}
}
)
) {
MyApp()
}token must emit whenever it changes so the displayed value stays current. Each button only appears when its action is provided. The module is hidden when fcmTokenProvider is null.
Append your own composables to the bottom of the sheet:
DevSheet(
customModules = {
Text("Feature flags: ${flags.joinToString()}")
}
) {
MyApp()
}Add DevHttpLogInterceptor to your OkHttpClient. Every request and response shows up in the Logs module:
val client = OkHttpClient.Builder()
.addInterceptor(DevHttpLogInterceptor())
.build()Plant DevLogTree in Application.onCreate() to forward Timber logs to the panel:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree(), DevLogTree())
}
}DevLogTree only logs to the panel. Keep Timber.DebugTree() alongside it if you also want logcat output.
The three-finger tap only opens the sheet while the dev tool is activated, unless isAlwaysActive = true. The flag is persisted on the device. Toggle it from your own UI, for example a hidden switch in settings:
val context = LocalContext.current
val scope = rememberCoroutineScope()
val isActivated by DevSettings.isDevToolActivated(context).collectAsState(initial = false)
Switch(
checked = isActivated,
onCheckedChange = { scope.launch { DevSettings.activateDevTool(context, it) } },
)DevSettings.activateDevTool(context, Boolean) is a suspend function; DevSettings.isDevToolActivated(context) returns a Flow<Boolean>. Inside the sheet, the App Info module offers the same toggle.
Build and test:
./gradlew build
Run the sample app from sample/ to try the panel. It plants DevLogTree, fires an HTTP request and exposes a switch that activates the dev tool.
To test an unreleased version in another project, publish to your local Maven repository. The flag skips GPG signing, which is only needed for Maven Central:
./gradlew publishToMavenLocal -PRELEASE_SIGNING_ENABLED=false
Then, in the consuming project, put mavenLocal() first in its repositories and depend on the version from gradle.properties:
repositories {
mavenLocal()
google()
mavenCentral()
}
dependencies {
debugImplementation("com.appswithlove.debug:debug:<VERSION_NAME>")
releaseImplementation("com.appswithlove.debug:debug-no-op:<VERSION_NAME>")
}- Bump
VERSION_NAMEingradle.propertiesand merge tomainvia pull request. - Open a pull request from
maintoproduction. It requires one approval. - Merging runs the
Publishworkflow: build and test, sign, upload all three artifacts to Maven Central, create a GitHub release tagged with the version.
The publish job waits for the Central Portal to validate the deployment and fails with the portal's reasons if it is rejected. Artifacts appear on Maven Central roughly 15 to 30 minutes after a successful run.
Copyright 2024 Apps with love AG
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.