On-device debug toolkit for Android apps.
Inspect network traffic, view exceptions, monitor performance, edit SharedPreferences, switch environments, toggle feature flags — all from a floating bubble overlay, with zero impact on production builds.
Lens is published on Maven Central — no repository credentials needed.
In your app/build.gradle.kts, use build-type-specific configurations:
// app/build.gradle.kts
debugImplementation("com.lokalapps.lens:lens:1.2.1")
releaseImplementation("com.lokalapps.lens:lens-noop:1.2.1")If you have a releaseDebug build type (a release-signed APK with debug tools enabled), include Lens there too:
debugImplementation("com.lokalapps.lens:lens:1.2.1")
"releaseDebugImplementation"("com.lokalapps.lens:lens:1.2.1")
releaseImplementation("com.lokalapps.lens:lens-noop:1.2.1")If you use Lens APIs (e.g. Lens.getNetworkInterceptor()) inside a library module, use a different dependency pattern. Do not use implementation(lens-noop) in library modules — it puts the noop on all variant runtimes, causing a duplicate-class crash in debug when the full lens artifact is also present.
Use compileOnly for the noop so it only provides the API surface at compile time:
// core/build.gradle.kts or any library module
compileOnly("com.lokalapps.lens:lens-noop:1.2.1") // compile-time API surface only
debugImplementation("com.lokalapps.lens:lens:1.2.1")
"releaseDebugImplementation"("com.lokalapps.lens:lens:1.2.1")
releaseImplementation("com.lokalapps.lens:lens-noop:1.2.1")Why
compileOnly? In library modules without product flavours,implementationadds the dependency to every variant's runtime classpath. When bothlens-noop(fromimplementation) andlens(fromdebugImplementation) land on the debug runtime classpath simultaneously, the AGPcheckDuplicateClassestask fails.compileOnlycontributes only to the compile classpath — the runtime artifact is supplied by whichever consuming module (:app) resolves the correct variant.
Call Lens.install() in your Application.onCreate(). It takes an Application instance — not a Context:
import com.lokalapps.lens.api.ActivationGesture
import com.lokalapps.lens.api.Lens
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
Lens.install(this) {
activationGesture = ActivationGesture.FIVE_TAP
showNotification = true
}
}
}val client = OkHttpClient.Builder()
.addInterceptor(Lens.getNetworkInterceptor())
.build()That's it. Tap 5 times anywhere to open the debug dashboard.
| Artifact | Purpose | Use with |
|---|---|---|
com.lokalapps.lens:lens |
Full SDK — all plugins, interceptors, and UI | debugImplementation |
com.lokalapps.lens:lens-noop |
No-op stubs — identical API surface, zero behavior | releaseImplementation / compileOnly in library modules |
com.lokalapps.lens:lens-api |
Pure-Kotlin interfaces — no Android dependency | Transitive (pulled automatically) |
| Plugin | Description |
|---|---|
| Network Inspector | HTTP request/response viewer with cURL export, header redaction |
| Global Search | Cross-plugin search across all log types with 300ms debounce |
| App Info | Build info, device details, session metadata |
| Performance Monitor | Real-time FPS (Choreographer), memory usage, jank detection with sparkline graphs |
| Analytics Inspector | Intercepted analytics events and user properties with Firebase limit validation |
| Exception Tracker | Uncaught + handled exceptions with stack traces, ANR detection (5s watchdog) |
| Database Inspector | Browse and query SQLite databases |
| SharedPreferences Editor | View and edit all SharedPreferences files |
| Deep Link Tester | Fire deep links without leaving the app |
| Log Viewer | Timber log viewer with level filtering |
| Cache Manager | View and clear app caches |
These appear only when you supply a provider implementation:
| Plugin | Provider Interface |
|---|---|
| Environment Switcher | EnvironmentProvider |
| Feature Flags Editor | FeatureFlagProvider |
| Quick Actions | QuickActionsProvider |
All configuration is done through the DSL passed to Lens.install(). Provider-based plugins are
wired via functions on the builder — not property assignment:
import com.lokalapps.lens.api.ActivationGesture
import com.lokalapps.lens.api.HeaderRedactor
import com.lokalapps.lens.api.Lens
Lens.install(this) {
// Activation gesture: THREE_TAP, FIVE_TAP (default), LONG_PRESS, or NONE
activationGesture = ActivationGesture.FIVE_TAP
// Shake to open (independent of activationGesture)
shakeToOpenEnabled = true
// Sticky notification with live request/error counts
showNotification = true
// Remote kill switch — disable Lens without shipping an app update.
// The lambda receives a callback; invoke it with true to enable, false to disable.
remoteActivation { callback ->
val enabled = FirebaseRemoteConfig.getInstance().getBoolean("devtools_enabled")
callback(enabled)
}
// Redact sensitive headers in network logs.
// Return true for any header name whose value should be replaced with "[REDACTED]".
headerRedactor(HeaderRedactor { name ->
name.equals("Authorization", ignoreCase = true)
})
// Provider-based plugins — wire via functions, not property assignment
environments(MyEnvironmentProvider())
featureFlags(MyFeatureFlagProvider())
quickActions(MyQuickActionsProvider())
}Builder API note:
remoteActivation,headerRedactor,environments,featureFlags, andquickActionsare functions on the builder, not writable properties. Assigning them as properties (e.g.headerRedactor = ...) will not compile.
| Method | How |
|---|---|
| 3-tap | Tap anywhere 3 times quickly (ActivationGesture.THREE_TAP) |
| 5-tap | Tap anywhere 5 times quickly (ActivationGesture.FIVE_TAP) — default |
| Long press | Long-press anywhere (ActivationGesture.LONG_PRESS) |
| Shake | Set shakeToOpenEnabled = true in the DSL |
| Programmatic | Lens.open() |
| Notification | Tap the sticky notification (requires showNotification = true) |
| Floating bubble | Always visible — injected into every Activity's DecorView (no permissions needed) |
The Environment Switcher plugin lets you switch API base URLs at runtime without rebuilding the APK. Lens provides the UI (including the confirmation dialog and restart button) — you implement persistence and restart.
class MyEnvironmentProvider(private val context: Context) : EnvironmentProvider {
private val prefs = context.getSharedPreferences("lens_env", Context.MODE_PRIVATE)
private val environments = listOf(
Environment(id = "prod", name = "Production", description = "Live servers", baseUrl = "https://api.example.com/"),
Environment(id = "staging", name = "Staging", description = "Staging servers", baseUrl = "https://staging.example.com/"),
Environment(id = "dev", name = "Development", description = "Local server", baseUrl = "http://10.0.2.2:8080/")
)
private val defaultId = if (BuildConfig.DEBUG) "dev" else "prod"
override fun getEnvironments() = environments
override fun getCurrentEnvironment(): Environment {
val id = prefs.getString("env_id", null) ?: defaultId
return environments.find { it.id == id } ?: environments.first()
}
override fun setEnvironment(environment: Environment) {
// commit() not apply() — the process is killed right after this call.
// apply() is async and may not flush to disk before the process dies.
prefs.edit().putString("env_id", environment.id).commit()
}
override fun onRestartRequested() {
// Fire a launch intent before killing so the app cold-starts cleanly.
// A bare killProcess() can restore the previous task stack without going
// through Application.onCreate(), leaving DI singletons on the old base URL.
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
intent?.let { context.startActivity(it) }
Process.killProcess(Process.myPid())
}
}Lens.install(this) {
environments(MyEnvironmentProvider(this))
}The switch only takes effect if your network layer reads the persisted value at startup — BuildConfig fields are compile-time constants and can't reflect a runtime selection. Your base URL source must check SharedPrefs before falling back to BuildConfig:
fun resolveBaseUrl(context: Context): String {
val prefs = context.getSharedPreferences("lens_env", Context.MODE_PRIVATE)
return when (prefs.getString("env_id", null)) {
"staging" -> "https://staging.example.com/"
"dev" -> "http://10.0.2.2:8080/"
else -> "https://api.example.com/"
}
}If you use Hilt or Dagger with @Singleton Retrofit/OkHttp instances, call this at component creation time (e.g., inside your @Provides method). The new URL takes effect after the restart because the DI graph is rebuilt from scratch on cold start.
Note: Switching environments logs the user out if your auth tokens are environment-scoped. This is expected — production and staging backends have separate auth systems.
The Deep Link Tester lets you fire any deep link without leaving the app. You can type a full URL or a relative path — Lens prefixes the correct scheme and host automatically.
The Quick Links section is optional and app-specific. Implement DeepLinkProvider to populate it with your own shortcuts.
class MyDeepLinkProvider : DeepLinkProvider {
override fun getQuickLinks() = listOf(
DeepLink(label = "Home", path = "/home"),
DeepLink(label = "Profile", path = "/profile"),
DeepLink(label = "Payment", path = "/payment"),
)
}Paths can be relative (/home) or absolute (myapp://myapp.com/home). Relative paths are prefixed with the app's scheme and host automatically.
Lens.install(this) {
deepLinks(MyDeepLinkProvider())
}Without a provider, the Quick Links section is hidden and the manual URL input still works.
The Analytics Inspector captures every event and user property sent through AnalyticsEventListenerLocator. No extra setup is needed — it works automatically once Lens is initialized.
Firebase Analytics silently drops or truncates data that violates its limits — no error is returned to the caller. Lens validates every event and user property destined for Firebase and surfaces violations inline:
- Amber left border on the event card in the list — catches your eye immediately while scanning
- Violations banner at the top of the event detail view — lists every issue with a plain-English explanation
- Per-parameter highlighting — offending parameters turn amber with the exact reason inline
Validation only runs for events where destinations contains "FIREBASE". MoEngage, Adjust, etc. are not affected.
| What | Limit | Consequence if violated |
|---|---|---|
| Event name length | 40 chars | Event dropped |
| Event name characters | [a-zA-Z][a-zA-Z0-9_]* |
Event dropped |
| Reserved event name | See Firebase docs | Event dropped |
Reserved prefix (firebase_, ga_, google_) |
— | Event dropped |
| Parameters per event | 25 | Extra params dropped |
| Parameter name length | 40 chars | Parameter dropped |
| Parameter name characters | [a-zA-Z][a-zA-Z0-9_]* |
Parameter dropped |
| Reserved parameter name | session_id, user_id, etc. |
Parameter dropped |
| Parameter value length (string) | 100 chars | Value truncated |
| User property name length | 24 chars | Property dropped |
| User property name characters | [a-zA-Z][a-zA-Z0-9_]* |
Property dropped |
| Reserved user property name | Age, Gender, Interest |
Property dropped |
| User property value length (string) | 36 chars | Value truncated |
// Capture WebView navigations
webView.webViewClient = Lens.wrapWebViewClient(myWebViewClient)
// Capture WebSocket frames
val listener = Lens.wrapWebSocketListener(myListener)import com.lokalapps.lens.api.ComposableLensPlugin
import com.lokalapps.lens.api.Lens
class MyDebugPlugin : ComposableLensPlugin {
override val id = "my_debug"
override val name = "My Debug Tool"
override val icon = R.drawable.ic_my_debug
override val description = "Custom debugging tool"
override val priority = 40
@Composable
override fun Content() {
Text("Hello from my plugin!")
}
}
// Register after Lens.install()
Lens.registerPlugin(MyDebugPlugin())For non-Compose consumers (React Native native modules, Java apps):
import com.lokalapps.lens.api.LensExperimental
import com.lokalapps.lens.api.ViewLensPlugin
@OptIn(LensExperimental::class)
class LegacyPlugin : ViewLensPlugin {
override val id = "legacy"
override val name = "Legacy Tool"
override val icon = R.drawable.ic_legacy
override val description = "View-based debug tool"
override fun createView(context: Context): View {
return TextView(context).apply { text = "Hello from Views" }
}
}Runtime configuration store usable by custom plugins:
Lens.putString("my_key", "my_value")
Lens.putBoolean("feature_enabled", true)
val value = Lens.getString("my_key", default = "fallback")
val enabled = Lens.getBoolean("feature_enabled", default = false)Backed by SharedPreferences in debug builds, no-op in release.
lens-api Pure Kotlin module — interfaces, data classes, annotations
lens Android library — full implementation, all plugins, Compose UI
lens-noop Android library — no-op stubs matching the public API surface
No Hilt, no Dagger, no reflection. Lens uses a lightweight internal service locator with zero impact on your app's DI graph.
Consumer rules are bundled — no manual configuration needed.
| Feature | Lens | Chucker | Flipper | Hyperion |
|---|---|---|---|---|
| Network inspector | Yes | Yes | Yes | Yes |
| SharedPreferences editor | Yes | No | Yes | Yes |
| Exception viewer + ANR | Yes | No | No | Yes* |
| FPS / Memory monitoring | Yes | No | No | No |
| Environment switcher | Yes | No | No | No |
| Feature flag editor | Yes | No | No | No |
| Analytics inspector | Yes | No | No | No |
| Database inspector | Yes | No | Yes | No |
| Global search | Yes | No | No | No |
| Custom plugin API | Yes | No | Yes | Yes |
| No-op release variant | Yes | Yes | N/A | Yes |
| No external tools needed | Yes | Yes | No (ADB) | Yes |
| No DI framework required | Yes | Yes | Yes | No |
| Version | |
|---|---|
| Min SDK | 24 (Android 7.0) |
| Compile SDK | 37 |
| Kotlin | 2.0+ |
| Jetpack Compose | BOM-managed |
To iterate on the SDK locally without publishing:
cd lens-android-sdk
./gradlew publishToMavenLocalThen temporarily add mavenLocal() above mavenCentral() in your app's settings.gradle.kts. Maven Local takes priority, so your local build will be used.
// settings.gradle.kts — local development only, do not commit
repositories {
mavenLocal() // must be first
mavenCentral()
google()
}Copyright 2026 Lokal
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